From 2f2ae357261f7f883697255aae392563550f60bf Mon Sep 17 00:00:00 2001 From: gitadityakumar Date: Wed, 2 Sep 2026 22:41:38 +0530 Subject: [PATCH 1/3] feat(providers): add Wikimedia Commons provider --- src/providers/wikimedia.zig | 259 ++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 src/providers/wikimedia.zig diff --git a/src/providers/wikimedia.zig b/src/providers/wikimedia.zig new file mode 100644 index 0000000..84984e2 --- /dev/null +++ b/src/providers/wikimedia.zig @@ -0,0 +1,259 @@ +//! Wikimedia Commons provider — free CC0, public domain, and freely-licensed educational/stock media. +//! Search: GET https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrnamespace=6&gsrsearch={query}&gsrlimit={limit}&prop=imageinfo&iiprop=url|size|mime&iiurlwidth=500&format=json +//! Download: Direct master full-resolution media from upload.wikimedia.org. + +const std = @import("std"); +const asset_mod = @import("../asset.zig"); +const http_client = @import("../http_client.zig"); +const download_mod = @import("../download.zig"); +const Allocator = std.mem.Allocator; +const Io = std.Io; +const Asset = asset_mod.Asset; +const SearchResult = asset_mod.SearchResult; + +pub const id = "wikimedia"; +pub const name = "Wikimedia Commons"; + +const RankedAsset = struct { + index: i64, + asset: Asset, +}; + +fn sortRanked(_: void, a: RankedAsset, b: RankedAsset) bool { + return a.index < b.index; +} + +fn stripQueryParam(url: []const u8) []const u8 { + if (std.mem.indexOfScalar(u8, url, '?')) |q| { + return url[0..q]; + } + return url; +} + +fn cleanTitle(allocator: Allocator, raw_title: []const u8) ![]u8 { + var title = raw_title; + if (std.mem.startsWith(u8, title, "File:")) { + title = title["File:".len..]; + } + if (std.mem.lastIndexOfScalar(u8, title, '.')) |dot| { + title = title[0..dot]; + } + const res = try allocator.dupe(u8, title); + for (res) |*c| { + if (c.* == '_') c.* = ' '; + } + return res; +} + +fn parseSearch(allocator: Allocator, json_bytes: []const u8, limit: u32) ![]Asset { + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_bytes, .{}); + defer parsed.deinit(); + + const root = parsed.value; + if (root != .object) return &[_]Asset{}; + const query_val = root.object.get("query") orelse return &[_]Asset{}; + if (query_val != .object) return &[_]Asset{}; + const pages_val = query_val.object.get("pages") orelse return &[_]Asset{}; + if (pages_val != .object) return &[_]Asset{}; + + var ranked_list: std.ArrayList(RankedAsset) = .empty; + errdefer { + for (ranked_list.items) |*r| r.asset.deinit(allocator); + ranked_list.deinit(allocator); + } + + var it = pages_val.object.iterator(); + while (it.next()) |entry| { + const page = entry.value_ptr.*; + if (page != .object) continue; + + const pageid_val = page.object.get("pageid"); + const page_id_int: i64 = if (pageid_val) |pid| switch (pid) { + .integer => |i| i, + else => 0, + } else 0; + + const index_val = page.object.get("index"); + const rank_index: i64 = if (index_val) |idx| switch (idx) { + .integer => |i| i, + else => 999999, + } else 999999; + + const title_val = page.object.get("title"); + const title_str = if (title_val) |t| switch (t) { + .string => |s| s, + else => "", + } else ""; + + const imageinfo_val = page.object.get("imageinfo"); + if (imageinfo_val == null or imageinfo_val.? != .array or imageinfo_val.?.array.items.len == 0) { + continue; + } + + const info = imageinfo_val.?.array.items[0]; + if (info != .object) continue; + + const url_val = info.object.get("url") orelse continue; + if (url_val != .string) continue; + const raw_img_url = url_val.string; + const img_url_clean = stripQueryParam(raw_img_url); + + const thumb_val = info.object.get("thumburl"); + const raw_thumb_url = if (thumb_val) |tv| switch (tv) { + .string => |s| s, + else => raw_img_url, + } else raw_img_url; + const thumb_url_clean = stripQueryParam(raw_thumb_url); + + const width_val = info.object.get("width"); + const width: ?u32 = if (width_val) |w| switch (w) { + .integer => |i| if (i > 0) @intCast(i) else null, + else => null, + } else null; + + const height_val = info.object.get("height"); + const height: ?u32 = if (height_val) |h| switch (h) { + .integer => |i| if (i > 0) @intCast(i) else null, + else => null, + } else null; + + const pid_str = try std.fmt.allocPrint(allocator, "wikimedia-{d}", .{page_id_int}); + errdefer allocator.free(pid_str); + + const cleaned_title = try cleanTitle(allocator, title_str); + errdefer allocator.free(cleaned_title); + + const prompt_str = try allocator.dupe(u8, cleaned_title); + errdefer allocator.free(prompt_str); + + const master_url = try allocator.dupe(u8, img_url_clean); + errdefer allocator.free(master_url); + + const thumb_url = try allocator.dupe(u8, thumb_url_clean); + errdefer allocator.free(thumb_url); + + const prov_owned = try allocator.dupe(u8, id); + errdefer allocator.free(prov_owned); + + const auth_owned = try allocator.dupe(u8, "Wikimedia Commons Contributors"); + errdefer allocator.free(auth_owned); + + var meta_aw: Io.Writer.Allocating = .init(allocator); + defer meta_aw.deinit(); + try meta_aw.writer.print("{{\"source\":\"wikimedia\",\"pageid\":{d},\"page\":\"https://commons.wikimedia.org/?curid={d}\"}}", .{ page_id_int, page_id_int }); + const meta = try meta_aw.toOwnedSlice(); + + try ranked_list.append(allocator, .{ + .index = rank_index, + .asset = .{ + .id = pid_str, + .description = cleaned_title, + .prompt = prompt_str, + .image_url = master_url, + .thumbnail_url = thumb_url, + .provider = prov_owned, + .author = auth_owned, + .width = width, + .height = height, + .metadata_json = meta, + }, + }); + } + + std.mem.sort(RankedAsset, ranked_list.items, {}, sortRanked); + + const take_count = @min(ranked_list.items.len, @as(usize, limit)); + const result = try allocator.alloc(Asset, take_count); + for (0..take_count) |i| { + result[i] = ranked_list.items[i].asset; + } + for (take_count..ranked_list.items.len) |i| { + ranked_list.items[i].asset.deinit(allocator); + } + ranked_list.deinit(allocator); + + return result; +} + +pub fn search( + client: *std.http.Client, + allocator: Allocator, + query: []const u8, + limit: u32, +) !SearchResult { + const trimmed = std.mem.trim(u8, query, " \t\n\r"); + if (trimmed.len == 0) return error.EmptyQuery; + + const enc = try http_client.queryEscape(allocator, trimmed); + defer allocator.free(enc); + + const lim = @min(@max(limit, 1), 50); + + const search_url = try std.fmt.allocPrint( + allocator, + "https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrnamespace=6&gsrsearch={s}&gsrlimit={d}&prop=imageinfo&iiprop=url|size|mime&iiurlwidth=500&format=json", + .{ enc, lim }, + ); + defer allocator.free(search_url); + + var resp = try http_client.get(client, allocator, search_url, .{ + .accept = "application/json", + .max_redirects = 5, + }); + defer resp.deinit(); + if (resp.status != 200) return error.HttpStatus; + + const assets = try parseSearch(allocator, resp.body, lim); + + return .{ + .assets = assets, + .total = null, + .provider = try allocator.dupe(u8, id), + .query = try allocator.dupe(u8, trimmed), + .allocator = allocator, + }; +} + +pub fn download( + client: *std.http.Client, + allocator: Allocator, + io: Io, + a: Asset, + output_dir: []const u8, +) !download_mod.Saved { + const url = a.image_url orelse return error.NoUrl; + const body = http_client.getBody(client, allocator, url, .{ + .accept = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + .max_redirects = 5, + }) catch |err| switch (err) { + error.RateLimited => return error.RateLimited, + error.HttpStatus => return error.HttpStatus, + error.OutOfMemory => return error.OutOfMemory, + else => return error.Network, + }; + defer allocator.free(body); + if (body.len == 0) return error.HttpStatus; + + try download_mod.ensureDir(io, output_dir); + const ext = download_mod.guessExtension(url); + const path = try download_mod.uniquePath(allocator, io, output_dir, a.id, ext); + errdefer allocator.free(path); + const cwd = Io.Dir.cwd(); + cwd.writeFile(io, .{ .sub_path = path, .data = body }) catch return error.Io; + return .{ + .path = path, + .filename = std.fs.path.basename(path), + .bytes = body.len, + .allocator = allocator, + }; +} + +pub fn getPrompt(a: Asset) ?[]const u8 { + if (a.prompt) |p| if (p.len > 0) return p; + if (a.description.len > 0) return a.description; + return null; +} + +pub fn getUrl(a: Asset) ?[]const u8 { + return a.image_url; +} From 6d5bdb727b37b09918a18b13fba2740d2fab424d Mon Sep 17 00:00:00 2001 From: gitadityakumar Date: Wed, 2 Sep 2026 22:41:41 +0530 Subject: [PATCH 2/3] feat(registry): register wikimedia provider and wire search and interactive CLI --- src/commands/search.zig | 9 +++++++++ src/interactive.zig | 9 +++++++++ src/providers/registry.zig | 4 +++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/commands/search.zig b/src/commands/search.zig index d2c2658..9d8e783 100644 --- a/src/commands/search.zig +++ b/src/commands/search.zig @@ -24,6 +24,7 @@ const moveast = @import("../providers/moveast.zig"); const cupcake = @import("../providers/cupcake.zig"); const freenaturestock = @import("../providers/freenaturestock.zig"); const goodfreephotos = @import("../providers/goodfreephotos.zig"); +const wikimedia = @import("../providers/wikimedia.zig"); const stdio = @import("../stdio.zig"); const Allocator = std.mem.Allocator; const Io = std.Io; @@ -108,6 +109,9 @@ fn doSearch( if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) { return goodfreephotos.search(client, allocator, query, limit); } + if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) { + return wikimedia.search(client, allocator, query, limit); + } return error.UnknownProvider; } @@ -182,6 +186,9 @@ fn doDownload( if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) { return goodfreephotos.download(client, allocator, io, a, output_dir); } + if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) { + return wikimedia.download(client, allocator, io, a, output_dir); + } return error.UnknownProvider; } @@ -207,6 +214,7 @@ fn doPrompt(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) return cupcake.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) return freenaturestock.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) return goodfreephotos.getPrompt(a); + if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) return wikimedia.getPrompt(a); return a.prompt orelse a.description; } @@ -232,6 +240,7 @@ fn doUrl(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) return cupcake.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) return freenaturestock.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) return goodfreephotos.getUrl(a); + if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) return wikimedia.getUrl(a); return a.image_url; } diff --git a/src/interactive.zig b/src/interactive.zig index e107fe4..76308bf 100644 --- a/src/interactive.zig +++ b/src/interactive.zig @@ -24,6 +24,7 @@ const moveast = @import("providers/moveast.zig"); const cupcake = @import("providers/cupcake.zig"); const freenaturestock = @import("providers/freenaturestock.zig"); const goodfreephotos = @import("providers/goodfreephotos.zig"); +const wikimedia = @import("providers/wikimedia.zig"); const registry = @import("providers/registry.zig"); const stdio = @import("stdio.zig"); const Allocator = std.mem.Allocator; @@ -105,6 +106,9 @@ fn searchProvider( if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) { return goodfreephotos.search(client, allocator, query, config.default_limit); } + if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) { + return wikimedia.search(client, allocator, query, config.default_limit); + } return error.UnknownProvider; } @@ -178,6 +182,9 @@ fn downloadAsset( if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) { return goodfreephotos.download(client, allocator, io, a, config.default_output_dir); } + if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) { + return wikimedia.download(client, allocator, io, a, config.default_output_dir); + } return error.UnknownProvider; } @@ -203,6 +210,7 @@ fn promptOf(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) return cupcake.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) return freenaturestock.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) return goodfreephotos.getPrompt(a); + if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) return wikimedia.getPrompt(a); return a.prompt; } @@ -228,6 +236,7 @@ fn urlOf(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) return cupcake.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) return freenaturestock.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) return goodfreephotos.getUrl(a); + if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) return wikimedia.getUrl(a); return a.image_url; } diff --git a/src/providers/registry.zig b/src/providers/registry.zig index 86dc95f..f0d5cea 100644 --- a/src/providers/registry.zig +++ b/src/providers/registry.zig @@ -22,6 +22,7 @@ const moveast = @import("moveast.zig"); const cupcake = @import("cupcake.zig"); const freenaturestock = @import("freenaturestock.zig"); const goodfreephotos = @import("goodfreephotos.zig"); +const wikimedia = @import("wikimedia.zig"); pub const ProviderInfo = struct { id: []const u8, @@ -51,6 +52,7 @@ pub fn list() []const ProviderInfo { .{ .id = cupcake.id, .name = cupcake.name }, .{ .id = freenaturestock.id, .name = freenaturestock.name }, .{ .id = goodfreephotos.id, .name = goodfreephotos.name }, + .{ .id = wikimedia.id, .name = wikimedia.name }, }; } @@ -69,5 +71,5 @@ pub fn displayName(id: []const u8) ?[]const u8 { } pub fn availableIds() []const u8 { - return "aura, unsplash, isorepublic, picjumbo, foodiesfeed, picography, gratisography, startupstockphotos, burst, jaymantri, publicdomainarchive, magdeleine, splitshire, deviantart, negativespace, skitterphoto, libreshot, moveast, cupcake, freenaturestock, goodfreephotos"; + return "aura, unsplash, isorepublic, picjumbo, foodiesfeed, picography, gratisography, startupstockphotos, burst, jaymantri, publicdomainarchive, magdeleine, splitshire, deviantart, negativespace, skitterphoto, libreshot, moveast, cupcake, freenaturestock, goodfreephotos, wikimedia"; } From 06183803171c5a866d721827784fa5760b0c9985 Mon Sep 17 00:00:00 2001 From: gitadityakumar Date: Wed, 2 Sep 2026 22:41:44 +0530 Subject: [PATCH 3/3] docs: update provider reference and tracker for Wikimedia Commons --- README.md | 2 ++ docs/providers.md | 16 ++++++++++++++++ docs/stock_photo_sites.md | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5837d0d..174ebe1 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ Provider and query may be given as positionals after `s` / `-s`, or with `-P` / | `cupcake` | [Cupcake](https://cupcake.nilssonlee.se) | Free CC0 stock photography by Jonas Nilsson Lee | Direct master resolution photo downloads. | | `freenaturestock` | [Free Nature Stock](https://freenaturestock.com) | Free CC0 nature & wildlife stock photography by Adrian Pelletier | Direct master full-resolution photo downloads (up to 20+ MB). | | `goodfreephotos` | [Good Free Photos](https://goodfreephotos.com) | Free CC0 public domain photos, state parks, and landmarks by Yinan Chen | Direct master full-resolution photo downloads. | +| `wikimedia` | [Wikimedia Commons](https://commons.wikimedia.org) | Freely licensed and public domain educational & stock media repository | Direct master full-resolution image downloads. | List providers at runtime via `ast help` (printed under **PROVIDERS**). @@ -312,6 +313,7 @@ src/ cupcake.zig freenaturestock.zig goodfreephotos.zig + wikimedia.zig assets/ README banner and static assets docs/ Provider implementation notes install.sh curl | bash installer (Linux release binaries) diff --git a/docs/providers.md b/docs/providers.md index 144844f..2747c54 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -259,3 +259,19 @@ This document summarizes the network endpoints, transport models, and image down * Master full-resolution URLs constructed by rewriting `/cache/` to `/albums/` and stripping thumbnail geometry tokens (`_200_w200_..._thumb`). * **Download**: Direct GET on master original camera photo JPEG files. * **License**: CC0 / 100% Free Public Domain. + +--- + +## 22. Wikimedia Commons (`commons.wikimedia.org`) + +* **Search**: `GET https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrnamespace=6&gsrsearch={query}&gsrlimit={limit}&prop=imageinfo&iiprop=url|size|mime&iiurlwidth=500&format=json` +* **Format**: Official MediaWiki Action API querying Wikimedia's file repository (namespace 6). +* **Extraction**: + * Sorted ascending by search relevance rank (`index`). + * File IDs formatted as `wikimedia-{pageid}`. + * Titles cleaned by stripping `File:` prefix and file extensions, and replacing underscores with spaces. + * Full original master media URLs extracted from `imageinfo.url` (with query tracking stripped). + * 500px thumbnail URLs extracted from `imageinfo.thumburl`. + * Dimensions extracted from `imageinfo.width` and `imageinfo.height`. +* **Download**: Direct GET on full master resolution media via `upload.wikimedia.org`. +* **License**: Public Domain, CC0, and Creative Commons. diff --git a/docs/stock_photo_sites.md b/docs/stock_photo_sites.md index 3c2899e..279bf6c 100644 --- a/docs/stock_photo_sites.md +++ b/docs/stock_photo_sites.md @@ -8,7 +8,7 @@ This document tracks all 24 stock photo websites listed in the [GrayGrids 21+ Be - **Total Sites Listed**: 24 - **Implemented & Working**: 13 (`unsplash`, `isorepublic`, `picjumbo`, `foodiesfeed`, `picography`, `gratisography`, `startupstockphotos`, `burst`, `jaymantri`, `publicdomainarchive`, `magdeleine`, `splitshire`, `deviantart`) -- *(Additional Providers Implemented in `ast`)*: `aura` ([Aura.build](https://www.aura.build)), `negativespace` ([NegativeSpace.co](https://negativespace.co)), `skitterphoto` ([Skitterphoto.com](https://skitterphoto.com)), `libreshot` ([LibreShot.com](https://libreshot.com)), `moveast` ([Moveast.me](https://moveast.me)), `cupcake` ([Cupcake](https://cupcake.nilssonlee.se)), `freenaturestock` ([FreeNatureStock](https://freenaturestock.com)), `goodfreephotos` ([Good Free Photos](https://goodfreephotos.com)) +- *(Additional Providers Implemented in `ast`)*: `aura` ([Aura.build](https://www.aura.build)), `negativespace` ([NegativeSpace.co](https://negativespace.co)), `skitterphoto` ([Skitterphoto.com](https://skitterphoto.com)), `libreshot` ([LibreShot.com](https://libreshot.com)), `moveast` ([Moveast.me](https://moveast.me)), `cupcake` ([Cupcake](https://cupcake.nilssonlee.se)), `freenaturestock` ([FreeNatureStock](https://freenaturestock.com)), `goodfreephotos` ([Good Free Photos](https://goodfreephotos.com)), `wikimedia` ([Wikimedia Commons](https://commons.wikimedia.org)) - **Unimplemented / Blocked / Pending**: 11 ---