Skip to content

Commit 12a5a91

Browse files
Wikimedia commons (#201)
* - Add Wikimedia Commons app with Pic of the Day (POTD), Pic from on this day (in the past), Random Previous Pic of the day, and Random Pic * Add Wikimedia Commons sample scene * scene * img --------- Co-authored-by: DeftDawg <deftdawg@gmail.com>
1 parent c5f6f2a commit 12a5a91

8 files changed

Lines changed: 707 additions & 1 deletion

File tree

frameos/src/apps/apps.nim

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import apps/data/rotateImage/app_loader as data_rotateImage_loader
2121
import apps/data/rstpSnapshot/app_loader as data_rstpSnapshot_loader
2222
import apps/data/unsplash/app_loader as data_unsplash_loader
2323
import apps/data/weather/app_loader as data_weather_loader
24+
import apps/data/wikicommons/app_loader as data_wikicommons_loader
2425
import apps/data/xmlToJson/app_loader as data_xmlToJson_loader
2526
import apps/logic/breakIfRendering/app_loader as logic_breakIfRendering_loader
2627
import apps/logic/ifElse/app_loader as logic_ifElse_loader
@@ -59,6 +60,7 @@ proc initApp*(keyword: string, node: DiagramNode, scene: FrameScene): AppRoot =
5960
of "data/rstpSnapshot": data_rstpSnapshot_loader.init(node, scene)
6061
of "data/unsplash": data_unsplash_loader.init(node, scene)
6162
of "data/weather": data_weather_loader.init(node, scene)
63+
of "data/wikicommons": data_wikicommons_loader.init(node, scene)
6264
of "data/xmlToJson": data_xmlToJson_loader.init(node, scene)
6365
of "logic/breakIfRendering": logic_breakIfRendering_loader.init(node, scene)
6466
of "logic/ifElse": logic_ifElse_loader.init(node, scene)
@@ -98,6 +100,7 @@ proc setAppField*(keyword: string, app: AppRoot, field: string, value: Value) =
98100
of "data/rstpSnapshot": data_rstpSnapshot_loader.setField(app, field, value)
99101
of "data/unsplash": data_unsplash_loader.setField(app, field, value)
100102
of "data/weather": data_weather_loader.setField(app, field, value)
103+
of "data/wikicommons": data_wikicommons_loader.setField(app, field, value)
101104
of "data/xmlToJson": data_xmlToJson_loader.setField(app, field, value)
102105
of "logic/breakIfRendering": logic_breakIfRendering_loader.setField(app, field, value)
103106
of "logic/ifElse": logic_ifElse_loader.setField(app, field, value)
@@ -153,6 +156,7 @@ proc getApp*(keyword: string, app: AppRoot, context: ExecutionContext): Value =
153156
of "data/rstpSnapshot": data_rstpSnapshot_loader.get(app, context)
154157
of "data/unsplash": data_unsplash_loader.get(app, context)
155158
of "data/weather": data_weather_loader.get(app, context)
159+
of "data/wikicommons": data_wikicommons_loader.get(app, context)
156160
of "data/xmlToJson": data_xmlToJson_loader.get(app, context)
157161
of "render/calendar": render_calendar_loader.get(app, context)
158162
of "render/color": render_color_loader.get(app, context)
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import pixie
2+
import std/[httpclient, json, options, random, sequtils, strformat, strutils, times, uri]
3+
import frameos/apps
4+
import frameos/types
5+
import frameos/utils/http_client
6+
import frameos/utils/image
7+
8+
const
9+
CommonsApiUrl = "https://commons.wikimedia.org/w/api.php"
10+
CommonsUserAgent = "FrameOS Wikimedia Commons app (https://github.com/FrameOS/frameos)"
11+
MaxCommonsResponseBytes = 2 * 1024 * 1024
12+
MaxCommonsImageBytes = 20 * 1024 * 1024
13+
FirstPotdYear = 2008
14+
15+
type
16+
AppConfig* = object
17+
mode*: string
18+
submode*: string
19+
saveAssets*: string
20+
metadataStateKey*: string
21+
22+
App* = ref object of AppRoot
23+
appConfig*: AppConfig
24+
25+
CommonsDate = object
26+
year: int
27+
month: int
28+
day: int
29+
30+
CommonsImage = object
31+
title: string
32+
imageUrl: string
33+
pageUrl: string
34+
description: string
35+
author: string
36+
license: string
37+
mime: string
38+
39+
proc init*(self: App) =
40+
randomize()
41+
self.appConfig.mode = self.appConfig.mode.strip()
42+
self.appConfig.submode = self.appConfig.submode.strip()
43+
self.appConfig.metadataStateKey = self.appConfig.metadataStateKey.strip()
44+
45+
proc error*(self: App, context: ExecutionContext, message: string): Image =
46+
self.logError(message)
47+
result = renderError(if context.hasImage: context.image.width else: self.frameConfig.renderWidth(),
48+
if context.hasImage: context.image.height else: self.frameConfig.renderHeight(), message)
49+
50+
proc commonsHeaders(): HttpHeaders =
51+
newHttpHeaders([
52+
("Accept", "application/json"),
53+
("User-Agent", CommonsUserAgent),
54+
])
55+
56+
proc queryString(params: openArray[(string, string)]): string =
57+
params.mapIt(encodeUrl(it[0]) & "=" & encodeUrl(it[1])).join("&")
58+
59+
proc fetchCommonsJson(params: openArray[(string, string)]): JsonNode =
60+
var allParams = @[
61+
("format", "json"),
62+
("formatversion", "2")
63+
]
64+
allParams.add(params)
65+
let body = boundedGetContent(
66+
CommonsApiUrl & "?" & queryString(allParams),
67+
headers = commonsHeaders(),
68+
timeoutMs = 60000,
69+
maxBytes = MaxCommonsResponseBytes,
70+
maxSeconds = 60
71+
)
72+
result = parseJson(body)
73+
if result.hasKey("error"):
74+
let message = result["error"]{"info"}.getStr($result["error"])
75+
raise newException(CatchableError, "Wikimedia Commons API error: " & message)
76+
77+
proc isLeapYear(year: int): bool =
78+
(year mod 4 == 0 and year mod 100 != 0) or year mod 400 == 0
79+
80+
proc daysInMonth(year: int, month: int): int =
81+
case month
82+
of 1, 3, 5, 7, 8, 10, 12: 31
83+
of 4, 6, 9, 11: 30
84+
of 2: (if isLeapYear(year): 29 else: 28)
85+
else: 0
86+
87+
proc todayDate(): CommonsDate =
88+
let current = now()
89+
CommonsDate(year: current.year, month: current.month.int, day: current.monthday.int)
90+
91+
proc dateString(date: CommonsDate): string =
92+
&"{date.year}-{date.month:02d}-{date.day:02d}"
93+
94+
proc randomPreviousDate(today: CommonsDate): CommonsDate =
95+
let year = rand(FirstPotdYear..today.year)
96+
let maxMonth = if year == today.year: today.month else: 12
97+
let month = rand(1..maxMonth)
98+
var maxDay = daysInMonth(year, month)
99+
if year == today.year and month == today.month:
100+
maxDay = min(maxDay, today.day)
101+
CommonsDate(year: year, month: month, day: rand(1..maxDay))
102+
103+
proc randomOnThisDay(today: CommonsDate): CommonsDate =
104+
let maxYear = max(FirstPotdYear, today.year - 1)
105+
for _ in 0 ..< 100:
106+
let year = rand(FirstPotdYear..maxYear)
107+
if today.day <= daysInMonth(year, today.month):
108+
return CommonsDate(year: year, month: today.month, day: today.day)
109+
raise newException(CatchableError, "No previous Wikimedia Commons picture of the day exists for this date.")
110+
111+
proc stripHtml(value: string): string =
112+
var inTag = false
113+
for ch in value:
114+
case ch
115+
of '<':
116+
inTag = true
117+
of '>':
118+
inTag = false
119+
else:
120+
if not inTag:
121+
result.add(ch)
122+
result = result.replace("&quot;", "\"")
123+
result = result.replace("&amp;", "&")
124+
result = result.replace("&#039;", "'")
125+
result = result.replace("&apos;", "'")
126+
result = result.replace("&lt;", "<")
127+
result = result.replace("&gt;", ">")
128+
result = result.strip()
129+
130+
proc metadataValue(imageInfo: JsonNode, key: string): string =
131+
let metadata = imageInfo{"extmetadata"}
132+
if metadata.kind == JObject and metadata.hasKey(key):
133+
return metadata[key]{"value"}.getStr().stripHtml()
134+
""
135+
136+
proc imageExtension(image: CommonsImage): string =
137+
let urlPath = image.imageUrl.split("?")[0].toLowerAscii()
138+
for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]:
139+
if urlPath.endsWith(ext):
140+
return if ext == ".jpeg": ".jpg" else: ext
141+
case image.mime
142+
of "image/jpeg": ".jpg"
143+
of "image/png": ".png"
144+
of "image/gif": ".gif"
145+
of "image/webp": ".webp"
146+
of "image/svg+xml": ".svg"
147+
else: ".img"
148+
149+
proc imageFromPage(page: JsonNode): Option[CommonsImage] =
150+
let info = page{"imageinfo"}{0}
151+
if info.kind != JObject:
152+
return none(CommonsImage)
153+
154+
let mime = info{"mime"}.getStr()
155+
if not mime.startsWith("image/"):
156+
return none(CommonsImage)
157+
158+
let imageUrl = info{"thumburl"}.getStr(info{"url"}.getStr())
159+
if imageUrl == "":
160+
return none(CommonsImage)
161+
162+
let title = page{"title"}.getStr()
163+
let description = metadataValue(info, "ImageDescription")
164+
result = some(CommonsImage(
165+
title: title,
166+
imageUrl: imageUrl,
167+
pageUrl: info{"descriptionurl"}.getStr(),
168+
description: if description != "": description else: metadataValue(info, "ObjectName"),
169+
author: metadataValue(info, "Artist"),
170+
license: metadataValue(info, "LicenseShortName"),
171+
mime: mime
172+
))
173+
174+
proc firstImageFromQuery(json: JsonNode): CommonsImage =
175+
let pages = json{"query"}{"pages"}
176+
if pages.kind == JArray:
177+
for page in pages:
178+
let image = imageFromPage(page)
179+
if image.isSome:
180+
return image.get()
181+
raise newException(CatchableError, "No supported image returned from Wikimedia Commons.")
182+
183+
proc fetchPotdImage(date: CommonsDate, thumbnailWidth: int): CommonsImage =
184+
firstImageFromQuery(fetchCommonsJson([
185+
("action", "query"),
186+
("generator", "images"),
187+
("titles", "Template:Potd/" & date.dateString()),
188+
("gimlimit", "20"),
189+
("prop", "imageinfo"),
190+
("iiprop", "url|mime|size|extmetadata"),
191+
("iiurlwidth", $thumbnailWidth)
192+
]))
193+
194+
proc fetchRandomImage(thumbnailWidth: int): CommonsImage =
195+
firstImageFromQuery(fetchCommonsJson([
196+
("action", "query"),
197+
("generator", "random"),
198+
("grnnamespace", "6"),
199+
("grnlimit", "20"),
200+
("prop", "imageinfo"),
201+
("iiprop", "url|mime|size|extmetadata"),
202+
("iiurlwidth", $thumbnailWidth)
203+
]))
204+
205+
proc normalizedMode(self: App): string =
206+
case self.appConfig.mode
207+
of "", "potd", "pictureOfTheDay":
208+
case self.appConfig.submode
209+
of "", "day": "pictureOfTheDay"
210+
of "onthisday", "onThisDay": "onThisDay"
211+
of "month", "random", "randomPotd", "randomPictureOfTheDay": "randomPictureOfTheDay"
212+
else: self.appConfig.mode
213+
of "random": "randomImage"
214+
else: self.appConfig.mode
215+
216+
proc fetchImageForMode(self: App, thumbnailWidth: int): CommonsImage =
217+
let today = todayDate()
218+
case self.normalizedMode()
219+
of "pictureOfTheDay":
220+
fetchPotdImage(today, thumbnailWidth)
221+
of "onThisDay":
222+
fetchPotdImage(randomOnThisDay(today), thumbnailWidth)
223+
of "randomPictureOfTheDay":
224+
var lastError = ""
225+
for _ in 0 ..< 10:
226+
try:
227+
return fetchPotdImage(randomPreviousDate(today), thumbnailWidth)
228+
except CatchableError as err:
229+
lastError = err.msg
230+
raise newException(CatchableError, "Could not find a random Wikimedia Commons picture of the day: " & lastError)
231+
of "randomImage":
232+
var lastError = ""
233+
for _ in 0 ..< 10:
234+
try:
235+
return fetchRandomImage(thumbnailWidth)
236+
except CatchableError as err:
237+
lastError = err.msg
238+
raise newException(CatchableError, "Could not find a random Wikimedia Commons image: " & lastError)
239+
else:
240+
raise newException(ValueError, "Invalid Wikimedia Commons mode: " & self.appConfig.mode)
241+
242+
proc get*(self: App, context: ExecutionContext): Image =
243+
let width = if context.hasImage: context.image.width else: self.frameConfig.renderWidth()
244+
let height = if context.hasImage: context.image.height else: self.frameConfig.renderHeight()
245+
246+
try:
247+
let commonsImage = self.fetchImageForMode(max(max(width, height), 1))
248+
249+
if self.frameConfig.debug:
250+
self.log(&"Downloading Wikimedia Commons image: {commonsImage.imageUrl}")
251+
252+
let imageData = boundedGetContent(
253+
commonsImage.imageUrl,
254+
headers = newHttpHeaders([("User-Agent", CommonsUserAgent)]),
255+
timeoutMs = 60000,
256+
maxBytes = MaxCommonsImageBytes,
257+
maxSeconds = 60
258+
)
259+
260+
if self.appConfig.metadataStateKey != "":
261+
self.scene.state[self.appConfig.metadataStateKey] = %*{
262+
"source": "wikimedia-commons",
263+
"mode": self.normalizedMode(),
264+
"title": commonsImage.title,
265+
"description": commonsImage.description,
266+
"author": commonsImage.author,
267+
"license": commonsImage.license,
268+
"pageUrl": commonsImage.pageUrl,
269+
"imageUrl": commonsImage.imageUrl,
270+
"mime": commonsImage.mime
271+
}
272+
273+
if self.appConfig.saveAssets == "auto" or self.appConfig.saveAssets == "always":
274+
discard self.saveAsset(commonsImage.title.replace("File:", ""), commonsImage.imageExtension(),
275+
imageData, self.appConfig.saveAssets == "auto")
276+
277+
result = decodeImageWithFallback(imageData)
278+
except CatchableError as e:
279+
return self.error(context, "Error fetching image from Wikimedia Commons: " & e.msg)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "Wikimedia Commons",
3+
"description": "Images from Wikimedia Commons",
4+
"category": "data",
5+
"version": "1.0.0",
6+
"fields": [
7+
{
8+
"name": "mode",
9+
"type": "select",
10+
"value": "pictureOfTheDay",
11+
"options": ["pictureOfTheDay", "onThisDay", "randomPictureOfTheDay", "randomImage"],
12+
"required": false,
13+
"label": "Mode",
14+
"hint": "Choose today's Picture of the Day, the Picture of the Day from this date in a previous year, a random previous Picture of the Day, or a random Commons image."
15+
},
16+
{
17+
"name": "saveAssets",
18+
"type": "select",
19+
"value": "auto",
20+
"options": ["auto", "always", "never"],
21+
"label": "Save asset",
22+
"hint": "Save the generated image to disk as an asset. It'll be placed into the frame's assets folder.\n\nYou can later use the 'Local image' app to view saved assets.\n\nIf set to 'auto', the image will be saved if the frame is set to save assets. If set to 'always', the image will always be saved. If set to 'never', the image will never be saved."
23+
},
24+
{
25+
"name": "metadataStateKey",
26+
"type": "string",
27+
"value": "",
28+
"required": false,
29+
"label": "Metadata state key",
30+
"placeholder": "e.g. wikimediaMetadata"
31+
}
32+
],
33+
"output": [
34+
{
35+
"name": "image",
36+
"type": "image"
37+
}
38+
],
39+
"cache": {
40+
"enabled": true,
41+
"inputEnabled": true,
42+
"durationEnabled": true,
43+
"duration": "3600"
44+
}
45+
}

0 commit comments

Comments
 (0)