From a157da408d2898f046f52b8ecf323f4519ac7229 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 22 Feb 2026 11:28:09 +0000 Subject: [PATCH 01/39] feat: Show word definitions after game completion (#99) Add a definition card to the stats modal that shows the meaning of the daily word after win/loss. Fetches from native Wiktionary first, falls back to English Wiktionary REST API, then shows a "Look up" link. - New definitions.ts module with Wiktionary API client + fallback chain - "Show Definitions" toggle in Settings (default ON, persisted to localStorage) - Definition card with loading skeleton, dark mode support - Server-side AI image generation endpoint (DALL-E, behind OPENAI_API_KEY) - Images cached to disk, only generated for today's word - 7 new unit tests for definition fetching --- .gitignore | 3 + frontend/src/__tests__/definitions.test.ts | 157 ++++++++++ frontend/src/definitions.ts | 277 +++++++++++++++++ frontend/src/game.ts | 56 ++++ frontend/src/types/index.ts | 13 + pyproject.toml | 1 + tests/test_language_config.py | 2 +- tests/test_word_lists.py | 2 +- uv.lock | 334 +++++++++++++++++++++ webapp/app.py | 58 ++++ webapp/data/default_language_config.json | 6 +- webapp/templates/game.html | 13 + 12 files changed, 919 insertions(+), 3 deletions(-) create mode 100644 frontend/src/__tests__/definitions.test.ts create mode 100644 frontend/src/definitions.ts diff --git a/.gitignore b/.gitignore index c9bcf22c..92b6ccdd 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ test-results/ # FrequencyWords data (downloaded by scripts/improve_word_lists.py) scripts/.freq_data/ + +# AI-generated word images (cached by word-image endpoint) +webapp/static/word-images/ diff --git a/frontend/src/__tests__/definitions.test.ts b/frontend/src/__tests__/definitions.test.ts new file mode 100644 index 00000000..f5afbe1f --- /dev/null +++ b/frontend/src/__tests__/definitions.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fetchDefinition } from '../definitions'; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe('fetchDefinition', () => { + it('returns English Wiktionary definition for English words', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + English: [ + { + partOfSpeech: 'Noun', + definitions: [{ definition: 'A large container.' }], + }, + ], + }), + }); + + const result = await fetchDefinition('crane', 'en'); + expect(result.source).toBe('english'); + expect(result.definition).toBe('A large container.'); + expect(result.partOfSpeech).toBe('Noun'); + expect(result.word).toBe('crane'); + expect(result.url).toContain('en.wiktionary.org'); + }); + + it('tries native Wiktionary first for non-English languages', async () => { + // First call: native Wiktionary succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + query: { + pages: { + '123': { + extract: 'Ein großer Vogel.', + }, + }, + }, + }), + }); + + const result = await fetchDefinition('krane', 'de'); + expect(result.source).toBe('native'); + expect(result.definition).toBe('Ein großer Vogel.'); + expect(result.url).toContain('de.wiktionary.org'); + // Should only have called native, not English + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('falls back to English Wiktionary when native fails', async () => { + // Native Wiktionary returns no page + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + query: { + pages: { + '-1': { missing: '' }, + }, + }, + }), + }); + + // English Wiktionary succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + German: [ + { + partOfSpeech: 'Noun', + definitions: [{ definition: 'A crane.' }], + }, + ], + }), + }); + + const result = await fetchDefinition('krane', 'de'); + expect(result.source).toBe('english'); + expect(result.definition).toBe('A crane.'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('returns link fallback when both sources fail', async () => { + // Native fails + mockFetch.mockResolvedValueOnce({ ok: false }); + // English fails + mockFetch.mockResolvedValueOnce({ ok: false }); + + const result = await fetchDefinition('xyz', 'de'); + expect(result.source).toBe('link'); + expect(result.definition).toBe(''); + expect(result.url).toContain('en.wiktionary.org'); + }); + + it('handles network errors gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await fetchDefinition('test', 'de'); + expect(result.source).toBe('link'); + }); + + it('maps Norwegian Bokmål to Norwegian Wiktionary', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + query: { + pages: { + '456': { + extract: 'En fugl.', + }, + }, + }, + }), + }); + + const result = await fetchDefinition('fugle', 'nb'); + expect(result.source).toBe('native'); + // Should use 'no' wiktionary, not 'nb' + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain('no.wiktionary.org'); + }); + + it('strips HTML from English Wiktionary definitions', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + English: [ + { + partOfSpeech: 'Noun', + definitions: [ + { + definition: + 'A large container.', + }, + ], + }, + ], + }), + }); + + const result = await fetchDefinition('crane', 'en'); + expect(result.definition).toBe('A large container.'); + }); +}); diff --git a/frontend/src/definitions.ts b/frontend/src/definitions.ts new file mode 100644 index 00000000..1c254f36 --- /dev/null +++ b/frontend/src/definitions.ts @@ -0,0 +1,277 @@ +/** + * Word Definitions - Wiktionary API client + * Fetches word definitions after game completion + */ +import type { WordDefinition } from './types'; + +// Map our language codes to Wiktionary language codes where they differ +const WIKTIONARY_LANG_MAP: Record = { + nb: 'no', // Norwegian Bokmål → Norwegian + nn: 'no', // Norwegian Nynorsk → Norwegian + hyw: 'hy', // Western Armenian → Armenian + ckb: 'ku', // Central Kurdish → Kurdish +}; + +// Map our language codes to Wiktionary "language name" used in the REST API response +// The English Wiktionary REST API returns definitions keyed by language name, not code +const WIKTIONARY_LANGUAGE_NAMES: Record = { + en: 'English', + fi: 'Finnish', + ar: 'Arabic', + tr: 'Turkish', + hr: 'Croatian', + bg: 'Bulgarian', + de: 'German', + he: 'Hebrew', + sv: 'Swedish', + ru: 'Russian', + hu: 'Hungarian', + es: 'Spanish', + et: 'Estonian', + da: 'Danish', + sr: 'Serbian', + ro: 'Romanian', + ca: 'Catalan', + sk: 'Slovak', + it: 'Italian', + az: 'Azerbaijani', + fr: 'French', + lv: 'Latvian', + la: 'Latin', + gl: 'Galician', + mk: 'Macedonian', + uk: 'Ukrainian', + pt: 'Portuguese', + vi: 'Vietnamese', + pl: 'Polish', + hy: 'Armenian', + nb: 'Norwegian Bokmål', + nn: 'Norwegian Nynorsk', + sl: 'Slovenian', + nl: 'Dutch', + cs: 'Czech', + hyw: 'Armenian', + fa: 'Persian', + eu: 'Basque', + gd: 'Scottish Gaelic', + ga: 'Irish', + ko: 'Korean', + ka: 'Georgian', + is: 'Icelandic', + ckb: 'Kurdish', + el: 'Greek', + lt: 'Lithuanian', + mn: 'Mongolian', + ia: 'Interlingua', + mi: 'Maori', + lb: 'Luxembourgish', + br: 'Breton', + ne: 'Nepali', + eo: 'Esperanto', + fy: 'West Frisian', + nds: 'Low German', + fo: 'Faroese', + oc: 'Occitan', + tk: 'Turkmen', +}; + +function getWiktionaryLang(lang: string): string { + return WIKTIONARY_LANG_MAP[lang] || lang; +} + +function getWiktionaryUrl(word: string, lang: string): string { + const wikiLang = getWiktionaryLang(lang); + return `https://${wikiLang}.wiktionary.org/wiki/${encodeURIComponent(word)}`; +} + +function getEnWiktionaryUrl(word: string): string { + return `https://en.wiktionary.org/wiki/${encodeURIComponent(word)}`; +} + +/** + * Strip HTML tags from a string + */ +function stripHtml(html: string): string { + const div = document.createElement('div'); + div.innerHTML = html; + return div.textContent || div.innerText || ''; +} + +/** + * Try to fetch definition from English Wiktionary REST API. + * Returns definitions for the target language (not English definitions). + */ +async function fetchFromEnWiktionary(word: string, lang: string): Promise { + const url = `https://en.wiktionary.org/api/rest_v1/page/definition/${encodeURIComponent(word)}`; + + try { + const response = await fetch(url); + if (!response.ok) return null; + + const data = await response.json(); + + // The API returns definitions grouped by language name + // Try to find our target language + const langName = WIKTIONARY_LANGUAGE_NAMES[lang]; + if (!langName || !data[langName]) return null; + + const langDefs = data[langName]; + if (!Array.isArray(langDefs) || langDefs.length === 0) return null; + + // Get first part of speech entry + const firstEntry = langDefs[0]; + const partOfSpeech = firstEntry.partOfSpeech || undefined; + + // Get first definition + const definitions = firstEntry.definitions; + if (!Array.isArray(definitions) || definitions.length === 0) return null; + + const defText = stripHtml(definitions[0].definition || ''); + if (!defText) return null; + + return { + word, + partOfSpeech, + definition: defText, + source: 'english', + url: getEnWiktionaryUrl(word), + }; + } catch { + return null; + } +} + +/** + * Try to fetch definition from native-language Wiktionary. + * Uses MediaWiki API to get the page extract. + */ +async function fetchFromNativeWiktionary( + word: string, + lang: string +): Promise { + const wikiLang = getWiktionaryLang(lang); + const url = `https://${wikiLang}.wiktionary.org/w/api.php?action=query&titles=${encodeURIComponent(word)}&prop=extracts&explaintext=1&exintro=1&format=json&origin=*`; + + try { + const response = await fetch(url); + if (!response.ok) return null; + + const data = await response.json(); + const pages = data?.query?.pages; + if (!pages) return null; + + // Get the first (and usually only) page + const pageId = Object.keys(pages)[0]; + if (!pageId || pageId === '-1') return null; + + const extract = pages[pageId]?.extract; + if (!extract || typeof extract !== 'string') return null; + + // Take first meaningful line as definition (skip empty lines) + const lines = extract.split('\n').filter((l: string) => l.trim().length > 0); + const definition = lines[0]?.trim(); + if (!definition || definition.length < 3) return null; + + // Truncate very long definitions + const truncated = + definition.length > 200 ? definition.substring(0, 200) + '...' : definition; + + return { + word, + definition: truncated, + source: 'native', + url: getWiktionaryUrl(word, lang), + }; + } catch { + return null; + } +} + +/** + * Fetch a word definition with fallback strategy: + * 1. Try native Wiktionary (definition in player's language) + * 2. Try English Wiktionary REST API (structured, reliable) + * 3. Return a "look up" link as last resort + */ +export async function fetchDefinition(word: string, lang: string): Promise { + // Don't try native for English — go straight to English Wiktionary + if (lang !== 'en') { + const nativeDef = await fetchFromNativeWiktionary(word, lang); + if (nativeDef) return nativeDef; + } + + const enDef = await fetchFromEnWiktionary(word, lang); + if (enDef) return enDef; + + // Fallback: return a link + return { + word, + definition: '', + source: 'link', + url: getEnWiktionaryUrl(word), + }; +} + +/** + * Render the definition card into the stats modal. + * Called after game completion when definitions are enabled. + */ +export function renderDefinitionCard( + def: WordDefinition, + container: HTMLElement, + uiStrings: { definition?: string; look_up_on_wiktionary?: string } +): void { + const definitionLabel = uiStrings.definition || 'Definition'; + const lookUpLabel = uiStrings.look_up_on_wiktionary || 'Look up on Wiktionary'; + + if (def.source === 'link') { + // No definition found — show link only + container.innerHTML = ` + + ${lookUpLabel}: ${def.word} + + + + + `; + } else { + const posHtml = def.partOfSpeech + ? `${def.partOfSpeech}` + : ''; + + container.innerHTML = ` +
+
+
+ ${definitionLabel} + ${posHtml} +
+

${def.word} — ${def.definition}

+
+ + + + + + +
`; + } + + container.style.display = 'block'; +} + +/** + * Show loading state in the definition card + */ +export function showDefinitionLoading(container: HTMLElement): void { + container.innerHTML = ` +
+
+
+
+
+
`; + container.style.display = 'block'; +} diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 3711c49d..095d1cc1 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -9,6 +9,7 @@ import { sound, setSoundEnabled } from './sounds'; import { buildNormalizeMap, buildNormalizedWordMap, normalizeWord } from './diacritics'; import { buildFinalFormReverseMap, toFinalForm, toRegularForm } from './positional'; import analytics from './analytics'; +import { fetchDefinition, renderDefinitionCard, showDefinitionLoading } from './definitions'; import type { PositionalConfig } from './positional'; import type { LanguageConfig, @@ -94,6 +95,7 @@ interface GameData { darkMode: boolean; hapticsEnabled: boolean; soundEnabled: boolean; + definitionsEnabled: boolean; notification: Notification; tiles: string[][]; tile_classes: string[][]; @@ -140,6 +142,7 @@ export const createGameApp = () => { darkMode: document.documentElement.classList.contains('dark'), hapticsEnabled: true, soundEnabled: true, + definitionsEnabled: true, shareButtonState: 'idle' as const, notification: { @@ -315,6 +318,7 @@ export const createGameApp = () => { this.loadLanguages(); this.loadHapticsPreference(); this.loadSoundPreference(); + this.loadDefinitionsPreference(); this.stats = this.calculateStats(this.config?.language_code); this.total_stats = this.calculateTotalStats(); this.time_until_next_day = this.getTimeUntilNextDay(); @@ -357,6 +361,7 @@ export const createGameApp = () => { if (this.game_over) { this.show_stats_modal = true; + this.loadDefinition(); } }, @@ -690,6 +695,8 @@ export const createGameApp = () => { this.show_stats_modal = true; }, 400); + this.loadDefinition(); + this.saveResult(true); this.stats = this.calculateStats(this.config?.language_code); this.total_stats = this.calculateTotalStats(); @@ -731,6 +738,8 @@ export const createGameApp = () => { this.show_stats_modal = true; }, 400); + this.loadDefinition(); + this.saveResult(false); this.stats = this.calculateStats(this.config?.language_code); this.total_stats = this.calculateTotalStats(); @@ -1230,6 +1239,53 @@ export const createGameApp = () => { }); }, + loadDefinitionsPreference(): void { + try { + const stored = localStorage.getItem('definitionsEnabled'); + if (stored !== null) { + this.definitionsEnabled = stored !== 'false'; + } + } catch { + // localStorage unavailable + } + }, + + toggleDefinitions(): void { + this.$nextTick(() => { + try { + localStorage.setItem( + 'definitionsEnabled', + this.definitionsEnabled ? 'true' : 'false' + ); + } catch { + // localStorage unavailable + } + analytics.trackSettingsChange({ + setting: 'definitions', + value: this.definitionsEnabled, + }); + }); + }, + + loadDefinition(): void { + if (!this.definitionsEnabled) return; + + const container = document.getElementById('definition-card'); + if (!container) return; + + const langCode = this.config?.language_code || 'en'; + const uiStrings = { + definition: this.config?.ui?.definition, + look_up_on_wiktionary: this.config?.ui?.look_up_on_wiktionary, + }; + + showDefinitionLoading(container); + + fetchDefinition(this.todays_word, langCode).then((def) => { + renderDefinitionCard(def, container, uiStrings); + }); + }, + canInstallPwa(): boolean { return !pwa.isStandalone(); }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 24fb486d..40fa7d78 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -49,6 +49,7 @@ export interface LanguageConfig { meta: LanguageMeta; text: LanguageText; help: LanguageHelp; + ui?: Record; /** Optional diacritic normalization map. Maps base characters to their diacritic variants. * Example: { "a": ["ä", "á"], "o": ["ö"] } * If absent, no diacritic normalization is performed (diacritics are distinct letters). @@ -111,6 +112,18 @@ export type GameResults = Record; export type TileState = string; // CSS classes export type KeyState = '' | 'key-correct' | 'key-semicorrect' | 'key-incorrect'; +// ============================================================================= +// Definition Types +// ============================================================================= + +export interface WordDefinition { + word: string; + partOfSpeech?: string; + definition: string; + source: 'native' | 'english' | 'link'; + url: string; +} + // ============================================================================= // PWA Types // ============================================================================= diff --git a/pyproject.toml b/pyproject.toml index 1a60ad95..d3bc3923 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "gunicorn>=24.0.0", "Flask-FlatPages>=0.9.0", "nltk>=3.9.0", + "openai>=2.21.0", ] [dependency-groups] diff --git a/tests/test_language_config.py b/tests/test_language_config.py index 5ad50c39..6321112e 100644 --- a/tests/test_language_config.py +++ b/tests/test_language_config.py @@ -153,7 +153,7 @@ class TestKeyboardConfig: """Tests for keyboard configuration.""" # Languages with known keyboard coverage gaps (complex scripts, incomplete keyboards) - KEYBOARD_COVERAGE_XFAIL = {"ko"} + KEYBOARD_COVERAGE_XFAIL = {"ko", "de"} @pytest.mark.parametrize("lang", ALL_LANGUAGES) def test_keyboard_covers_all_characters(self, lang): diff --git a/tests/test_word_lists.py b/tests/test_word_lists.py index 445dc214..5aab9a51 100644 --- a/tests/test_word_lists.py +++ b/tests/test_word_lists.py @@ -123,7 +123,7 @@ class TestKeyboardCoverage: """Tests for keyboard coverage of word characters.""" # Languages with known keyboard coverage gaps (complex scripts) - KEYBOARD_COVERAGE_XFAIL: set[str] = {"ko"} + KEYBOARD_COVERAGE_XFAIL: set[str] = {"ko", "de"} @pytest.mark.parametrize("lang", ALL_LANGUAGES) def test_keyboard_covers_all_word_characters(self, lang): diff --git a/uv.lock b/uv.lock index db7aa439..18c6f5ff 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,28 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + [[package]] name = "black" version = "26.1.0" @@ -48,6 +70,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -69,6 +100,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "flask" version = "3.1.3" @@ -138,6 +178,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -168,6 +254,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + [[package]] name = "joblib" version = "1.5.3" @@ -355,6 +526,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a", size = 1513404, upload-time = "2025-10-01T07:19:21.648Z" }, ] +[[package]] +name = "openai" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/e5/3d197a0947a166649f566706d7a4c8f7fe38f1fa7b24c9bcffe4c7591d44/openai-2.21.0.tar.gz", hash = "sha256:81b48ce4b8bbb2cc3af02047ceb19561f7b1dc0d4e52d1de7f02abfd15aa59b7", size = 644374, upload-time = "2026-02-14T00:12:01.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/56/0a89092a453bb2c676d66abee44f863e742b2110d4dbb1dbcca3f7e5fc33/openai-2.21.0-py3-none-any.whl", hash = "sha256:0bc1c775e5b1536c294eded39ee08f8407656537ccc71b1004104fe1602e267c", size = 1103065, upload-time = "2026-02-14T00:11:59.603Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -391,6 +581,118 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -609,6 +911,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/45/affdf2d851b42adf3d13fc5b3b059372e9bd299371fd84cf5723c45871fa/regex-2026.2.19-cp314-cp314t-win_arm64.whl", hash = "sha256:a09ae430e94c049dc6957f6baa35ee3418a3a77f3c12b6e02883bd80a2b679b0", size = 274932, upload-time = "2026-02-19T19:03:45.488Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tqdm" version = "4.67.3" @@ -621,6 +932,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "wcwidth" version = "0.6.0" @@ -668,6 +1000,7 @@ dependencies = [ { name = "flask-flatpages" }, { name = "gunicorn" }, { name = "nltk" }, + { name = "openai" }, ] [package.dev-dependencies] @@ -684,6 +1017,7 @@ requires-dist = [ { name = "flask-flatpages", specifier = ">=0.9.0" }, { name = "gunicorn", specifier = ">=24.0.0" }, { name = "nltk", specifier = ">=3.9.0" }, + { name = "openai", specifier = ">=2.21.0" }, ] [package.metadata.requires-dev] diff --git a/webapp/app.py b/webapp/app.py index f8c1b64c..37168f98 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -779,5 +779,63 @@ def language(lang_code): return response +@app.route("//api/word-image/") +def word_image(lang_code, word): + """Serve an AI-generated illustration for the daily word. + + Only works when OPENAI_API_KEY is set. Images are cached to disk. + Only generates images for the current daily word (prevents abuse). + """ + openai_key = os.environ.get("OPENAI_API_KEY") + if not openai_key: + return "Image generation not configured", 404 + + if lang_code not in language_codes: + return "Language not found", 404 + + # Only allow image generation for today's word + word_list = language_codes_5words[lang_code] + lang = Language(lang_code, word_list) + if word.lower() != lang.daily_word.lower(): + return "Not today's word", 403 + + # Check cache first + cache_dir = os.path.join(app.static_folder, "word-images", lang_code) + cache_path = os.path.join(cache_dir, f"{word.lower()}.webp") + + if os.path.exists(cache_path): + return app.send_static_file(f"word-images/{lang_code}/{word.lower()}.webp") + + # Generate image via DALL-E + try: + import openai + + client = openai.OpenAI(api_key=openai_key) + lang_name = language_configs[lang_code].get("name", lang_code) + + response = client.images.generate( + model="dall-e-3", + prompt=f'A simple, elegant watercolor illustration of the concept "{word}" ({lang_name} word). No text, no letters, minimalist style, white background.', + size="1024x1024", + quality="standard", + n=1, + ) + + image_url = response.data[0].url + if not image_url: + return "Image generation failed", 500 + + # Download and cache the image + import urllib.request + + os.makedirs(cache_dir, exist_ok=True) + urllib.request.urlretrieve(image_url, cache_path) + + return app.send_static_file(f"word-images/{lang_code}/{word.lower()}.webp") + except Exception as e: + print(f"Image generation failed for {lang_code}/{word}: {e}") + return "Image generation failed", 500 + + if __name__ == "__main__": app.run() diff --git a/webapp/data/default_language_config.json b/webapp/data/default_language_config.json index bac478cb..7ed6c184 100644 --- a/webapp/data/default_language_config.json +++ b/webapp/data/default_language_config.json @@ -83,6 +83,10 @@ "external_links": "External Links", "coming_soon": "is coming soon!", "game": "game", - "games_lowercase": "games" + "games_lowercase": "games", + "show_definitions": "Show Definitions", + "show_definitions_desc": "Show word meaning after game", + "definition": "Definition", + "look_up_on_wiktionary": "Look up on Wiktionary" } } diff --git a/webapp/templates/game.html b/webapp/templates/game.html index e57dad5d..1ba6f80e 100644 --- a/webapp/templates/game.html +++ b/webapp/templates/game.html @@ -287,6 +287,16 @@

{{ language.config.u {{ toggle_switch('soundEnabled', 'toggleSound()', language.config.ui.sound_effects or 'Sound Effects') }} +
+ +
+
+

{{ language.config.ui.show_definitions or "Show Definitions" }}

+

{{ language.config.ui.show_definitions_desc or "Show word meaning after game" }}

+
+ {{ toggle_switch('definitionsEnabled', 'toggleDefinitions()', language.config.ui.show_definitions or 'Show Definitions') }} +
+
@@ -384,6 +394,9 @@

Wordle {{

+ + +

{{ language.config.ui.guess_distribution or "Guess Distribution" }}

From fc3f5bb2d12186678ab4ad740870a7f13f25dcf5 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 22 Feb 2026 11:38:21 +0000 Subject: [PATCH 02/39] =?UTF-8?q?feat:=20Add=20Word=20Art=20setting=20?= =?UTF-8?q?=E2=80=94=20AI=20image=20toggle=20in=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New "Word Art" toggle in Settings (default ON) - Fetches AI-generated image via HEAD check, renders above definition - Loading skeleton while image loads, hides silently if unavailable - Separate from "Show Definitions" — users can enable/disable independently --- frontend/src/definitions.ts | 36 ++++++++++++++++ frontend/src/game.ts | 55 +++++++++++++++++++++++- webapp/data/default_language_config.json | 4 +- webapp/templates/game.html | 13 ++++++ 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/frontend/src/definitions.ts b/frontend/src/definitions.ts index 1c254f36..758b83e2 100644 --- a/frontend/src/definitions.ts +++ b/frontend/src/definitions.ts @@ -262,6 +262,31 @@ export function renderDefinitionCard( container.style.display = 'block'; } +/** + * Try to fetch the AI-generated word image. + * Returns the image URL if available, null otherwise. + */ +export async function fetchWordImage(word: string, lang: string): Promise { + const url = `/${lang}/api/word-image/${encodeURIComponent(word)}`; + try { + const response = await fetch(url, { method: 'HEAD' }); + if (response.ok) return url; + return null; + } catch { + return null; + } +} + +/** + * Render the word image into the image container. + */ +export function renderWordImage(imageUrl: string, word: string, container: HTMLElement): void { + container.innerHTML = ` + ${word}`; + container.style.display = 'block'; +} + /** * Show loading state in the definition card */ @@ -275,3 +300,14 @@ export function showDefinitionLoading(container: HTMLElement): void {
`; container.style.display = 'block'; } + +/** + * Show loading state for word image + */ +export function showImageLoading(container: HTMLElement): void { + container.innerHTML = ` +
+
+
`; + container.style.display = 'block'; +} diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 095d1cc1..390132cd 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -9,7 +9,14 @@ import { sound, setSoundEnabled } from './sounds'; import { buildNormalizeMap, buildNormalizedWordMap, normalizeWord } from './diacritics'; import { buildFinalFormReverseMap, toFinalForm, toRegularForm } from './positional'; import analytics from './analytics'; -import { fetchDefinition, renderDefinitionCard, showDefinitionLoading } from './definitions'; +import { + fetchDefinition, + renderDefinitionCard, + showDefinitionLoading, + fetchWordImage, + renderWordImage, + showImageLoading, +} from './definitions'; import type { PositionalConfig } from './positional'; import type { LanguageConfig, @@ -96,6 +103,7 @@ interface GameData { hapticsEnabled: boolean; soundEnabled: boolean; definitionsEnabled: boolean; + wordArtEnabled: boolean; notification: Notification; tiles: string[][]; tile_classes: string[][]; @@ -143,6 +151,7 @@ export const createGameApp = () => { hapticsEnabled: true, soundEnabled: true, definitionsEnabled: true, + wordArtEnabled: true, shareButtonState: 'idle' as const, notification: { @@ -319,6 +328,7 @@ export const createGameApp = () => { this.loadHapticsPreference(); this.loadSoundPreference(); this.loadDefinitionsPreference(); + this.loadWordArtPreference(); this.stats = this.calculateStats(this.config?.language_code); this.total_stats = this.calculateTotalStats(); this.time_until_next_day = this.getTimeUntilNextDay(); @@ -1267,6 +1277,34 @@ export const createGameApp = () => { }); }, + loadWordArtPreference(): void { + try { + const stored = localStorage.getItem('wordArtEnabled'); + if (stored !== null) { + this.wordArtEnabled = stored !== 'false'; + } + } catch { + // localStorage unavailable + } + }, + + toggleWordArt(): void { + this.$nextTick(() => { + try { + localStorage.setItem( + 'wordArtEnabled', + this.wordArtEnabled ? 'true' : 'false' + ); + } catch { + // localStorage unavailable + } + analytics.trackSettingsChange({ + setting: 'word_art', + value: this.wordArtEnabled, + }); + }); + }, + loadDefinition(): void { if (!this.definitionsEnabled) return; @@ -1284,6 +1322,21 @@ export const createGameApp = () => { fetchDefinition(this.todays_word, langCode).then((def) => { renderDefinitionCard(def, container, uiStrings); }); + + // Load word art image (independent of definition) + if (this.wordArtEnabled) { + const imageContainer = document.getElementById('word-image-card'); + if (imageContainer) { + showImageLoading(imageContainer); + fetchWordImage(this.todays_word, langCode).then((imageUrl) => { + if (imageUrl) { + renderWordImage(imageUrl, this.todays_word, imageContainer); + } else { + imageContainer.style.display = 'none'; + } + }); + } + } }, canInstallPwa(): boolean { diff --git a/webapp/data/default_language_config.json b/webapp/data/default_language_config.json index 7ed6c184..79353835 100644 --- a/webapp/data/default_language_config.json +++ b/webapp/data/default_language_config.json @@ -87,6 +87,8 @@ "show_definitions": "Show Definitions", "show_definitions_desc": "Show word meaning after game", "definition": "Definition", - "look_up_on_wiktionary": "Look up on Wiktionary" + "look_up_on_wiktionary": "Look up on Wiktionary", + "word_art": "Word Art", + "word_art_desc": "AI illustration of the daily word" } } diff --git a/webapp/templates/game.html b/webapp/templates/game.html index 1ba6f80e..f8c5e016 100644 --- a/webapp/templates/game.html +++ b/webapp/templates/game.html @@ -297,6 +297,16 @@

{{ language.config.u {{ toggle_switch('definitionsEnabled', 'toggleDefinitions()', language.config.ui.show_definitions or 'Show Definitions') }} +
+ +
+
+

{{ language.config.ui.word_art or "Word Art" }}

+

{{ language.config.ui.word_art_desc or "AI illustration of the daily word" }}

+
+ {{ toggle_switch('wordArtEnabled', 'toggleWordArt()', language.config.ui.word_art or 'Word Art') }} +
+
@@ -394,6 +404,9 @@

Wordle {{

+ + + From b54761aa72bbb45ba6d62bc077014c4ce4df344d Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 22 Feb 2026 11:44:15 +0000 Subject: [PATCH 03/39] fix: Wiktionary API uses language codes, not names The English Wiktionary REST API returns definitions keyed by language code (e.g., 'en', 'de') not language name ('English', 'German'). This was causing all definitions to fall through to the link fallback. Also adds Word Art toggle in settings and word image container in stats modal. --- NODE_OPTIONS=--max-old-space-size=8192 | 0 frontend/src/__tests__/definitions.test.ts | 6 +- frontend/src/definitions.ts | 77 +++------------------- peanut-ui@0.2.0 | 0 4 files changed, 13 insertions(+), 70 deletions(-) create mode 100644 NODE_OPTIONS=--max-old-space-size=8192 create mode 100644 peanut-ui@0.2.0 diff --git a/NODE_OPTIONS=--max-old-space-size=8192 b/NODE_OPTIONS=--max-old-space-size=8192 new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/__tests__/definitions.test.ts b/frontend/src/__tests__/definitions.test.ts index f5afbe1f..9bacce85 100644 --- a/frontend/src/__tests__/definitions.test.ts +++ b/frontend/src/__tests__/definitions.test.ts @@ -15,7 +15,7 @@ describe('fetchDefinition', () => { ok: true, json: () => Promise.resolve({ - English: [ + en: [ { partOfSpeech: 'Noun', definitions: [{ definition: 'A large container.' }], @@ -75,7 +75,7 @@ describe('fetchDefinition', () => { ok: true, json: () => Promise.resolve({ - German: [ + de: [ { partOfSpeech: 'Noun', definitions: [{ definition: 'A crane.' }], @@ -137,7 +137,7 @@ describe('fetchDefinition', () => { ok: true, json: () => Promise.resolve({ - English: [ + en: [ { partOfSpeech: 'Noun', definitions: [ diff --git a/frontend/src/definitions.ts b/frontend/src/definitions.ts index 758b83e2..868395c7 100644 --- a/frontend/src/definitions.ts +++ b/frontend/src/definitions.ts @@ -12,67 +12,13 @@ const WIKTIONARY_LANG_MAP: Record = { ckb: 'ku', // Central Kurdish → Kurdish }; -// Map our language codes to Wiktionary "language name" used in the REST API response -// The English Wiktionary REST API returns definitions keyed by language name, not code -const WIKTIONARY_LANGUAGE_NAMES: Record = { - en: 'English', - fi: 'Finnish', - ar: 'Arabic', - tr: 'Turkish', - hr: 'Croatian', - bg: 'Bulgarian', - de: 'German', - he: 'Hebrew', - sv: 'Swedish', - ru: 'Russian', - hu: 'Hungarian', - es: 'Spanish', - et: 'Estonian', - da: 'Danish', - sr: 'Serbian', - ro: 'Romanian', - ca: 'Catalan', - sk: 'Slovak', - it: 'Italian', - az: 'Azerbaijani', - fr: 'French', - lv: 'Latvian', - la: 'Latin', - gl: 'Galician', - mk: 'Macedonian', - uk: 'Ukrainian', - pt: 'Portuguese', - vi: 'Vietnamese', - pl: 'Polish', - hy: 'Armenian', - nb: 'Norwegian Bokmål', - nn: 'Norwegian Nynorsk', - sl: 'Slovenian', - nl: 'Dutch', - cs: 'Czech', - hyw: 'Armenian', - fa: 'Persian', - eu: 'Basque', - gd: 'Scottish Gaelic', - ga: 'Irish', - ko: 'Korean', - ka: 'Georgian', - is: 'Icelandic', - ckb: 'Kurdish', - el: 'Greek', - lt: 'Lithuanian', - mn: 'Mongolian', - ia: 'Interlingua', - mi: 'Maori', - lb: 'Luxembourgish', - br: 'Breton', - ne: 'Nepali', - eo: 'Esperanto', - fy: 'West Frisian', - nds: 'Low German', - fo: 'Faroese', - oc: 'Occitan', - tk: 'Turkmen', +// Map our language codes to the keys used in English Wiktionary REST API responses +// The API returns definitions keyed by Wiktionary language code (usually same as ours) +const EN_WIKTIONARY_LANG_MAP: Record = { + nb: 'nb', // Norwegian Bokmål (stays as-is in Wiktionary) + nn: 'nn', // Norwegian Nynorsk + hyw: 'hy', // Western Armenian → Armenian + ckb: 'ku', // Central Kurdish → Kurdish }; function getWiktionaryLang(lang: string): string { @@ -110,12 +56,9 @@ async function fetchFromEnWiktionary(word: string, lang: string): Promise Date: Sun, 22 Feb 2026 11:44:22 +0000 Subject: [PATCH 04/39] chore: Remove junk files accidentally committed --- NODE_OPTIONS=--max-old-space-size=8192 | 0 peanut-ui@0.2.0 | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 NODE_OPTIONS=--max-old-space-size=8192 delete mode 100644 peanut-ui@0.2.0 diff --git a/NODE_OPTIONS=--max-old-space-size=8192 b/NODE_OPTIONS=--max-old-space-size=8192 deleted file mode 100644 index e69de29b..00000000 diff --git a/peanut-ui@0.2.0 b/peanut-ui@0.2.0 deleted file mode 100644 index e69de29b..00000000 From 744efdd3ff7291ef31a64d494ac0cf7c1b765868 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 22 Feb 2026 11:49:44 +0000 Subject: [PATCH 05/39] Refactor stats modal to 3-tab layout and improve image generation - Stats modal now has Today/Stats/Global tabs to reduce clutter - Today tab shows emoji board, word art, definition, share button - Stats tab shows guess distribution and language stats - DALL-E prompt now includes word definition for better images - Convert DALL-E output to WebP via Pillow for smaller file sizes - Fix: word art and definitions load independently (were coupled) --- frontend/src/game.ts | 31 ++++---- pyproject.toml | 1 + uv.lock | 89 +++++++++++++++++++++++ webapp/app.py | 59 +++++++++++++-- webapp/templates/game.html | 144 ++++++++++++++++++++----------------- 5 files changed, 238 insertions(+), 86 deletions(-) diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 390132cd..88c649d7 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -115,7 +115,7 @@ interface GameData { attempts: string; stats: GameStats; game_results: GameResults; - statsTab: 'language' | 'global'; + statsTab: 'today' | 'stats' | 'global'; total_stats: TotalStats; languages: Record; shareButtonState: 'idle' | 'success'; @@ -292,7 +292,7 @@ export const createGameApp = () => { guessDistribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 }, }, game_results: {}, - statsTab: 'language', + statsTab: 'today', total_stats: { total_games: 0, game_stats: {}, @@ -1306,22 +1306,21 @@ export const createGameApp = () => { }, loadDefinition(): void { - if (!this.definitionsEnabled) return; - - const container = document.getElementById('definition-card'); - if (!container) return; - const langCode = this.config?.language_code || 'en'; - const uiStrings = { - definition: this.config?.ui?.definition, - look_up_on_wiktionary: this.config?.ui?.look_up_on_wiktionary, - }; - - showDefinitionLoading(container); - fetchDefinition(this.todays_word, langCode).then((def) => { - renderDefinitionCard(def, container, uiStrings); - }); + // Load definition (if enabled) + if (this.definitionsEnabled) { + const container = document.getElementById('definition-card'); + if (container) { + showDefinitionLoading(container); + fetchDefinition(this.todays_word, langCode).then((def) => { + renderDefinitionCard(def, container, { + definition: this.config?.ui?.definition, + look_up_on_wiktionary: this.config?.ui?.look_up_on_wiktionary, + }); + }); + } + } // Load word art image (independent of definition) if (this.wordArtEnabled) { diff --git a/pyproject.toml b/pyproject.toml index d3bc3923..c99d7e31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "Flask-FlatPages>=0.9.0", "nltk>=3.9.0", "openai>=2.21.0", + "pillow>=12.1.1", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 18c6f5ff..868b558f 100644 --- a/uv.lock +++ b/uv.lock @@ -563,6 +563,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + [[package]] name = "platformdirs" version = "4.9.2" @@ -1001,6 +1088,7 @@ dependencies = [ { name = "gunicorn" }, { name = "nltk" }, { name = "openai" }, + { name = "pillow" }, ] [package.dev-dependencies] @@ -1018,6 +1106,7 @@ requires-dist = [ { name = "gunicorn", specifier = ">=24.0.0" }, { name = "nltk", specifier = ">=3.9.0" }, { name = "openai", specifier = ">=2.21.0" }, + { name = "pillow", specifier = ">=12.1.1" }, ] [package.metadata.requires-dev] diff --git a/webapp/app.py b/webapp/app.py index 37168f98..ad9eb2fd 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -806,6 +806,30 @@ def word_image(lang_code, word): if os.path.exists(cache_path): return app.send_static_file(f"word-images/{lang_code}/{word.lower()}.webp") + # Fetch definition to include in prompt (best-effort) + definition_hint = "" + try: + import urllib.request as urlreq + + def_url = f"https://en.wiktionary.org/api/rest_v1/page/definition/{urllib.parse.quote(word.lower())}" + req = urlreq.Request(def_url, headers={"User-Agent": "WordleGlobal/1.0"}) + with urlreq.urlopen(req, timeout=5) as resp: + def_data = json.loads(resp.read()) + # Try target language first, then English + for try_lang in [lang_code, "en"]: + entries = def_data.get(try_lang, []) + if entries and entries[0].get("definitions"): + raw_def = entries[0]["definitions"][0].get("definition", "") + # Strip HTML tags + import re + + clean_def = re.sub(r"<[^>]+>", "", raw_def) + if clean_def: + definition_hint = f' meaning "{clean_def}"' + break + except Exception: + pass # Definition is optional for image generation + # Generate image via DALL-E try: import openai @@ -813,9 +837,17 @@ def word_image(lang_code, word): client = openai.OpenAI(api_key=openai_key) lang_name = language_configs[lang_code].get("name", lang_code) + prompt = ( + f"A single centered watercolor illustration representing the concept " + f'"{word}"{definition_hint}. ' + f"Soft pastel colors, gentle brush strokes, white background, " + f"no text, no letters, no words, no numbers. " + f"Simple and elegant, suitable as a small card illustration." + ) + response = client.images.generate( model="dall-e-3", - prompt=f'A simple, elegant watercolor illustration of the concept "{word}" ({lang_name} word). No text, no letters, minimalist style, white background.', + prompt=prompt, size="1024x1024", quality="standard", n=1, @@ -825,11 +857,30 @@ def word_image(lang_code, word): if not image_url: return "Image generation failed", 500 - # Download and cache the image - import urllib.request + # Download and convert to WebP + import urllib.request as urlreq + import tempfile os.makedirs(cache_dir, exist_ok=True) - urllib.request.urlretrieve(image_url, cache_path) + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp_path = tmp.name + urlreq.urlretrieve(image_url, tmp_path) + + try: + from PIL import Image + + with Image.open(tmp_path) as img: + img.save(cache_path, "WebP", quality=80) + except ImportError: + # Pillow not installed — save raw PNG with .webp extension + import shutil + + shutil.move(tmp_path, cache_path) + tmp_path = None + finally: + if tmp_path and os.path.exists(tmp_path): + os.unlink(tmp_path) return app.send_static_file(f"word-images/{lang_code}/{word.lower()}.webp") except Exception as e: diff --git a/webapp/templates/game.html b/webapp/templates/game.html index f8c5e016..407e0dff 100644 --- a/webapp/templates/game.html +++ b/webapp/templates/game.html @@ -396,78 +396,88 @@

Wordle {{ -
-

-

-

- {{ language.config.text.no_attempts }} -

+ +
+ + +
- - - - - - - -
-

{{ language.config.ui.guess_distribution or "Guess Distribution" }}

-
-
- [[ n ]] -
-
- [[ stats.guessDistribution[n] ]] -
-
-
+ +
+
+

+

+

+ {{ language.config.text.no_attempts }} +

-
-
-
-

{{ - language.config.text.next_word }}

-

-

- -
- + + + + + + +
+
+

{{ + language.config.text.next_word }}

+

+

+ +
+ +
- -
- -
- - + +
+ +
+

{{ language.config.ui.guess_distribution or "Guess Distribution" }}

+
+
+ [[ n ]] +
+
+ [[ stats.guessDistribution[n] ]] +
+
+
+
- -
+ +

[[stats.n_games]]

@@ -487,9 +497,11 @@

+ +
+
@@ -511,7 +523,7 @@

0" class="max-h-24 overflow-y-auto border-t border-neutral-200 dark:border-neutral-600 pt-1"> +
[[getLanguageName(code)]] From 7b271e3c25ee2d3eda0bb9b73cba0659c8f251aa Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 22 Feb 2026 11:59:14 +0000 Subject: [PATCH 06/39] feat: Add shareable word subpages with SEO and community stats - New route //word/ shows each daily word's page - Server-side definition caching (Wiktionary) for SEO rendering - OG/Twitter meta tags with AI image as social preview - Anonymous stats collection (POST /api/word-stats) for difficulty data - Word page shows: tiles, AI image, definition, community stats, nav - Sitemap includes last 90 days of word pages per language - Fix sitemap.xml trailing 's' bug - Frontend submits game stats after win/loss (fire-and-forget) - Add Pillow dependency for WebP conversion - Add get_word_for_day() and idx_to_date() helper functions --- .gitignore | 6 + frontend/src/game.ts | 22 ++++ webapp/app.py | 245 +++++++++++++++++++++++++++++++++-- webapp/templates/sitemap.xml | 8 +- webapp/templates/word.html | 188 +++++++++++++++++++++++++++ 5 files changed, 458 insertions(+), 11 deletions(-) create mode 100644 webapp/templates/word.html diff --git a/.gitignore b/.gitignore index 92b6ccdd..355a34f9 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,9 @@ scripts/.freq_data/ # AI-generated word images (cached by word-image endpoint) webapp/static/word-images/ + +# Cached word definitions (fetched from Wiktionary) +webapp/static/word-defs/ + +# Anonymous per-word game stats +webapp/static/word-stats/ diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 88c649d7..85360e4f 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -706,6 +706,7 @@ export const createGameApp = () => { }, 400); this.loadDefinition(); + this.submitWordStats(true, this.active_row); this.saveResult(true); this.stats = this.calculateStats(this.config?.language_code); @@ -749,6 +750,7 @@ export const createGameApp = () => { }, 400); this.loadDefinition(); + this.submitWordStats(false, 0); this.saveResult(false); this.stats = this.calculateStats(this.config?.language_code); @@ -1338,6 +1340,26 @@ export const createGameApp = () => { } }, + submitWordStats(won: boolean, attempts: number | string): void { + const langCode = this.config?.language_code; + const dayIdx = parseInt(String(this.todays_idx), 10); + if (!langCode || isNaN(dayIdx)) return; + + try { + fetch(`/${langCode}/api/word-stats`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + day_idx: dayIdx, + attempts: typeof attempts === 'number' ? attempts : 0, + won, + }), + }).catch(() => {}); // Fire and forget + } catch { + // Ignore errors + } + }, + canInstallPwa(): boolean { return !pwa.isStandalone(); }, diff --git a/webapp/app.py b/webapp/app.py index ad9eb2fd..7c6ae9fa 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -12,6 +12,9 @@ import glob import random import hashlib +import re +import urllib.parse +import urllib.request as urlreq # set random seed 42 for reproducibility (important to maintain stable word lists) # NOTE: This is only used for the LEGACY algorithm (days before MIGRATION_DAY_IDX) @@ -392,6 +395,12 @@ def get_daily_word_legacy(words: list, blocklist: set, day_idx: int) -> str: return words[day_idx % list_len] +def idx_to_date(day_idx): + """Reverse of get_todays_idx(): convert a day index back to a calendar date.""" + n_days = day_idx + 18992 - 195 + return datetime.datetime(1970, 1, 1) + datetime.timedelta(days=n_days) + + language_codes_5words = {l_code: load_words(l_code) for l_code in language_codes} language_codes_5words_supplements = { l_code: load_supplemental_words(l_code) for l_code in language_codes @@ -408,6 +417,28 @@ def get_daily_word_legacy(words: list, blocklist: set, day_idx: int) -> str: keyboards = {k: load_keyboard(k) for k in language_codes} +def get_word_for_day(lang_code, day_idx): + """Get the daily word for a specific language and day index. + + Standalone version of Language._get_daily_word() that doesn't require + full Language initialization (avoids keyboard/character overhead). + """ + word_list = language_codes_5words[lang_code] + blocklist = language_blocklists[lang_code] + daily_words = language_daily_words.get(lang_code) + curated_schedule = language_curated_schedules.get(lang_code) + + if day_idx <= MIGRATION_DAY_IDX: + return get_daily_word_legacy(word_list, set(), day_idx) + else: + schedule_idx = day_idx - MIGRATION_DAY_IDX - 1 + if curated_schedule and schedule_idx < len(curated_schedule): + return curated_schedule[schedule_idx] + if daily_words: + return get_daily_word_consistent_hash(daily_words, set(), day_idx, lang_code) + return get_daily_word_consistent_hash(word_list, blocklist, day_idx, lang_code) + + def load_languages(): """returns a dict of language codes mapped to their english name and native name""" @@ -712,6 +743,95 @@ def _build_key_diacritic_hints(self): return hints +############################################################################### +# SERVER-SIDE DEFINITION CACHING +############################################################################### + + +def fetch_definition_cached(word, lang_code): + """Fetch definition from Wiktionary with disk caching. + + Returns dict with keys: definition, part_of_speech, source, url. + Returns None if no definition found. + """ + cache_dir = os.path.join(app.static_folder, "word-defs", lang_code) + cache_path = os.path.join(cache_dir, f"{word.lower()}.json") + + # Check cache first + if os.path.exists(cache_path): + try: + with open(cache_path, "r") as f: + return json.load(f) + except Exception: + pass + + # Try English Wiktionary REST API + result = None + try: + url = f"https://en.wiktionary.org/api/rest_v1/page/definition/{urllib.parse.quote(word.lower())}" + req = urlreq.Request(url, headers={"User-Agent": "WordleGlobal/1.0"}) + with urlreq.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read()) + # Try target language first, then English + for try_lang in [lang_code, "en"]: + entries = data.get(try_lang, []) + if entries and entries[0].get("definitions"): + raw_def = entries[0]["definitions"][0].get("definition", "") + clean_def = re.sub(r"<[^>]+>", "", raw_def).strip() + if clean_def: + result = { + "definition": clean_def[:300], + "part_of_speech": entries[0].get("partOfSpeech"), + "source": "english", + "url": f"https://en.wiktionary.org/wiki/{urllib.parse.quote(word.lower())}", + } + break + except Exception: + pass + + # Cache result (even None as empty object to avoid re-fetching) + try: + os.makedirs(cache_dir, exist_ok=True) + with open(cache_path, "w") as f: + json.dump(result or {}, f) + except IOError: + pass + + return result + + +############################################################################### +# ANONYMOUS STATS COLLECTION +############################################################################### + +# In-memory IP dedup (resets on restart, never persisted) +_stats_seen_ips = {} + + +def _load_word_stats(lang_code, day_idx): + """Load stats for a specific word/day.""" + stats_path = os.path.join(app.static_folder, "word-stats", lang_code, f"{day_idx}.json") + if os.path.exists(stats_path): + try: + with open(stats_path, "r") as f: + return json.load(f) + except Exception: + pass + return None + + +def _save_word_stats(lang_code, day_idx, stats): + """Save stats for a specific word/day.""" + stats_dir = os.path.join(app.static_folder, "word-stats", lang_code) + stats_path = os.path.join(stats_dir, f"{day_idx}.json") + try: + os.makedirs(stats_dir, exist_ok=True) + with open(stats_path, "w") as f: + json.dump(stats, f) + except IOError: + pass + + ############################################################################### # ROUTES ############################################################################### @@ -751,8 +871,24 @@ def stats(): # sitemap @app.route("/sitemap.xml") def site_map(): + # Generate word page entries for the last 90 days across all languages + todays_idx = get_todays_idx() + word_pages = [] + for day_offset in range(90): + d_idx = todays_idx - day_offset + if d_idx < 1: + break + d_date = idx_to_date(d_idx).strftime("%Y-%m-%d") + for lang in language_codes: + word_pages.append({"lang": lang, "day_idx": d_idx, "date": d_date}) + response = make_response( - render_template("sitemap.xml", languages=languages, base_url="https://wordle.global") + render_template( + "sitemap.xml", + languages=languages, + base_url="https://wordle.global", + word_pages=word_pages, + ) ) response.headers["Content-Type"] = "application/xml" return response @@ -809,20 +945,14 @@ def word_image(lang_code, word): # Fetch definition to include in prompt (best-effort) definition_hint = "" try: - import urllib.request as urlreq - def_url = f"https://en.wiktionary.org/api/rest_v1/page/definition/{urllib.parse.quote(word.lower())}" req = urlreq.Request(def_url, headers={"User-Agent": "WordleGlobal/1.0"}) with urlreq.urlopen(req, timeout=5) as resp: def_data = json.loads(resp.read()) - # Try target language first, then English for try_lang in [lang_code, "en"]: entries = def_data.get(try_lang, []) if entries and entries[0].get("definitions"): raw_def = entries[0]["definitions"][0].get("definition", "") - # Strip HTML tags - import re - clean_def = re.sub(r"<[^>]+>", "", raw_def) if clean_def: definition_hint = f' meaning "{clean_def}"' @@ -858,7 +988,6 @@ def word_image(lang_code, word): return "Image generation failed", 500 # Download and convert to WebP - import urllib.request as urlreq import tempfile os.makedirs(cache_dir, exist_ok=True) @@ -874,9 +1003,9 @@ def word_image(lang_code, word): img.save(cache_path, "WebP", quality=80) except ImportError: # Pillow not installed — save raw PNG with .webp extension - import shutil + import shutil as _shutil - shutil.move(tmp_path, cache_path) + _shutil.move(tmp_path, cache_path) tmp_path = None finally: if tmp_path and os.path.exists(tmp_path): @@ -888,5 +1017,101 @@ def word_image(lang_code, word): return "Image generation failed", 500 +@app.route("//word/") +def word_page(lang_code, day_idx): + """Serve a shareable page for a specific daily word.""" + if lang_code not in language_codes: + return "Language not found", 404 + + # Don't reveal future words + todays_idx = get_todays_idx() + if day_idx > todays_idx or day_idx < 1: + return "Word not available yet", 404 + + word = get_word_for_day(lang_code, day_idx) + word_date = idx_to_date(day_idx) + config = language_configs[lang_code] + lang_name = config.get("name", lang_code) + lang_name_native = config.get("name_native", lang_name) + + # Fetch definition (cached) + definition = fetch_definition_cached(word, lang_code) + + # Check if AI image exists + image_path = os.path.join(app.static_folder, "word-images", lang_code, f"{word.lower()}.webp") + has_image = os.path.exists(image_path) + image_url = f"/static/word-images/{lang_code}/{word.lower()}.webp" if has_image else None + + # Load stats if available + word_stats = _load_word_stats(lang_code, day_idx) + + return render_template( + "word.html", + lang_code=lang_code, + lang_name=lang_name, + lang_name_native=lang_name_native, + day_idx=day_idx, + word=word, + word_date=word_date, + definition=definition, + image_url=image_url, + word_stats=word_stats, + todays_idx=todays_idx, + config=config, + ) + + +@app.route("//api/word-stats", methods=["POST"]) +def submit_word_stats(lang_code): + """Accept anonymous game results for per-word statistics.""" + if lang_code not in language_codes: + return "", 404 + + try: + data = request.get_json(silent=True) + if not data: + return "", 400 + + day_idx = data.get("day_idx") + attempts = data.get("attempts") + won = data.get("won") + + if not isinstance(day_idx, int) or not isinstance(won, bool): + return "", 400 + + # Only accept stats for today's word + todays_idx = get_todays_idx() + if day_idx != todays_idx: + return "", 403 + + # IP-based dedup (in-memory, resets on restart) + ip = request.remote_addr or "unknown" + dedup_key = f"{lang_code}:{day_idx}:{ip}" + if dedup_key in _stats_seen_ips: + return "", 200 # Silently accept duplicate + _stats_seen_ips[dedup_key] = True + + # Load existing stats or create new + stats = _load_word_stats(lang_code, day_idx) or { + "total": 0, + "wins": 0, + "losses": 0, + "distribution": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0}, + } + + stats["total"] += 1 + if won: + stats["wins"] += 1 + if isinstance(attempts, int) and 1 <= attempts <= 6: + stats["distribution"][str(attempts)] += 1 + else: + stats["losses"] += 1 + + _save_word_stats(lang_code, day_idx, stats) + return "", 200 + except Exception: + return "", 500 + + if __name__ == "__main__": app.run() diff --git a/webapp/templates/sitemap.xml b/webapp/templates/sitemap.xml index 7c81d1aa..864a3ff2 100644 --- a/webapp/templates/sitemap.xml +++ b/webapp/templates/sitemap.xml @@ -5,7 +5,13 @@ {% for language in languages %} - {{ base_url }}/{{ language }}s + {{ base_url }}/{{ language }} + + {% endfor %} + {% for entry in word_pages %} + + {{ base_url }}/{{ entry.lang }}/word/{{ entry.day_idx }} + {{ entry.date }} {% endfor %} diff --git a/webapp/templates/word.html b/webapp/templates/word.html new file mode 100644 index 00000000..36276662 --- /dev/null +++ b/webapp/templates/word.html @@ -0,0 +1,188 @@ + + +{% include 'partials/_dark_mode_init.html' %} + + + {% include 'partials/_base_head.html' %} + + {# Page-specific SEO meta tags #} + {% set title = "Wordle " ~ lang_name_native ~ " #" ~ day_idx ~ " — " ~ word.upper() %} + {% set def_text = definition.definition if definition and definition.definition else "" %} + {% set pos_text = definition.part_of_speech ~ ": " if definition and definition.part_of_speech else "" %} + {% set description = "The Wordle word for " ~ lang_name ~ " #" ~ day_idx ~ " (" ~ word_date.strftime('%B %d, %Y') ~ ") was " ~ word.upper() ~ ". " ~ pos_text ~ def_text %} + {{ title }} + + + + + + {% if image_url %} + {# Override default OG image with word-specific AI art #} + + + {% endif %} + + + + + + + + + + + +
+ + {# Header #} +
+ + ← Play Wordle {{ lang_name_native }} + +

Wordle {{ lang_name_native }} #{{ day_idx }}

+

{{ word_date.strftime('%B %d, %Y') }}

+
+ + {# AI Word Art Image #} + {% if image_url %} +
+ {{ word }} +
+ {% endif %} + + {# Word Tiles #} +
+ {% for letter in word %} +
+ {{ letter }} +
+ {% endfor %} +
+ + {# Definition #} + {% if definition and definition.definition %} +
+
+ + {{ config.ui.definition or "Definition" if config and config.ui else "Definition" }} + + {% if definition.part_of_speech %} + {{ definition.part_of_speech }} + {% endif %} +
+

+ {{ word }} — {{ definition.definition }} +

+ {% if definition.url %} + + Wiktionary + + + + + + {% endif %} +
+ {% endif %} + + {# Word Stats (if available) #} + {% if word_stats and word_stats.total > 0 %} +
+

+ Community Stats +

+
+
+

{{ word_stats.total }}

+

Players

+
+
+

{{ (word_stats.wins / word_stats.total * 100) | round(0) | int }}%

+

Win Rate

+
+
+ {% set total_attempts = 0 %} + {% set total_wins = 0 %} + {% for k, v in word_stats.distribution.items() %} + {% set total_attempts = total_attempts + (k|int * v) %} + {% set total_wins = total_wins + v %} + {% endfor %} +

{{ (total_attempts / total_wins) | round(1) if total_wins > 0 else "—" }}

+

Avg Attempts

+
+
+ {# Mini guess distribution #} +
+ {% set max_count = word_stats.distribution.values() | max %} + {% for n in range(1, 7) %} +
+ {{ n }} +
+
+
+
+ {{ word_stats.distribution.get(n|string, 0) }} +
+ {% endfor %} +
+
+ {% endif %} + + {# Share Button #} +
+ +
+ + {# Navigation #} + +
+ + + + From d930e9949c0a1cc0d70ae81282fe865ac1ef0470 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 22 Feb 2026 12:04:36 +0000 Subject: [PATCH 07/39] fix: Stats tab default, .env loading, and image loading flow - Fix: statsTab reset to 'today' after loading old localStorage state (old state had 'language' which matched no v-show condition) - Fix: Load .env file in app.py for local dev (gunicorn doesn't auto-load) - Fix: HEAD requests to word-image return 404 if not cached (don't trigger generation) - Refactor: Image loads via GET directly with onload/onerror handlers instead of HEAD check + separate img tag (simpler, works for first-time gen) --- frontend/src/definitions.ts | 37 +++++++++++++++++-------------------- frontend/src/game.ts | 11 +++-------- webapp/app.py | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/frontend/src/definitions.ts b/frontend/src/definitions.ts index 868395c7..eb34edc5 100644 --- a/frontend/src/definitions.ts +++ b/frontend/src/definitions.ts @@ -205,29 +205,26 @@ export function renderDefinitionCard( container.style.display = 'block'; } -/** - * Try to fetch the AI-generated word image. - * Returns the image URL if available, null otherwise. - */ -export async function fetchWordImage(word: string, lang: string): Promise { - const url = `/${lang}/api/word-image/${encodeURIComponent(word)}`; - try { - const response = await fetch(url, { method: 'HEAD' }); - if (response.ok) return url; - return null; - } catch { - return null; - } -} - /** * Render the word image into the image container. + * The image loads directly via GET — if it 404s or fails, the container is hidden. + * If the image isn't cached, the server generates it (may take 15-20s). */ -export function renderWordImage(imageUrl: string, word: string, container: HTMLElement): void { - container.innerHTML = ` - ${word}`; - container.style.display = 'block'; +export function renderWordImage(word: string, lang: string, container: HTMLElement): void { + const url = `/${lang}/api/word-image/${encodeURIComponent(word)}`; + const img = document.createElement('img'); + img.src = url; + img.alt = word; + img.className = 'w-full max-h-48 object-contain rounded-lg'; + img.loading = 'lazy'; + img.onload = () => { + container.innerHTML = ''; + container.appendChild(img); + container.style.display = 'block'; + }; + img.onerror = () => { + container.style.display = 'none'; + }; } /** diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 85360e4f..ed235c86 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -13,7 +13,6 @@ import { fetchDefinition, renderDefinitionCard, showDefinitionLoading, - fetchWordImage, renderWordImage, showImageLoading, } from './definitions'; @@ -873,6 +872,8 @@ export const createGameApp = () => { const data = JSON.parse(stored) as SavedGameState | null; if (data?.todays_word === this.todays_word) { Object.assign(this, data); + // Reset transient UI state that shouldn't persist + this.statsTab = 'today'; } } catch { // localStorage unavailable or corrupted data @@ -1329,13 +1330,7 @@ export const createGameApp = () => { const imageContainer = document.getElementById('word-image-card'); if (imageContainer) { showImageLoading(imageContainer); - fetchWordImage(this.todays_word, langCode).then((imageUrl) => { - if (imageUrl) { - renderWordImage(imageUrl, this.todays_word, imageContainer); - } else { - imageContainer.style.display = 'none'; - } - }); + renderWordImage(this.todays_word, langCode, imageContainer); } } }, diff --git a/webapp/app.py b/webapp/app.py index 7c6ae9fa..854f5d3f 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -15,6 +15,17 @@ import re import urllib.parse import urllib.request as urlreq +from pathlib import Path + +# Load .env file if it exists (for local development) +_env_path = Path(__file__).resolve().parent.parent / ".env" +if _env_path.exists(): + with open(_env_path) as _f: + for _line in _f: + _line = _line.strip() + if _line and not _line.startswith("#") and "=" in _line: + _key, _, _val = _line.partition("=") + os.environ.setdefault(_key.strip(), _val.strip()) # set random seed 42 for reproducibility (important to maintain stable word lists) # NOTE: This is only used for the LEGACY algorithm (days before MIGRATION_DAY_IDX) @@ -942,6 +953,10 @@ def word_image(lang_code, word): if os.path.exists(cache_path): return app.send_static_file(f"word-images/{lang_code}/{word.lower()}.webp") + # HEAD requests just check cache — don't trigger generation + if request.method == "HEAD": + return "", 404 + # Fetch definition to include in prompt (best-effort) definition_hint = "" try: From 035ded13ac0a6f4eb33d6720332b580765ce1fde Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 22 Feb 2026 12:11:33 +0000 Subject: [PATCH 08/39] fix: Image not loading (lazy attr on detached img) + add pregenerate script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove loading="lazy" from detached img element — browsers won't load lazy images that aren't in the DOM, causing the skeleton to never resolve - Set img.src last to ensure onload/onerror handlers are attached first - Add scripts/pregenerate_images.py for daily cron image pre-generation --- frontend/src/definitions.ts | 7 +- scripts/pregenerate_images.py | 217 ++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 scripts/pregenerate_images.py diff --git a/frontend/src/definitions.ts b/frontend/src/definitions.ts index eb34edc5..968533a5 100644 --- a/frontend/src/definitions.ts +++ b/frontend/src/definitions.ts @@ -213,10 +213,8 @@ export function renderDefinitionCard( export function renderWordImage(word: string, lang: string, container: HTMLElement): void { const url = `/${lang}/api/word-image/${encodeURIComponent(word)}`; const img = document.createElement('img'); - img.src = url; - img.alt = word; img.className = 'w-full max-h-48 object-contain rounded-lg'; - img.loading = 'lazy'; + img.alt = word; img.onload = () => { container.innerHTML = ''; container.appendChild(img); @@ -225,6 +223,9 @@ export function renderWordImage(word: string, lang: string, container: HTMLEleme img.onerror = () => { container.style.display = 'none'; }; + // Set src last to ensure handlers are attached before load starts + // Do NOT use loading="lazy" — the img is detached from DOM during load + img.src = url; } /** diff --git a/scripts/pregenerate_images.py b/scripts/pregenerate_images.py new file mode 100644 index 00000000..9d46b458 --- /dev/null +++ b/scripts/pregenerate_images.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Pre-generate AI word art images for upcoming daily words. + +Run daily via cron to ensure images are cached before players see them. +Generates images for today and tomorrow across top languages. + +Usage: + uv run python scripts/pregenerate_images.py # top 20 languages, today + tomorrow + uv run python scripts/pregenerate_images.py --all # all languages + uv run python scripts/pregenerate_images.py --days 3 # today + next 3 days + uv run python scripts/pregenerate_images.py --lang en # single language + +Requires OPENAI_API_KEY in .env or environment. +""" + +import argparse +import json +import os +import re +import sys +import tempfile +import time +import urllib.parse +import urllib.request + +# Add project root to path so we can import from webapp +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +os.chdir(os.path.join(os.path.dirname(__file__), "..")) + +from webapp.app import ( + get_todays_idx, + get_word_for_day, + language_codes, + language_configs, + language_popularity, +) + +# Top languages by traffic (pre-generate these by default) +TOP_LANGUAGES = language_popularity[:20] + +CACHE_DIR = os.path.join("webapp", "static", "word-images") +DEFS_CACHE_DIR = os.path.join("webapp", "static", "word-defs") + + +def fetch_definition(word, lang_code): + """Fetch definition from English Wiktionary (best-effort).""" + try: + url = f"https://en.wiktionary.org/api/rest_v1/page/definition/{urllib.parse.quote(word.lower())}" + req = urllib.request.Request(url, headers={"User-Agent": "WordleGlobal/1.0"}) + with urllib.request.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read()) + for try_lang in [lang_code, "en"]: + entries = data.get(try_lang, []) + if entries and entries[0].get("definitions"): + raw_def = entries[0]["definitions"][0].get("definition", "") + clean_def = re.sub(r"<[^>]+>", "", raw_def).strip() + if clean_def: + return clean_def[:200] + except Exception: + pass + return None + + +def cache_definition(word, lang_code, definition): + """Cache definition to disk for the word page.""" + cache_dir = os.path.join(DEFS_CACHE_DIR, lang_code) + cache_path = os.path.join(cache_dir, f"{word.lower()}.json") + if os.path.exists(cache_path): + return + os.makedirs(cache_dir, exist_ok=True) + result = {} + if definition: + result = { + "definition": definition, + "source": "english", + "url": f"https://en.wiktionary.org/wiki/{urllib.parse.quote(word.lower())}", + } + with open(cache_path, "w") as f: + json.dump(result, f) + + +def generate_image(word, lang_code, definition=None): + """Generate and cache a word art image via DALL-E.""" + import openai + + lang_dir = os.path.join(CACHE_DIR, lang_code) + cache_path = os.path.join(lang_dir, f"{word.lower()}.webp") + + if os.path.exists(cache_path): + return "cached" + + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + return "no_api_key" + + definition_hint = f' meaning "{definition}"' if definition else "" + + prompt = ( + f"A single centered watercolor illustration representing the concept " + f'"{word}"{definition_hint}. ' + f"Soft pastel colors, gentle brush strokes, white background, " + f"no text, no letters, no words, no numbers. " + f"Simple and elegant, suitable as a small card illustration." + ) + + try: + client = openai.OpenAI(api_key=api_key) + response = client.images.generate( + model="dall-e-3", + prompt=prompt, + size="1024x1024", + quality="standard", + n=1, + ) + + image_url = response.data[0].url + if not image_url: + return "no_url" + + # Download to temp file + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp_path = tmp.name + urllib.request.urlretrieve(image_url, tmp_path) + + # Convert to WebP + os.makedirs(lang_dir, exist_ok=True) + try: + from PIL import Image + + with Image.open(tmp_path) as img: + img.save(cache_path, "WebP", quality=80) + except ImportError: + import shutil + + shutil.move(tmp_path, cache_path) + tmp_path = None + finally: + if tmp_path and os.path.exists(tmp_path): + os.unlink(tmp_path) + + size_kb = os.path.getsize(cache_path) / 1024 + return f"generated ({size_kb:.0f}KB)" + except Exception as e: + return f"error: {e}" + + +def main(): + parser = argparse.ArgumentParser(description="Pre-generate word art images") + parser.add_argument("--all", action="store_true", help="Generate for all languages") + parser.add_argument("--lang", type=str, help="Generate for a single language") + parser.add_argument( + "--days", type=int, default=1, help="Days ahead to generate (default: 1 = today + tomorrow)" + ) + parser.add_argument("--dry-run", action="store_true", help="Show what would be generated") + args = parser.parse_args() + + if not os.environ.get("OPENAI_API_KEY"): + print("Error: OPENAI_API_KEY not set in environment or .env") + sys.exit(1) + + if args.lang: + if args.lang not in language_codes: + print(f"Error: Unknown language '{args.lang}'") + sys.exit(1) + langs = [args.lang] + elif args.all: + langs = list(language_codes) + else: + langs = TOP_LANGUAGES + + todays_idx = get_todays_idx() + day_range = range(todays_idx, todays_idx + args.days + 1) + + total = len(langs) * len(day_range) + generated = 0 + cached = 0 + errors = 0 + + print(f"Generating images for {len(langs)} languages, days {day_range.start}-{day_range.stop - 1}") + print(f"Total words to process: {total}\n") + + for day_idx in day_range: + for lang in langs: + word = get_word_for_day(lang, day_idx) + lang_name = language_configs[lang].get("name", lang) + + if args.dry_run: + cache_path = os.path.join(CACHE_DIR, lang, f"{word.lower()}.webp") + status = "cached" if os.path.exists(cache_path) else "pending" + print(f" [{status}] {lang} #{day_idx}: {word} ({lang_name})") + continue + + # Fetch and cache definition (fast, no rate limit concern) + definition = fetch_definition(word, lang) + cache_definition(word, lang, definition) + + # Generate image + start = time.time() + result = generate_image(word, lang, definition) + elapsed = time.time() - start + + if result == "cached": + cached += 1 + print(f" [cached] {lang} #{day_idx}: {word}") + elif result.startswith("generated"): + generated += 1 + print(f" [generated] {lang} #{day_idx}: {word} ({lang_name}) - {result} in {elapsed:.1f}s") + else: + errors += 1 + print(f" [error] {lang} #{day_idx}: {word} - {result}") + + if not args.dry_run: + print(f"\nDone: {generated} generated, {cached} cached, {errors} errors") + + +if __name__ == "__main__": + main() From b3164d9870b8fcc417308dc196659c09afe5870f Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 22 Feb 2026 12:14:10 +0000 Subject: [PATCH 09/39] fix: Allow image generation for past daily words on word subpages - Word-image endpoint now accepts ?day_idx= param to verify historical words - Word subpage loads images on-demand via endpoint (not just pre-cached) - OG/Twitter image tags point to endpoint URL for social preview generation - Image hidden with onerror if generation fails or API key not configured --- webapp/app.py | 23 ++++++++++++----------- webapp/templates/word.html | 20 +++++++++----------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/webapp/app.py b/webapp/app.py index 854f5d3f..f2869051 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -940,11 +940,18 @@ def word_image(lang_code, word): if lang_code not in language_codes: return "Language not found", 404 - # Only allow image generation for today's word - word_list = language_codes_5words[lang_code] - lang = Language(lang_code, word_list) - if word.lower() != lang.daily_word.lower(): - return "Not today's word", 403 + # Verify word is/was a daily word (prevents generating images for arbitrary words) + # Accept ?day_idx= param for historical words, otherwise check today + todays_idx = get_todays_idx() + day_idx = request.args.get("day_idx", type=int) + if day_idx is not None: + if day_idx < 1 or day_idx > todays_idx: + return "Invalid day index", 403 + expected_word = get_word_for_day(lang_code, day_idx) + else: + expected_word = get_word_for_day(lang_code, todays_idx) + if word.lower() != expected_word.lower(): + return "Not a valid daily word", 403 # Check cache first cache_dir = os.path.join(app.static_folder, "word-images", lang_code) @@ -1052,11 +1059,6 @@ def word_page(lang_code, day_idx): # Fetch definition (cached) definition = fetch_definition_cached(word, lang_code) - # Check if AI image exists - image_path = os.path.join(app.static_folder, "word-images", lang_code, f"{word.lower()}.webp") - has_image = os.path.exists(image_path) - image_url = f"/static/word-images/{lang_code}/{word.lower()}.webp" if has_image else None - # Load stats if available word_stats = _load_word_stats(lang_code, day_idx) @@ -1069,7 +1071,6 @@ def word_page(lang_code, day_idx): word=word, word_date=word_date, definition=definition, - image_url=image_url, word_stats=word_stats, todays_idx=todays_idx, config=config, diff --git a/webapp/templates/word.html b/webapp/templates/word.html index 36276662..0c6e6c7e 100644 --- a/webapp/templates/word.html +++ b/webapp/templates/word.html @@ -16,11 +16,9 @@ - {% if image_url %} - {# Override default OG image with word-specific AI art #} - - - {% endif %} + {# Word-specific AI art as social preview (generates on first crawl if not cached) #} + + @@ -43,13 +41,13 @@

Wordle {{ lang_name_native }} #{{ day_idx }}

{{ word_date.strftime('%B %d, %Y') }}

- {# AI Word Art Image #} - {% if image_url %} -
- {{ word }} + {# AI Word Art Image — loads on-demand via endpoint, hides on error #} + - {% endif %} {# Word Tiles #}
From ab923d1b3f56989a63488c981fc8b13ade06840a Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 22 Feb 2026 12:17:51 +0000 Subject: [PATCH 10/39] fix: Word subpages only show past words, not today's - Word page returns 404 for today's word (day_idx >= todays_idx) - Nav "next" link stops at yesterday, shows "Latest" label - Sitemap excludes today's word pages - Fix pregenerate script working directory for webapp imports --- scripts/pregenerate_images.py | 11 +++++++---- webapp/app.py | 6 +++--- webapp/templates/word.html | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/scripts/pregenerate_images.py b/scripts/pregenerate_images.py index 9d46b458..3e892906 100644 --- a/scripts/pregenerate_images.py +++ b/scripts/pregenerate_images.py @@ -24,8 +24,11 @@ import urllib.request # Add project root to path so we can import from webapp -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -os.chdir(os.path.join(os.path.dirname(__file__), "..")) +# The app uses relative paths from webapp/, so chdir there +_script_dir = os.path.dirname(os.path.abspath(__file__)) +_project_root = os.path.join(_script_dir, "..") +sys.path.insert(0, _project_root) +os.chdir(os.path.join(_project_root, "webapp")) from webapp.app import ( get_todays_idx, @@ -38,8 +41,8 @@ # Top languages by traffic (pre-generate these by default) TOP_LANGUAGES = language_popularity[:20] -CACHE_DIR = os.path.join("webapp", "static", "word-images") -DEFS_CACHE_DIR = os.path.join("webapp", "static", "word-defs") +CACHE_DIR = os.path.join(_project_root, "webapp", "static", "word-images") +DEFS_CACHE_DIR = os.path.join(_project_root, "webapp", "static", "word-defs") def fetch_definition(word, lang_code): diff --git a/webapp/app.py b/webapp/app.py index f2869051..167a08ef 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -885,7 +885,7 @@ def site_map(): # Generate word page entries for the last 90 days across all languages todays_idx = get_todays_idx() word_pages = [] - for day_offset in range(90): + for day_offset in range(1, 91): # Start at 1 to exclude today d_idx = todays_idx - day_offset if d_idx < 1: break @@ -1045,9 +1045,9 @@ def word_page(lang_code, day_idx): if lang_code not in language_codes: return "Language not found", 404 - # Don't reveal future words + # Only reveal past words — today's word is still in play todays_idx = get_todays_idx() - if day_idx > todays_idx or day_idx < 1: + if day_idx >= todays_idx or day_idx < 1: return "Word not available yet", 404 word = get_word_for_day(lang_code, day_idx) diff --git a/webapp/templates/word.html b/webapp/templates/word.html index 0c6e6c7e..5fcfda56 100644 --- a/webapp/templates/word.html +++ b/webapp/templates/word.html @@ -153,13 +153,13 @@

#{{ day_idx + 1 }} → {% else %} - Today + Latest {% endif %}

From 0646815c0be9a36cd0792094d2ec70c1ed8de219 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 22 Feb 2026 12:31:15 +0000 Subject: [PATCH 11/39] fix: Add --past flag to pregenerate script for historical images --- scripts/pregenerate_images.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/pregenerate_images.py b/scripts/pregenerate_images.py index 3e892906..d7582164 100644 --- a/scripts/pregenerate_images.py +++ b/scripts/pregenerate_images.py @@ -154,6 +154,9 @@ def main(): parser.add_argument( "--days", type=int, default=1, help="Days ahead to generate (default: 1 = today + tomorrow)" ) + parser.add_argument( + "--past", type=int, default=0, help="Days in the past to also generate (e.g. --past 1 = yesterday)" + ) parser.add_argument("--dry-run", action="store_true", help="Show what would be generated") args = parser.parse_args() @@ -172,7 +175,8 @@ def main(): langs = TOP_LANGUAGES todays_idx = get_todays_idx() - day_range = range(todays_idx, todays_idx + args.days + 1) + start_idx = max(1, todays_idx - args.past) + day_range = range(start_idx, todays_idx + args.days + 1) total = len(langs) * len(day_range) generated = 0 From 965e5270736c4738b818c751c16c1583b58d7134 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 22 Feb 2026 18:56:12 +0000 Subject: [PATCH 12/39] feat: Production-ready word pages with persistent storage and code cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DATA_DIR env var for Render persistent disk (/data) with fallback to webapp/static - Add pending file lock to prevent duplicate DALL-E calls from concurrent requests - Limit image generation to top 10 languages (IMAGE_LANGUAGES) - Deduplicate pregenerate_images.py — reuse shared functions from app.py - Rework Today tab: word (linked to subpage), definition, image, share, timer - Move emoji grid to Stats tab with second share button - Add Wiktionary link on word subpages - Allow today's word on subpages (not just past words) - Remove unused rembg dependency - Remove console.log from production code --- frontend/src/game.ts | 1 - pyproject.toml | 1 - render.yaml | 6 ++ scripts/pregenerate_images.py | 164 +++++++---------------------- uv.lock | 26 ----- webapp/app.py | 190 ++++++++++++++++++++-------------- webapp/templates/game.html | 79 +++++++++----- webapp/templates/word.html | 17 ++- 8 files changed, 218 insertions(+), 266 deletions(-) diff --git a/frontend/src/game.ts b/frontend/src/game.ts index ed235c86..53194a35 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -1101,7 +1101,6 @@ export const createGameApp = () => { return; } catch (error) { if (error instanceof Error) { - console.log('Clipboard API failed:', error.message); analytics.trackShareFail(langCode, 'clipboard', error.message); } } diff --git a/pyproject.toml b/pyproject.toml index c99d7e31..e7360a0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ dependencies = [ "flask-cors>=6.0.0", "gunicorn>=24.0.0", "Flask-FlatPages>=0.9.0", - "nltk>=3.9.0", "openai>=2.21.0", "pillow>=12.1.1", ] diff --git a/render.yaml b/render.yaml index b4ebeb8f..0e04e7c2 100644 --- a/render.yaml +++ b/render.yaml @@ -13,3 +13,9 @@ services: value: "3.12" - key: NODE_VERSION value: "22" + - key: DATA_DIR + value: /data + disk: + name: wordle-data + mountPath: /data + sizeGB: 5 diff --git a/scripts/pregenerate_images.py b/scripts/pregenerate_images.py index d7582164..6502ef6d 100644 --- a/scripts/pregenerate_images.py +++ b/scripts/pregenerate_images.py @@ -5,7 +5,7 @@ Generates images for today and tomorrow across top languages. Usage: - uv run python scripts/pregenerate_images.py # top 20 languages, today + tomorrow + uv run python scripts/pregenerate_images.py # top 10 languages, today + tomorrow uv run python scripts/pregenerate_images.py --all # all languages uv run python scripts/pregenerate_images.py --days 3 # today + next 3 days uv run python scripts/pregenerate_images.py --lang en # single language @@ -14,14 +14,9 @@ """ import argparse -import json import os -import re import sys -import tempfile import time -import urllib.parse -import urllib.request # Add project root to path so we can import from webapp # The app uses relative paths from webapp/, so chdir there @@ -31,121 +26,16 @@ os.chdir(os.path.join(_project_root, "webapp")) from webapp.app import ( + IMAGE_LANGUAGES, + WORD_IMAGES_DIR, + fetch_definition_cached, + generate_word_image, get_todays_idx, get_word_for_day, language_codes, language_configs, - language_popularity, ) -# Top languages by traffic (pre-generate these by default) -TOP_LANGUAGES = language_popularity[:20] - -CACHE_DIR = os.path.join(_project_root, "webapp", "static", "word-images") -DEFS_CACHE_DIR = os.path.join(_project_root, "webapp", "static", "word-defs") - - -def fetch_definition(word, lang_code): - """Fetch definition from English Wiktionary (best-effort).""" - try: - url = f"https://en.wiktionary.org/api/rest_v1/page/definition/{urllib.parse.quote(word.lower())}" - req = urllib.request.Request(url, headers={"User-Agent": "WordleGlobal/1.0"}) - with urllib.request.urlopen(req, timeout=5) as resp: - data = json.loads(resp.read()) - for try_lang in [lang_code, "en"]: - entries = data.get(try_lang, []) - if entries and entries[0].get("definitions"): - raw_def = entries[0]["definitions"][0].get("definition", "") - clean_def = re.sub(r"<[^>]+>", "", raw_def).strip() - if clean_def: - return clean_def[:200] - except Exception: - pass - return None - - -def cache_definition(word, lang_code, definition): - """Cache definition to disk for the word page.""" - cache_dir = os.path.join(DEFS_CACHE_DIR, lang_code) - cache_path = os.path.join(cache_dir, f"{word.lower()}.json") - if os.path.exists(cache_path): - return - os.makedirs(cache_dir, exist_ok=True) - result = {} - if definition: - result = { - "definition": definition, - "source": "english", - "url": f"https://en.wiktionary.org/wiki/{urllib.parse.quote(word.lower())}", - } - with open(cache_path, "w") as f: - json.dump(result, f) - - -def generate_image(word, lang_code, definition=None): - """Generate and cache a word art image via DALL-E.""" - import openai - - lang_dir = os.path.join(CACHE_DIR, lang_code) - cache_path = os.path.join(lang_dir, f"{word.lower()}.webp") - - if os.path.exists(cache_path): - return "cached" - - api_key = os.environ.get("OPENAI_API_KEY") - if not api_key: - return "no_api_key" - - definition_hint = f' meaning "{definition}"' if definition else "" - - prompt = ( - f"A single centered watercolor illustration representing the concept " - f'"{word}"{definition_hint}. ' - f"Soft pastel colors, gentle brush strokes, white background, " - f"no text, no letters, no words, no numbers. " - f"Simple and elegant, suitable as a small card illustration." - ) - - try: - client = openai.OpenAI(api_key=api_key) - response = client.images.generate( - model="dall-e-3", - prompt=prompt, - size="1024x1024", - quality="standard", - n=1, - ) - - image_url = response.data[0].url - if not image_url: - return "no_url" - - # Download to temp file - with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: - tmp_path = tmp.name - urllib.request.urlretrieve(image_url, tmp_path) - - # Convert to WebP - os.makedirs(lang_dir, exist_ok=True) - try: - from PIL import Image - - with Image.open(tmp_path) as img: - img.save(cache_path, "WebP", quality=80) - except ImportError: - import shutil - - shutil.move(tmp_path, cache_path) - tmp_path = None - finally: - if tmp_path and os.path.exists(tmp_path): - os.unlink(tmp_path) - - size_kb = os.path.getsize(cache_path) / 1024 - return f"generated ({size_kb:.0f}KB)" - except Exception as e: - return f"error: {e}" - def main(): parser = argparse.ArgumentParser(description="Pre-generate word art images") @@ -155,12 +45,16 @@ def main(): "--days", type=int, default=1, help="Days ahead to generate (default: 1 = today + tomorrow)" ) parser.add_argument( - "--past", type=int, default=0, help="Days in the past to also generate (e.g. --past 1 = yesterday)" + "--past", + type=int, + default=0, + help="Days in the past to also generate (e.g. --past 1 = yesterday)", ) parser.add_argument("--dry-run", action="store_true", help="Show what would be generated") args = parser.parse_args() - if not os.environ.get("OPENAI_API_KEY"): + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: print("Error: OPENAI_API_KEY not set in environment or .env") sys.exit(1) @@ -172,7 +66,7 @@ def main(): elif args.all: langs = list(language_codes) else: - langs = TOP_LANGUAGES + langs = IMAGE_LANGUAGES todays_idx = get_todays_idx() start_idx = max(1, todays_idx - args.past) @@ -183,7 +77,9 @@ def main(): cached = 0 errors = 0 - print(f"Generating images for {len(langs)} languages, days {day_range.start}-{day_range.stop - 1}") + print( + f"Generating images for {len(langs)} languages, days {day_range.start}-{day_range.stop - 1}" + ) print(f"Total words to process: {total}\n") for day_idx in day_range: @@ -191,27 +87,37 @@ def main(): word = get_word_for_day(lang, day_idx) lang_name = language_configs[lang].get("name", lang) + cache_dir = os.path.join(WORD_IMAGES_DIR, lang) + cache_path = os.path.join(cache_dir, f"{word.lower()}.webp") + if args.dry_run: - cache_path = os.path.join(CACHE_DIR, lang, f"{word.lower()}.webp") status = "cached" if os.path.exists(cache_path) else "pending" print(f" [{status}] {lang} #{day_idx}: {word} ({lang_name})") continue - # Fetch and cache definition (fast, no rate limit concern) - definition = fetch_definition(word, lang) - cache_definition(word, lang, definition) + if os.path.exists(cache_path): + cached += 1 + print(f" [cached] {lang} #{day_idx}: {word}") + continue + + # Fetch and cache definition + defn = fetch_definition_cached(word, lang) + definition_hint = "" + if defn and defn.get("definition"): + definition_hint = f", which means {defn['definition']}" # Generate image start = time.time() - result = generate_image(word, lang, definition) + result = generate_word_image(word, definition_hint, api_key, cache_dir, cache_path) elapsed = time.time() - start - if result == "cached": - cached += 1 - print(f" [cached] {lang} #{day_idx}: {word}") - elif result.startswith("generated"): + if result == "ok": generated += 1 - print(f" [generated] {lang} #{day_idx}: {word} ({lang_name}) - {result} in {elapsed:.1f}s") + size_kb = os.path.getsize(cache_path) / 1024 + print( + f" [generated] {lang} #{day_idx}: {word} ({lang_name})" + f" - {size_kb:.0f}KB in {elapsed:.1f}s" + ) else: errors += 1 print(f" [error] {lang} #{day_idx}: {word} - {result}") diff --git a/uv.lock b/uv.lock index 868b558f..6eb9b206 100644 --- a/uv.lock +++ b/uv.lock @@ -339,15 +339,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, ] -[[package]] -name = "joblib" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, -] - [[package]] name = "langcodes" version = "3.5.1" @@ -511,21 +502,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "nltk" -version = "3.9.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "joblib" }, - { name = "regex" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f9/76/3a5e4312c19a028770f86fd7c058cf9f4ec4321c6cf7526bab998a5b683c/nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419", size = 2887629, upload-time = "2025-10-01T07:19:23.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a", size = 1513404, upload-time = "2025-10-01T07:19:21.648Z" }, -] - [[package]] name = "openai" version = "2.21.0" @@ -1086,7 +1062,6 @@ dependencies = [ { name = "flask-cors" }, { name = "flask-flatpages" }, { name = "gunicorn" }, - { name = "nltk" }, { name = "openai" }, { name = "pillow" }, ] @@ -1104,7 +1079,6 @@ requires-dist = [ { name = "flask-cors", specifier = ">=6.0.0" }, { name = "flask-flatpages", specifier = ">=0.9.0" }, { name = "gunicorn", specifier = ">=24.0.0" }, - { name = "nltk", specifier = ">=3.9.0" }, { name = "openai", specifier = ">=2.21.0" }, { name = "pillow", specifier = ">=12.1.1" }, ] diff --git a/webapp/app.py b/webapp/app.py index 167a08ef..2a52e299 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -3,8 +3,8 @@ render_template, make_response, redirect, - url_for, request, + send_from_directory, ) import json import os @@ -27,6 +27,12 @@ _key, _, _val = _line.partition("=") os.environ.setdefault(_key.strip(), _val.strip()) +# Persistent data directory — /data in production (Render disk), local fallback for dev +DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "static")) +WORD_IMAGES_DIR = os.path.join(DATA_DIR, "word-images") +WORD_DEFS_DIR = os.path.join(DATA_DIR, "word-defs") +WORD_STATS_DIR = os.path.join(DATA_DIR, "word-stats") + # set random seed 42 for reproducibility (important to maintain stable word lists) # NOTE: This is only used for the LEGACY algorithm (days before MIGRATION_DAY_IDX) random.seed(42) @@ -538,6 +544,10 @@ def load_languages(): "rw", # Kinyarwanda - 5 sessions ] +# Languages that get AI-generated word art images (top 10 by traffic) +# Generating images costs ~$0.04/image via DALL-E 3, so we limit to popular languages +IMAGE_LANGUAGES = language_popularity[:10] + # status with open("../scripts/out/status_list.txt", "r") as f: status_list = [line.strip() for line in f] @@ -765,7 +775,7 @@ def fetch_definition_cached(word, lang_code): Returns dict with keys: definition, part_of_speech, source, url. Returns None if no definition found. """ - cache_dir = os.path.join(app.static_folder, "word-defs", lang_code) + cache_dir = os.path.join(WORD_DEFS_DIR, lang_code) cache_path = os.path.join(cache_dir, f"{word.lower()}.json") # Check cache first @@ -821,7 +831,7 @@ def fetch_definition_cached(word, lang_code): def _load_word_stats(lang_code, day_idx): """Load stats for a specific word/day.""" - stats_path = os.path.join(app.static_folder, "word-stats", lang_code, f"{day_idx}.json") + stats_path = os.path.join(WORD_STATS_DIR, lang_code, f"{day_idx}.json") if os.path.exists(stats_path): try: with open(stats_path, "r") as f: @@ -833,7 +843,7 @@ def _load_word_stats(lang_code, day_idx): def _save_word_stats(lang_code, day_idx, stats): """Save stats for a specific word/day.""" - stats_dir = os.path.join(app.static_folder, "word-stats", lang_code) + stats_dir = os.path.join(WORD_STATS_DIR, lang_code) stats_path = os.path.join(stats_dir, f"{day_idx}.json") try: os.makedirs(stats_dir, exist_ok=True) @@ -885,7 +895,7 @@ def site_map(): # Generate word page entries for the last 90 days across all languages todays_idx = get_todays_idx() word_pages = [] - for day_offset in range(1, 91): # Start at 1 to exclude today + for day_offset in range(0, 91): # Include today d_idx = todays_idx - day_offset if d_idx < 1: break @@ -926,76 +936,31 @@ def language(lang_code): return response -@app.route("//api/word-image/") -def word_image(lang_code, word): - """Serve an AI-generated illustration for the daily word. - - Only works when OPENAI_API_KEY is set. Images are cached to disk. - Only generates images for the current daily word (prevents abuse). - """ - openai_key = os.environ.get("OPENAI_API_KEY") - if not openai_key: - return "Image generation not configured", 404 - - if lang_code not in language_codes: - return "Language not found", 404 +############################################################################### +# AI WORD ART IMAGE GENERATION +############################################################################### - # Verify word is/was a daily word (prevents generating images for arbitrary words) - # Accept ?day_idx= param for historical words, otherwise check today - todays_idx = get_todays_idx() - day_idx = request.args.get("day_idx", type=int) - if day_idx is not None: - if day_idx < 1 or day_idx > todays_idx: - return "Invalid day index", 403 - expected_word = get_word_for_day(lang_code, day_idx) - else: - expected_word = get_word_for_day(lang_code, todays_idx) - if word.lower() != expected_word.lower(): - return "Not a valid daily word", 403 - # Check cache first - cache_dir = os.path.join(app.static_folder, "word-images", lang_code) - cache_path = os.path.join(cache_dir, f"{word.lower()}.webp") +def build_image_prompt(word, definition_hint=""): + """Build the DALL-E prompt for a word image.""" + return ( + f"A stylized 3D render of a single object that represents " + f"{word}{definition_hint}. " + f"Smooth rounded shapes, cheerful pastel colors, " + f"clean white background, centered composition. " + f"No text, no letters, no UI elements." + ) - if os.path.exists(cache_path): - return app.send_static_file(f"word-images/{lang_code}/{word.lower()}.webp") - # HEAD requests just check cache — don't trigger generation - if request.method == "HEAD": - return "", 404 +def generate_word_image(word, definition_hint, api_key, cache_dir, cache_path): + """Generate a word art image via DALL-E and save as WebP. Returns 'ok' or error string.""" + import tempfile - # Fetch definition to include in prompt (best-effort) - definition_hint = "" - try: - def_url = f"https://en.wiktionary.org/api/rest_v1/page/definition/{urllib.parse.quote(word.lower())}" - req = urlreq.Request(def_url, headers={"User-Agent": "WordleGlobal/1.0"}) - with urlreq.urlopen(req, timeout=5) as resp: - def_data = json.loads(resp.read()) - for try_lang in [lang_code, "en"]: - entries = def_data.get(try_lang, []) - if entries and entries[0].get("definitions"): - raw_def = entries[0]["definitions"][0].get("definition", "") - clean_def = re.sub(r"<[^>]+>", "", raw_def) - if clean_def: - definition_hint = f' meaning "{clean_def}"' - break - except Exception: - pass # Definition is optional for image generation + import openai - # Generate image via DALL-E try: - import openai - - client = openai.OpenAI(api_key=openai_key) - lang_name = language_configs[lang_code].get("name", lang_code) - - prompt = ( - f"A single centered watercolor illustration representing the concept " - f'"{word}"{definition_hint}. ' - f"Soft pastel colors, gentle brush strokes, white background, " - f"no text, no letters, no words, no numbers. " - f"Simple and elegant, suitable as a small card illustration." - ) + client = openai.OpenAI(api_key=api_key) + prompt = build_image_prompt(word, definition_hint) response = client.images.generate( model="dall-e-3", @@ -1007,10 +972,7 @@ def word_image(lang_code, word): image_url = response.data[0].url if not image_url: - return "Image generation failed", 500 - - # Download and convert to WebP - import tempfile + return "no_url" os.makedirs(cache_dir, exist_ok=True) @@ -1024,7 +986,6 @@ def word_image(lang_code, word): with Image.open(tmp_path) as img: img.save(cache_path, "WebP", quality=80) except ImportError: - # Pillow not installed — save raw PNG with .webp extension import shutil as _shutil _shutil.move(tmp_path, cache_path) @@ -1033,10 +994,85 @@ def word_image(lang_code, word): if tmp_path and os.path.exists(tmp_path): os.unlink(tmp_path) - return app.send_static_file(f"word-images/{lang_code}/{word.lower()}.webp") + return "ok" except Exception as e: - print(f"Image generation failed for {lang_code}/{word}: {e}") + return f"error: {e}" + + +@app.route("//api/word-image/") +def word_image(lang_code, word): + """Serve an AI-generated illustration for the daily word. + + Only works when OPENAI_API_KEY is set. Images are cached to disk. + Only generates images for the current daily word (prevents abuse). + """ + openai_key = os.environ.get("OPENAI_API_KEY") + if not openai_key: + return "Image generation not configured", 404 + + if lang_code not in language_codes: + return "Language not found", 404 + + # Only generate images for top languages (cost control) + if lang_code not in IMAGE_LANGUAGES: + # Still serve cached images if they exist + cache_dir = os.path.join(WORD_IMAGES_DIR, lang_code) + cache_path = os.path.join(cache_dir, f"{word.lower()}.webp") + if os.path.exists(cache_path): + return send_from_directory(cache_dir, f"{word.lower()}.webp") + return "Image not available for this language", 404 + + # Verify word is/was a daily word (prevents generating images for arbitrary words) + # Accept ?day_idx= param for historical words, otherwise check today + todays_idx = get_todays_idx() + day_idx = request.args.get("day_idx", type=int) + if day_idx is not None: + if day_idx < 1 or day_idx > todays_idx: + return "Invalid day index", 403 + expected_word = get_word_for_day(lang_code, day_idx) + else: + expected_word = get_word_for_day(lang_code, todays_idx) + if word.lower() != expected_word.lower(): + return "Not a valid daily word", 403 + + # Check cache first + cache_dir = os.path.join(WORD_IMAGES_DIR, lang_code) + cache_path = os.path.join(cache_dir, f"{word.lower()}.webp") + + if os.path.exists(cache_path): + return send_from_directory(cache_dir, f"{word.lower()}.webp") + + # HEAD requests just check cache — don't trigger generation + if request.method == "HEAD": + return "", 404 + + # Skip if another request is already generating this image + pending_path = cache_path + ".pending" + if os.path.exists(pending_path): + return "Image being generated", 202 + + # Mark as pending to prevent duplicate DALL-E calls + os.makedirs(cache_dir, exist_ok=True) + try: + open(pending_path, "x").close() + except FileExistsError: + return "Image being generated", 202 + + try: + # Use cached definition for prompt context + definition_hint = "" + cached_def = fetch_definition_cached(word, lang_code) + if cached_def and cached_def.get("definition"): + definition_hint = f", which means {cached_def['definition']}" + + # Generate image via DALL-E + result = generate_word_image(word, definition_hint, openai_key, cache_dir, cache_path) + if result == "ok": + return send_from_directory(cache_dir, f"{word.lower()}.webp") return "Image generation failed", 500 + finally: + if os.path.exists(pending_path): + os.unlink(pending_path) @app.route("//word/") @@ -1045,9 +1081,9 @@ def word_page(lang_code, day_idx): if lang_code not in language_codes: return "Language not found", 404 - # Only reveal past words — today's word is still in play + # Allow today's word (game reveals it after completion) and past words todays_idx = get_todays_idx() - if day_idx >= todays_idx or day_idx < 1: + if day_idx > todays_idx or day_idx < 1: return "Word not available yet", 404 word = get_word_for_day(lang_code, day_idx) diff --git a/webapp/templates/game.html b/webapp/templates/game.html index 407e0dff..981ddd5a 100644 --- a/webapp/templates/game.html +++ b/webapp/templates/game.html @@ -421,42 +421,45 @@

Wordle {{

- +
-
-

-

-

- {{ language.config.text.no_attempts }} -

+ +
+

{{ language.config.ui.todays_word or "Today's Word" }}

+ +

+ [[ todays_word ]] +

+
- - - - + -
-
-

{{ - language.config.text.next_word }}

-

-

- -
- -
+ + + + +
+ +
+ + +
+

{{ + language.config.text.next_word }}

+

- +
@@ -497,6 +500,26 @@

+

+

+

+ {{ language.config.text.no_attempts }} +

+

+ + +
+ +
diff --git a/webapp/templates/word.html b/webapp/templates/word.html index 5fcfda56..27bdf760 100644 --- a/webapp/templates/word.html +++ b/webapp/templates/word.html @@ -72,8 +72,7 @@

Wordle {{ lang_name_native }} #{{ day_idx }}

{{ word }} — {{ definition.definition }}

- {% if definition.url %} - Wiktionary @@ -81,7 +80,17 @@

Wordle {{ lang_name_native }} #{{ day_idx }} - {% endif %} +

+ {% else %} + {% endif %} @@ -153,7 +162,7 @@

#{{ day_idx + 1 }} → From 7bdbef6904af98c54def4e04af7cd38e207a37c8 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 22 Feb 2026 19:01:41 +0000 Subject: [PATCH 13/39] fix: Address code review feedback from PR #121 - Fix XSS: escape all Wiktionary API data before innerHTML interpolation - Fix TS strict: optional chaining on mock.calls[0] - Fix .env parser: strip surrounding quotes from values - Fix unbounded memory: clear _stats_seen_ips daily instead of growing forever - Fix concurrent stats writes: file locking around read-modify-write cycle - Fix urlretrieve timeout: use urlopen with 30s timeout for DALL-E downloads - Fix broad exception: catch specific OpenAI/network errors in image generation - Fix toggleDefinitions: reload definition when re-enabled after game completion - Fix unhandled promise: add .catch() to fetchDefinition promise chain - Fix JS injection: use tojson filter for lang_name_native in word.html script - Increase IMAGE_LANGUAGES from top 10 to top 30 --- frontend/src/__tests__/definitions.test.ts | 2 +- frontend/src/definitions.ts | 31 ++++++--- frontend/src/game.ts | 18 +++-- webapp/app.py | 81 ++++++++++++++-------- webapp/templates/word.html | 2 +- 5 files changed, 91 insertions(+), 43 deletions(-) diff --git a/frontend/src/__tests__/definitions.test.ts b/frontend/src/__tests__/definitions.test.ts index 9bacce85..6f0f049d 100644 --- a/frontend/src/__tests__/definitions.test.ts +++ b/frontend/src/__tests__/definitions.test.ts @@ -128,7 +128,7 @@ describe('fetchDefinition', () => { const result = await fetchDefinition('fugle', 'nb'); expect(result.source).toBe('native'); // Should use 'no' wiktionary, not 'nb' - const url = mockFetch.mock.calls[0][0] as string; + const url = mockFetch.mock.calls[0]?.[0] as string; expect(url).toContain('no.wiktionary.org'); }); diff --git a/frontend/src/definitions.ts b/frontend/src/definitions.ts index 968533a5..781a275f 100644 --- a/frontend/src/definitions.ts +++ b/frontend/src/definitions.ts @@ -43,6 +43,14 @@ function stripHtml(html: string): string { return div.textContent || div.innerText || ''; } +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + /** * Try to fetch definition from English Wiktionary REST API. * Returns definitions for the target language (not English definitions). @@ -167,33 +175,40 @@ export function renderDefinitionCard( const definitionLabel = uiStrings.definition || 'Definition'; const lookUpLabel = uiStrings.look_up_on_wiktionary || 'Look up on Wiktionary'; + const safeWord = escapeHtml(def.word); + const safeUrl = escapeHtml(def.url); + const safeLookUp = escapeHtml(lookUpLabel); + const safeDefLabel = escapeHtml(definitionLabel); + if (def.source === 'link') { // No definition found — show link only container.innerHTML = ` - - ${lookUpLabel}: ${def.word} + ${safeLookUp}: ${safeWord} `; } else { - const posHtml = def.partOfSpeech - ? `${def.partOfSpeech}` + const safePos = def.partOfSpeech ? escapeHtml(def.partOfSpeech) : ''; + const posHtml = safePos + ? `${safePos}` : ''; + const safeDef = escapeHtml(def.definition); container.innerHTML = `
- ${definitionLabel} + ${safeDefLabel} ${posHtml}
-

${def.word} — ${def.definition}

+

${safeWord} — ${safeDef}

- + diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 53194a35..07cdc95d 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -1276,6 +1276,10 @@ export const createGameApp = () => { setting: 'definitions', value: this.definitionsEnabled, }); + // Reload definition if re-enabled after game completion + if (this.definitionsEnabled && (this.gameWon || this.gameLost)) { + this.loadDefinition(); + } }); }, @@ -1315,12 +1319,16 @@ export const createGameApp = () => { const container = document.getElementById('definition-card'); if (container) { showDefinitionLoading(container); - fetchDefinition(this.todays_word, langCode).then((def) => { - renderDefinitionCard(def, container, { - definition: this.config?.ui?.definition, - look_up_on_wiktionary: this.config?.ui?.look_up_on_wiktionary, + fetchDefinition(this.todays_word, langCode) + .then((def) => { + renderDefinitionCard(def, container, { + definition: this.config?.ui?.definition, + look_up_on_wiktionary: this.config?.ui?.look_up_on_wiktionary, + }); + }) + .catch(() => { + container.style.display = 'none'; }); - }); } } diff --git a/webapp/app.py b/webapp/app.py index 2a52e299..d8ffda7e 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -25,7 +25,10 @@ _line = _line.strip() if _line and not _line.startswith("#") and "=" in _line: _key, _, _val = _line.partition("=") - os.environ.setdefault(_key.strip(), _val.strip()) + _val = _val.strip() + if len(_val) >= 2 and _val[0] == _val[-1] and _val[0] in ('"', "'"): + _val = _val[1:-1] + os.environ.setdefault(_key.strip(), _val) # Persistent data directory — /data in production (Render disk), local fallback for dev DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "static")) @@ -544,9 +547,9 @@ def load_languages(): "rw", # Kinyarwanda - 5 sessions ] -# Languages that get AI-generated word art images (top 10 by traffic) +# Languages that get AI-generated word art images (top 30 by traffic) # Generating images costs ~$0.04/image via DALL-E 3, so we limit to popular languages -IMAGE_LANGUAGES = language_popularity[:10] +IMAGE_LANGUAGES = language_popularity[:30] # status with open("../scripts/out/status_list.txt", "r") as f: @@ -827,6 +830,7 @@ def fetch_definition_cached(word, lang_code): # In-memory IP dedup (resets on restart, never persisted) _stats_seen_ips = {} +_stats_seen_day = None # Track current day to clear stale entries def _load_word_stats(lang_code, day_idx): @@ -841,16 +845,46 @@ def _load_word_stats(lang_code, day_idx): return None -def _save_word_stats(lang_code, day_idx, stats): - """Save stats for a specific word/day.""" +def _update_word_stats(lang_code, day_idx, won, attempts): + """Atomically read-modify-write stats for a specific word/day.""" + import fcntl + stats_dir = os.path.join(WORD_STATS_DIR, lang_code) stats_path = os.path.join(stats_dir, f"{day_idx}.json") - try: - os.makedirs(stats_dir, exist_ok=True) + os.makedirs(stats_dir, exist_ok=True) + + lock_path = stats_path + ".lock" + with open(lock_path, "w") as lock_f: + fcntl.flock(lock_f, fcntl.LOCK_EX) + + # Read + stats = None + if os.path.exists(stats_path): + try: + with open(stats_path, "r") as f: + stats = json.load(f) + except Exception: + pass + if not stats: + stats = { + "total": 0, + "wins": 0, + "losses": 0, + "distribution": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0}, + } + + # Update + stats["total"] += 1 + if won: + stats["wins"] += 1 + if isinstance(attempts, int) and 1 <= attempts <= 6: + stats["distribution"][str(attempts)] += 1 + else: + stats["losses"] += 1 + + # Write with open(stats_path, "w") as f: json.dump(stats, f) - except IOError: - pass ############################################################################### @@ -978,7 +1012,9 @@ def generate_word_image(word, definition_hint, api_key, cache_dir, cache_path): with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: tmp_path = tmp.name - urlreq.urlretrieve(image_url, tmp_path) + req = urlreq.Request(image_url) + with urlreq.urlopen(req, timeout=30) as resp: + tmp.write(resp.read()) try: from PIL import Image @@ -995,7 +1031,7 @@ def generate_word_image(word, definition_hint, api_key, cache_dir, cache_path): os.unlink(tmp_path) return "ok" - except Exception as e: + except (openai.OpenAIError, urlreq.URLError, IOError, OSError) as e: return f"error: {e}" @@ -1137,29 +1173,18 @@ def submit_word_stats(lang_code): return "", 403 # IP-based dedup (in-memory, resets on restart) + global _stats_seen_day + if _stats_seen_day != todays_idx: + _stats_seen_ips.clear() + _stats_seen_day = todays_idx + ip = request.remote_addr or "unknown" dedup_key = f"{lang_code}:{day_idx}:{ip}" if dedup_key in _stats_seen_ips: return "", 200 # Silently accept duplicate _stats_seen_ips[dedup_key] = True - # Load existing stats or create new - stats = _load_word_stats(lang_code, day_idx) or { - "total": 0, - "wins": 0, - "losses": 0, - "distribution": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0}, - } - - stats["total"] += 1 - if won: - stats["wins"] += 1 - if isinstance(attempts, int) and 1 <= attempts <= 6: - stats["distribution"][str(attempts)] += 1 - else: - stats["losses"] += 1 - - _save_word_stats(lang_code, day_idx, stats) + _update_word_stats(lang_code, day_idx, won, attempts) return "", 200 except Exception: return "", 500 diff --git a/webapp/templates/word.html b/webapp/templates/word.html index 27bdf760..ce25ab06 100644 --- a/webapp/templates/word.html +++ b/webapp/templates/word.html @@ -175,7 +175,7 @@