diff --git a/.gitignore b/.gitignore index c9bcf22c..0c9b335f 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,15 @@ 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/ + +# Cached word definitions (fetched from Wiktionary) +webapp/static/word-defs/ + +# Anonymous per-word game stats +webapp/static/word-stats/ + +# Frozen daily word history (ensures past words never change) +webapp/static/word-history/ diff --git a/README.md b/README.md index 041406b1..8a12f642 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Each language folder in `webapp/data/languages/` contains a `SOURCES.md` with de - [ ] Word definitions — show the definition of the daily word after the game (e.g. via Wiktionary API) - [ ] Native speaker review of daily word lists for remaining languages +- [ ] Consolidate per-language data files — there are currently 3 overlapping mechanisms controlling daily word selection: `_daily_words.txt` (curated subset), `_blocklist.txt` (exclusion list), and `_curated_schedule.txt` (day-by-day override), plus the fallback to `_5words.txt`. These could be unified into a single curated daily list per language. ## Credits diff --git a/frontend/src/__tests__/definitions.test.ts b/frontend/src/__tests__/definitions.test.ts new file mode 100644 index 00000000..9812d332 --- /dev/null +++ b/frontend/src/__tests__/definitions.test.ts @@ -0,0 +1,101 @@ +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('fetches definition from backend API', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + definition: 'A large bird.', + part_of_speech: 'Noun', + source: 'english', + url: 'https://en.wiktionary.org/wiki/crane', + }), + }); + + const result = await fetchDefinition('crane', 'en'); + expect(result.source).toBe('english'); + expect(result.definition).toBe('A large bird.'); + expect(result.partOfSpeech).toBe('Noun'); + expect(result.word).toBe('crane'); + + // Should call our backend API + const url = mockFetch.mock.calls[0]?.[0] as string; + expect(url).toBe('/en/api/definition/crane'); + }); + + it('returns native source when backend provides it', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + definition: 'Ein großer Vogel.', + source: 'native', + url: 'https://de.wiktionary.org/wiki/Kran', + }), + }); + + const result = await fetchDefinition('krane', 'de'); + expect(result.source).toBe('native'); + expect(result.definition).toBe('Ein großer Vogel.'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('returns link fallback when API returns 404', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + + 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')); + + const result = await fetchDefinition('test', 'de'); + expect(result.source).toBe('link'); + }); + + it('calls correct API URL for different languages', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + definition: 'En fugl.', + source: 'native', + url: 'https://no.wiktionary.org/wiki/fugle', + }), + }); + + await fetchDefinition('fugle', 'nb'); + const url = mockFetch.mock.calls[0]?.[0] as string; + // Frontend calls our API with the original lang code — backend handles mapping + expect(url).toBe('/nb/api/definition/fugle'); + }); + + it('maps part_of_speech from backend response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + definition: 'To operate a crane.', + part_of_speech: 'Verb', + source: 'english', + url: 'https://en.wiktionary.org/wiki/crane', + }), + }); + + const result = await fetchDefinition('crane', 'en'); + expect(result.partOfSpeech).toBe('Verb'); + }); +}); diff --git a/frontend/src/analytics.ts b/frontend/src/analytics.ts index bea2605e..72cf2047 100644 --- a/frontend/src/analytics.ts +++ b/frontend/src/analytics.ts @@ -82,7 +82,7 @@ interface InvalidWordParams { } interface SettingsChangeParams { - setting: 'dark_mode' | 'haptics' | 'sound'; + setting: 'dark_mode' | 'haptics' | 'sound' | 'feedback' | 'word_info' | 'definitions'; value: boolean; } diff --git a/frontend/src/definitions.ts b/frontend/src/definitions.ts new file mode 100644 index 00000000..0611955c --- /dev/null +++ b/frontend/src/definitions.ts @@ -0,0 +1,148 @@ +/** + * Word Definitions - fetched from our backend API + * The backend handles Wiktionary lookups and caching in one place. + */ +import type { WordDefinition } from './types'; + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Fetch a word definition from our backend API. + * The backend tries native Wiktionary first, then English Wiktionary, + * and caches results to disk. + */ +export async function fetchDefinition(word: string, lang: string): Promise { + try { + const response = await fetch(`/${lang}/api/definition/${encodeURIComponent(word)}`); + if (response.ok) { + const data = await response.json(); + return { + word, + partOfSpeech: data.part_of_speech || undefined, + definition: data.definition || '', + source: data.source || 'english', + url: data.url || '', + }; + } + } catch { + // Network error — fall through to link fallback + } + + // Fallback: no definition available + return { + word, + definition: '', + source: 'link', + url: `https://en.wiktionary.org/wiki/${encodeURIComponent(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 }, + wordPageUrl?: string +): void { + const definitionLabel = uiStrings.definition || 'Definition'; + + const safeWord = escapeHtml(def.word); + const safeDefLabel = escapeHtml(definitionLabel); + + const isSafeUrl = (url: string) => + url.startsWith('/') || url.startsWith('https://') || url.startsWith('http://'); + + const linkHtml = + wordPageUrl && isSafeUrl(wordPageUrl) + ? ` + + + + + ` + : ''; + + if (def.source === 'link') { + // No definition found — hide the card (word subpage has Wiktionary links) + container.style.display = 'none'; + return; + } else { + const safePos = def.partOfSpeech ? escapeHtml(def.partOfSpeech) : ''; + const posHtml = safePos + ? `${safePos}` + : ''; + const safeDef = escapeHtml(def.definition); + + container.innerHTML = ` +
+
+
+ ${safeDefLabel} + ${posHtml} +
+

${safeWord} — ${safeDef}

+
+ ${linkHtml} +
`; + } + + container.style.display = 'block'; +} + +/** + * 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(word: string, lang: string, container: HTMLElement): void { + const url = `/${lang}/api/word-image/${encodeURIComponent(word)}`; + const img = document.createElement('img'); + img.className = 'w-full max-h-48 object-contain rounded-lg'; + img.alt = word; + img.onload = () => { + container.innerHTML = ''; + container.appendChild(img); + container.style.display = 'block'; + }; + 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; +} + +/** + * Show loading state in the definition card + */ +export function showDefinitionLoading(container: HTMLElement): void { + container.innerHTML = ` +
+
+
+
+
+
`; + 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 3711c49d..d129b3eb 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -9,6 +9,13 @@ 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, + renderWordImage, + showImageLoading, +} from './definitions'; import type { PositionalConfig } from './positional'; import type { LanguageConfig, @@ -92,8 +99,8 @@ interface GameData { show_options_modal: boolean; show_not_valid_notif: boolean; darkMode: boolean; - hapticsEnabled: boolean; - soundEnabled: boolean; + feedbackEnabled: boolean; + wordInfoEnabled: boolean; notification: Notification; tiles: string[][]; tile_classes: string[][]; @@ -105,7 +112,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'; @@ -138,8 +145,8 @@ export const createGameApp = () => { show_options_modal: false, show_not_valid_notif: false, darkMode: document.documentElement.classList.contains('dark'), - hapticsEnabled: true, - soundEnabled: true, + feedbackEnabled: true, + wordInfoEnabled: true, shareButtonState: 'idle' as const, notification: { @@ -280,7 +287,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: {}, @@ -313,8 +320,8 @@ export const createGameApp = () => { window.addEventListener('keydown', (e) => this.keyDown(e)); this.loadGameResults(); this.loadLanguages(); - this.loadHapticsPreference(); - this.loadSoundPreference(); + this.loadFeedbackPreference(); + this.loadWordInfoPreference(); this.stats = this.calculateStats(this.config?.language_code); this.total_stats = this.calculateTotalStats(); this.time_until_next_day = this.getTimeUntilNextDay(); @@ -357,6 +364,7 @@ export const createGameApp = () => { if (this.game_over) { this.show_stats_modal = true; + this.loadDefinition(); } }, @@ -690,6 +698,9 @@ export const createGameApp = () => { this.show_stats_modal = true; }, 400); + this.loadDefinition(); + this.submitWordStats(true, this.active_row); + this.saveResult(true); this.stats = this.calculateStats(this.config?.language_code); this.total_stats = this.calculateTotalStats(); @@ -731,6 +742,9 @@ export const createGameApp = () => { this.show_stats_modal = true; }, 400); + this.loadDefinition(); + this.submitWordStats(false, this.active_row); + this.saveResult(false); this.stats = this.calculateStats(this.config?.language_code); this.total_stats = this.calculateTotalStats(); @@ -852,6 +866,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 @@ -1079,7 +1095,6 @@ export const createGameApp = () => { return; } catch (error) { if (error instanceof Error) { - console.log('Clipboard API failed:', error.message); analytics.trackShareFail(langCode, 'clipboard', error.message); } } @@ -1167,69 +1182,132 @@ export const createGameApp = () => { }); }, - loadHapticsPreference(): void { + loadFeedbackPreference(): void { try { - const stored = localStorage.getItem('hapticsEnabled'); + const stored = localStorage.getItem('feedbackEnabled'); if (stored !== null) { - this.hapticsEnabled = stored === 'true'; + this.feedbackEnabled = stored === 'true'; } else { - // Default to enabled - this.hapticsEnabled = true; + this.feedbackEnabled = true; } - setHapticsEnabled(this.hapticsEnabled); + setHapticsEnabled(this.feedbackEnabled); + setSoundEnabled(this.feedbackEnabled); } catch { // localStorage unavailable } }, - toggleHaptics(): void { + toggleFeedback(): void { this.$nextTick(() => { - setHapticsEnabled(this.hapticsEnabled); - if (this.hapticsEnabled) { - haptic(); // Give feedback that haptics are now on + setHapticsEnabled(this.feedbackEnabled); + setSoundEnabled(this.feedbackEnabled); + if (this.feedbackEnabled) { + haptic(); } try { localStorage.setItem( - 'hapticsEnabled', - this.hapticsEnabled ? 'true' : 'false' + 'feedbackEnabled', + this.feedbackEnabled ? 'true' : 'false' ); } catch { // localStorage unavailable } analytics.trackSettingsChange({ - setting: 'haptics', - value: this.hapticsEnabled, + setting: 'feedback', + value: this.feedbackEnabled, }); }); }, - loadSoundPreference(): void { + loadWordInfoPreference(): void { try { - const stored = localStorage.getItem('soundEnabled'); + const stored = localStorage.getItem('wordInfoEnabled'); if (stored !== null) { - this.soundEnabled = stored === 'true'; - } else { - // Default to enabled - this.soundEnabled = true; + this.wordInfoEnabled = stored !== 'false'; } - setSoundEnabled(this.soundEnabled); } catch { // localStorage unavailable } }, - toggleSound(): void { + toggleWordInfo(): void { this.$nextTick(() => { - setSoundEnabled(this.soundEnabled); try { - localStorage.setItem('soundEnabled', this.soundEnabled ? 'true' : 'false'); + localStorage.setItem( + 'wordInfoEnabled', + this.wordInfoEnabled ? 'true' : 'false' + ); } catch { // localStorage unavailable } - analytics.trackSettingsChange({ setting: 'sound', value: this.soundEnabled }); + analytics.trackSettingsChange({ + setting: 'word_info', + value: this.wordInfoEnabled, + }); + if (this.wordInfoEnabled && this.game_over) { + this.loadDefinition(); + } }); }, + loadDefinition(): void { + const langCode = this.config?.language_code || 'en'; + + // Load definition (if enabled) + if (this.wordInfoEnabled) { + const container = document.getElementById('definition-card'); + if (container) { + showDefinitionLoading(container); + const wordPageUrl = `/${langCode}/word/${this.todays_idx}`; + 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, + }, + wordPageUrl + ); + }) + .catch(() => { + container.style.display = 'none'; + }); + } + } + + // Load word art image (independent of definition) + if (this.wordInfoEnabled) { + const imageContainer = document.getElementById('word-image-card'); + if (imageContainer) { + showImageLoading(imageContainer); + renderWordImage(this.todays_word, langCode, imageContainer); + } + } + }, + + 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/frontend/src/index-app.ts b/frontend/src/index-app.ts index 3aa6b918..84e20d8e 100644 --- a/frontend/src/index-app.ts +++ b/frontend/src/index-app.ts @@ -78,8 +78,7 @@ export default function createIndexApp(): App { showSettingsModal: false, clickedLanguage: '', darkMode: document.documentElement.classList.contains('dark'), - hapticsEnabled: true, - soundEnabled: true, + feedbackEnabled: true, // Flask data other_wordles, @@ -120,8 +119,7 @@ export default function createIndexApp(): App { this.cacheLanguages(); // Load preferences - this.loadHapticsPreference(); - this.loadSoundPreference(); + this.loadFeedbackPreference(); this.total_stats = this.calculateTotalStats(); // Initialize languages with recently played first @@ -224,25 +222,27 @@ export default function createIndexApp(): App { }); }, - loadHapticsPreference(): void { + loadFeedbackPreference(): void { try { - const stored = localStorage.getItem('hapticsEnabled'); + const stored = localStorage.getItem('feedbackEnabled'); if (stored !== null) { - this.hapticsEnabled = stored === 'true'; + this.feedbackEnabled = stored === 'true'; } - setHapticsEnabled(this.hapticsEnabled); + setHapticsEnabled(this.feedbackEnabled); + setSoundEnabled(this.feedbackEnabled); } catch { // localStorage unavailable } }, - toggleHaptics(): void { + toggleFeedback(): void { this.$nextTick(() => { - setHapticsEnabled(this.hapticsEnabled); + setHapticsEnabled(this.feedbackEnabled); + setSoundEnabled(this.feedbackEnabled); try { localStorage.setItem( - 'hapticsEnabled', - this.hapticsEnabled ? 'true' : 'false' + 'feedbackEnabled', + this.feedbackEnabled ? 'true' : 'false' ); } catch { // localStorage unavailable @@ -250,29 +250,6 @@ export default function createIndexApp(): App { }); }, - loadSoundPreference(): void { - try { - const stored = localStorage.getItem('soundEnabled'); - if (stored !== null) { - this.soundEnabled = stored === 'true'; - } - setSoundEnabled(this.soundEnabled); - } catch { - // localStorage unavailable - } - }, - - toggleSound(): void { - this.$nextTick(() => { - setSoundEnabled(this.soundEnabled); - try { - localStorage.setItem('soundEnabled', this.soundEnabled ? 'true' : 'false'); - } catch { - // localStorage unavailable - } - }); - }, - 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..e7360a0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,8 @@ 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", ] [dependency-groups] 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 new file mode 100644 index 00000000..6502ef6d --- /dev/null +++ b/scripts/pregenerate_images.py @@ -0,0 +1,130 @@ +#!/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 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 + +Requires OPENAI_API_KEY in .env or environment. +""" + +import argparse +import os +import sys +import time + +# Add project root to path so we can import from webapp +# 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 ( + IMAGE_LANGUAGES, + WORD_IMAGES_DIR, + fetch_definition_cached, + generate_word_image, + get_todays_idx, + get_word_for_day, + language_codes, + language_configs, +) + + +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( + "--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() + + 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) + + 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 = IMAGE_LANGUAGES + + todays_idx = get_todays_idx() + 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 + 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) + + cache_dir = os.path.join(WORD_IMAGES_DIR, lang) + cache_path = os.path.join(cache_dir, f"{word.lower()}.webp") + + if args.dry_run: + status = "cached" if os.path.exists(cache_path) else "pending" + print(f" [{status}] {lang} #{day_idx}: {word} ({lang_name})") + continue + + 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_word_image(word, definition_hint, api_key, cache_dir, cache_path) + elapsed = time.time() - start + + if result == "ok": + generated += 1 + 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}") + + if not args.dry_run: + print(f"\nDone: {generated} generated, {cached} cached, {errors} errors") + + +if __name__ == "__main__": + main() 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..6eb9b206 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" @@ -169,12 +255,88 @@ wheels = [ ] [[package]] -name = "joblib" -version = "1.5.3" +name = "jiter" +version = "0.13.0" 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" } +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/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, + { 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]] @@ -341,18 +503,22 @@ wheels = [ ] [[package]] -name = "nltk" -version = "3.9.2" +name = "openai" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "joblib" }, - { name = "regex" }, + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, { name = "tqdm" }, + { name = "typing-extensions" }, ] -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" } +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/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a", size = 1513404, upload-time = "2025-10-01T07:19:21.648Z" }, + { 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]] @@ -373,6 +539,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" @@ -391,6 +644,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 +974,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 +995,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" @@ -667,7 +1062,8 @@ dependencies = [ { name = "flask-cors" }, { name = "flask-flatpages" }, { name = "gunicorn" }, - { name = "nltk" }, + { name = "openai" }, + { name = "pillow" }, ] [package.dev-dependencies] @@ -683,7 +1079,8 @@ 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" }, ] [package.metadata.requires-dev] diff --git a/webapp/app.py b/webapp/app.py index f8c1b64c..7d5a1bb6 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -3,8 +3,10 @@ render_template, make_response, redirect, - url_for, request, + send_from_directory, + jsonify, + abort, ) import json import os @@ -12,6 +14,31 @@ import glob import random import hashlib +import re +import urllib.parse +import urllib.request as urlreq +import logging +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("=") + _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")) +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") +WORD_HISTORY_DIR = os.path.join(DATA_DIR, "word-history") # 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) @@ -72,6 +99,15 @@ def inject_vite_assets(): return {"vite_assets": get_vite_assets()} +@app.context_processor +def inject_hreflang(): + """Make hreflang data available in all templates for international SEO.""" + return { + "hreflang_langs": sorted(language_codes), + "hreflang_url_pattern": "https://wordle.global/{lang}", + } + + ############################################################################### # DATA ############################################################################### @@ -392,6 +428,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 +450,60 @@ def get_daily_word_legacy(words: list, blocklist: set, day_idx: int) -> str: keyboards = {k: load_keyboard(k) for k in language_codes} +def _compute_word_for_day(lang_code, day_idx): + """Compute the daily word from word lists (no caching).""" + 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 get_word_for_day(lang_code, day_idx): + """Get the daily word for a specific language and day index. + + Once a word is computed for a past day, it's cached to disk so future + word list changes can never alter historical daily words. + """ + # Check cache first + cache_path = os.path.join(WORD_HISTORY_DIR, lang_code, f"{day_idx}.txt") + if os.path.exists(cache_path): + try: + with open(cache_path, "r") as f: + cached = f.read().strip() + if cached: + return cached + except OSError: + pass # Fall through to recompute + + word = _compute_word_for_day(lang_code, day_idx) + + # Cache past/current days (not future) + todays_idx = get_todays_idx() + if day_idx <= todays_idx: + lang_dir = os.path.join(WORD_HISTORY_DIR, lang_code) + os.makedirs(lang_dir, exist_ok=True) + # Write atomically via temp file to prevent corrupt reads + tmp_path = cache_path + ".tmp" + try: + with open(tmp_path, "w") as f: + f.write(word) + os.replace(tmp_path, cache_path) + except OSError: + pass + + return word + + def load_languages(): """returns a dict of language codes mapped to their english name and native name""" @@ -496,15 +592,10 @@ def load_languages(): "rw", # Kinyarwanda - 5 sessions ] -# status -with open("../scripts/out/status_list.txt", "r") as f: - status_list = [line.strip() for line in f] - status_list_str = "" - for status in status_list: - status_list_str += f"" - status_list_str += ( - "more at Github" - ) +# 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[:30] + # print stats about how many languages we have print("\n***********************************************") @@ -584,40 +675,12 @@ def __init__(self, language_code, word_list, keyboard_layout=None): self.key_diacritic_hints = self._build_key_diacritic_hints() def _get_daily_word(self, day_idx): - """Get the daily word using the appropriate algorithm and word list. - - Word list priority (for days > MIGRATION_DAY_IDX): - 1. Ordered curated_schedule (if exists and not exhausted) - hand-picked words - 2. Curated daily_words list (if exists) - high quality pool - 3. Main word_list filtered by blocklist - fallback + """Get the daily word, delegating to the shared get_word_for_day(). - For backwards compatibility: - - Days <= MIGRATION_DAY_IDX: Use legacy shuffle algorithm on main word_list - - Days > MIGRATION_DAY_IDX: Use new algorithm with priority above + This ensures the game and word subpages always agree, and that + past words are frozen to disk so word list changes can't alter history. """ - if day_idx <= MIGRATION_DAY_IDX: - # Legacy algorithm for past days (preserves history) - # IMPORTANT: No blocklist for past days - we must return exactly - # what was shown historically, even if it's a "bad" word - return get_daily_word_legacy(self.word_list, set(), day_idx) # Empty blocklist! - else: - # New algorithm for future days - schedule_idx = day_idx - MIGRATION_DAY_IDX - 1 # 0-indexed from day 1682 - - # Priority 1: Ordered curated schedule (positional selection) - if self.curated_schedule and schedule_idx < len(self.curated_schedule): - return self.curated_schedule[schedule_idx] - - # Priority 2: Curated daily_words with consistent hashing - if self.daily_words: - return get_daily_word_consistent_hash( - self.daily_words, set(), day_idx, self.language_code - ) - - # Priority 3: Filtered main list with consistent hashing - return get_daily_word_consistent_hash( - self.word_list, self.blocklist, day_idx, self.language_code - ) + return get_word_for_day(self.language_code, day_idx) def _build_keyboard_layouts(self, keyboard_config): """ @@ -712,6 +775,360 @@ def _build_key_diacritic_hints(self): return hints +############################################################################### +# SERVER-SIDE DEFINITION CACHING +############################################################################### + + +_WIKT_LANG_MAP = {"nb": "no", "nn": "no", "hyw": "hy", "ckb": "ku"} + + +def _strip_html(text): + """Strip HTML tags from a string.""" + return re.sub(r"<[^>]+>", "", text).strip() + + +def _parse_wikt_definition(extract): + """Extract a definition line from a Wiktionary plaintext extract. + + Strategy: find lines that follow a definition-section header or marker, + skipping etymology, pronunciation, inflection, and metadata. Works across + many language-edition Wiktionary formats. + """ + lines = extract.split("\n") + in_definition_section = False + + # == headers that mark definition sections (e.g. "==== Noun ====") + defn_headers = re.compile( + r"^={2,4}\s*(" + r"Noun|Verb|Adjective|Adverb|Pronoun|Preposition|Conjunction|Interjection|" + r"Nom commun|Verbe|Adjectif|Adverbe|" + r"Sustantivo\b|Verbo|Adjetivo|Adverbio|" + r"Substantivo|Sostantivo|" + r"Substantiv\b|Adjektiv|" + r"Bijvoeglijk naamwoord|Zelfstandig naamwoord|Werkwoord" + r")", + re.IGNORECASE, + ) + + # Plain-text markers that start definition blocks (German, Polish, etc.) + defn_text_markers = re.compile(r"^(Bedeutungen|znaczenia)\s*:?\s*$", re.IGNORECASE) + + # Lines to always skip within a definition section + skip_line = re.compile( + r"^(" + r"=|IPA|Rhymes:|Homophones:|wymowa:|Pronúncia|Prononciation|Pronunciación|" + r"Aussprache|Worttrennung|Silbentrennung|Hörbeispiele|Reime|" + r"Étymologie|Etimología|Etimologia|Etymology|Herkunft|" + r"Synonym|Sinónim|Sinônim|Antonym|Antónim|" + r"Übersetzung|Translation|Tradução|Oberbegriffe|" + r"Beispiele|Examples|Uso:|odmiana:|przykłady:|składnia:|kolokacje:|" + r"synonimy:|antonimy:|hiperonimy:|hiponimy:|holonimy:|meronimy:|" + r"wyrazy pokrewne:|związki frazeologiczne:|etymologia:|" + r"Cognate |From |Du |Del |Do |Uit |Vom |Van |Derived |Compare |" + r"rzeczownik|przymiotnik|przysłówek|czasownik" + r")", + re.IGNORECASE, + ) + + # Markers that end a definition section (plain-text, German/Polish style) + end_markers = re.compile( + r"^(Herkunft|Synonyme|Antonyme|Oberbegriffe|Beispiele|" + r"Übersetzungen|odmiana|przykłady|składnia|kolokacje|" + r"synonimy|antonimy|wyrazy pokrewne|związki frazeologiczne)\s*:?\s*$", + re.IGNORECASE, + ) + + for line in lines: + line = line.strip() + if not line: + continue + + # Check for == definition section header + if defn_headers.match(line): + in_definition_section = True + continue + + # Check for plain-text definition marker + if defn_text_markers.match(line): + in_definition_section = True + continue + + # End markers (plain text like "Herkunft:" in German) + if end_markers.match(line): + if in_definition_section: + in_definition_section = False + continue + + # Non-definition == header resets section + if re.match(r"^={2,4}\s*\S", line): + if in_definition_section and not defn_headers.match(line): + in_definition_section = False + continue + + if not in_definition_section: + continue + + if skip_line.match(line): + continue + + # Skip inflection lines like "casa ¦ plural: casas" + if re.match(r"^\S+\s*¦", line): + continue + + # Skip hyphenation lines like "De·pot, Plural: De·pots" + if "·" in line or re.match(r".*Plural\s*:", line): + continue + + # Skip phonetic/gender headword lines + if re.match(r"^\\", line): + continue + if re.match(r"^[a-záàâãéèêíóòôõúüçñ.·ˈˌ]+\s*\\", line, re.IGNORECASE): + continue + # Skip "word (approfondimento) m sing" style (Italian) + if re.match(r"^\w+\s*\(?\s*approfondimento", line, re.IGNORECASE): + continue + # Skip "de wereld v / m" style (Dutch headword with gender) + if re.match(r"^(de|het|een|die|das|der)\s+\w+\s+[vmfn]\b", line, re.IGNORECASE): + continue + if re.match( + r"^[a-záàâãéèêíóòôõúüçñ.·ˈˌ]+,?\s*(masculino|feminino|comum|neutro|féminin|masculin|m\s|f\s|m sing|f sing)", + line, + re.IGNORECASE, + ): + continue + + # Skip headword lines like "crane (plural cranes)" or "grind (third-person..." + if re.match(r"^\w+\s*\((plural|third-person|present|past|pl\.)\b", line, re.IGNORECASE): + continue + + # German [1] definitions: "[1] Ort oder Gebäude..." + m = re.match(r"^\[(\d+)\]\s+(.+)", line) + if m and len(m.group(2)) > 5: + return m.group(2).strip()[:300] + + # Polish (1.2) definitions + m = re.match(r"^\([\d.]+\)\s+(.+)", line) + if m: + defn = m.group(1).strip() + if re.match(r"(zdrobn|zgrub|forma)\b", defn, re.IGNORECASE): + continue + if len(defn) > 5: + return defn[:300] + continue + + # Spanish/numbered: "1 Vivienda" — but skip single-word topic labels + m = re.match(r"^\d+\.?\s+(.*)", line) + if m and len(m.group(1)) > 3: + text = m.group(1).strip() + # If it's a topic label like "Vivienda", check next non-empty line + # Accept it anyway — it's better than nothing + return text[:300] + + # Skip example sentences (Dutch ▸, French/Spanish quotes, etc.) + if line.startswith("▸") or line.startswith("►"): + continue + + # Plain definition text (at least 3 chars) + if len(line) > 3: + return line[:300] + + return None + + +def _fetch_native_wiktionary(word, lang_code): + """Try native-language Wiktionary via MediaWiki API. Returns dict or None.""" + wikt_lang = _WIKT_LANG_MAP.get(lang_code, lang_code) + + # Try original case first, then title-case (German nouns are capitalized) + candidates = [word] + if word[0].islower(): + candidates.append(word[0].upper() + word[1:]) + + for try_word in candidates: + api_url = ( + f"https://{wikt_lang}.wiktionary.org/w/api.php?" + f"action=query&titles={urllib.parse.quote(try_word)}" + f"&prop=extracts&explaintext=1&format=json" + ) + try: + req = urlreq.Request(api_url, headers={"User-Agent": "WordleGlobal/1.0"}) + with urlreq.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read()) + pages = data.get("query", {}).get("pages", {}) + for pid, page in pages.items(): + if pid == "-1": + continue + extract = page.get("extract", "").strip() + if not extract: + continue + defn = _parse_wikt_definition(extract) + if defn: + return { + "definition": defn, + "source": "native", + "url": f"https://{wikt_lang}.wiktionary.org/wiki/{urllib.parse.quote(try_word)}", + } + except Exception: + pass + return None + + +def fetch_definition_cached(word, lang_code): + """Fetch definition from Wiktionary with disk caching. + + Tries native-language Wiktionary first, falls back to English Wiktionary. + Returns dict with keys: definition, part_of_speech, source, url. + Returns None if no definition found. + """ + cache_dir = os.path.join(WORD_DEFS_DIR, 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: + loaded = json.load(f) + return loaded if loaded else None + except Exception: + pass + + # Try native Wiktionary first (definitions in the word's own language) + result = _fetch_native_wiktionary(word, lang_code) + + # Fall back to English Wiktionary REST API + if not result: + 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"]: + for entry in data.get(try_lang, []): + for defn in entry.get("definitions", []): + raw_def = defn.get("definition", "") + clean_def = _strip_html(raw_def) + if clean_def: + result = { + "definition": clean_def[:300], + "part_of_speech": entry.get("partOfSpeech"), + "source": "english", + "url": f"https://en.wiktionary.org/wiki/{urllib.parse.quote(word.lower())}", + } + break + if result: + break + if result: + 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 + + +def _fetch_english_definition(word, lang_code): + """Fetch an English-language definition for a word (any language). + + Used for DALL-E prompts where English comprehension is best. + Hits the English Wiktionary REST API and returns the definition string, + or None if not found. + """ + 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 section first (e.g. "pt" for Portuguese words), + # then fall back to English section + for try_lang in [lang_code, "en"]: + for entry in data.get(try_lang, []): + for defn in entry.get("definitions", []): + raw_def = defn.get("definition", "") + clean_def = _strip_html(raw_def) + if clean_def: + return clean_def[:200] + except Exception: + pass + return None + + +############################################################################### +# ANONYMOUS STATS COLLECTION +############################################################################### + +# In-memory IP dedup (resets on restart, never persisted) +# Bounded to 50k entries to prevent memory exhaustion under attack. +_STATS_MAX_IPS = 50_000 +_stats_seen_ips = {} +_stats_seen_day = None # Track current day to clear stale entries + + +def _load_word_stats(lang_code, day_idx): + """Load stats for a specific word/day.""" + 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: + return json.load(f) + except Exception: + pass + return None + + +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") + os.makedirs(stats_dir, exist_ok=True) + + lock_path = stats_path + ".lock" + with open(lock_path, "w") as lock_f: + try: + fcntl.flock(lock_f, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return # Another process holds the lock; skip this update + + # 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) + + ############################################################################### # ROUTES ############################################################################### @@ -730,6 +1147,18 @@ def before_request(): return redirect(url, code=code) +@app.errorhandler(404) +def page_not_found(e): + return ( + render_template( + "404.html", + languages=languages, + language_codes=language_codes, + ), + 404, + ) + + @app.route("/") def index(): return render_template( @@ -743,16 +1172,225 @@ def index(): ) +_stats_cache = {"data": None, "ts": 0} +_STATS_CACHE_TTL = 300 # 5 minutes + + +def _build_stats_data(): + """Aggregate site-wide stats (cached in memory for 5 min).""" + import time + + now = time.time() + if _stats_cache["data"] and now - _stats_cache["ts"] < _STATS_CACHE_TTL: + return _stats_cache["data"] + + todays_idx = get_todays_idx() + lang_stats = [] + total_words_all = 0 + + earliest_stats_idx = None + + for lc in language_codes: + n_words = len(language_codes_5words.get(lc, [])) + n_supplement = len(language_codes_5words_supplements.get(lc, [])) + total_words_all += n_words + n_supplement + + # Aggregate community stats from cached files (if any) + lang_total_plays = 0 + lang_total_wins = 0 + lang_dir = os.path.join(WORD_STATS_DIR, lc) + if os.path.isdir(lang_dir): + for fname in os.listdir(lang_dir): + if not fname.endswith(".json"): + continue + try: + day = int(fname[:-5]) # strip ".json" + except ValueError: + continue # skip non-numeric filenames + try: + with open(os.path.join(lang_dir, fname), "r") as f: + s = json.load(f) + lang_total_plays += s.get("total", 0) + lang_total_wins += s.get("wins", 0) + if earliest_stats_idx is None or day < earliest_stats_idx: + earliest_stats_idx = day + except (json.JSONDecodeError, OSError) as e: + logging.warning("Failed to load stats %s/%s: %s", lc, fname, e) + + lang_stats.append( + { + "code": lc, + "name": languages[lc]["language_name"], + "name_native": languages[lc].get("language_name_native", ""), + "n_words": n_words, + "n_supplement": n_supplement, + "total_plays": lang_total_plays, + "total_wins": lang_total_wins, + "win_rate": ( + round(lang_total_wins / lang_total_plays * 100) + if lang_total_plays > 0 + else None + ), + } + ) + lang_stats.sort(key=lambda x: x["n_words"], reverse=True) + + global_plays = sum(ls["total_plays"] for ls in lang_stats) + global_wins = sum(ls["total_wins"] for ls in lang_stats) + + # Convert earliest stats day_idx to a date string + stats_since_date = None + if earliest_stats_idx is not None: + stats_since_date = idx_to_date(earliest_stats_idx).strftime("%B %d, %Y") + + data = { + "lang_stats": lang_stats, + "total_languages": len(language_codes), + "total_words": total_words_all, + "total_puzzles": todays_idx * len(language_codes), + "todays_idx": todays_idx, + "global_plays": global_plays, + "global_wins": global_wins, + "global_win_rate": (round(global_wins / global_plays * 100) if global_plays > 0 else None), + "stats_since_date": stats_since_date, + } + _stats_cache["data"] = data + _stats_cache["ts"] = now + return data + + @app.route("/stats") def stats(): - return status_list_str + """Site-wide statistics page — language stats and community play data.""" + data = _build_stats_data() + return render_template("stats.html", **data) + + +# robots.txt and llms.txt +@app.route("/robots.txt") +def robots_txt(): + content = """User-agent: * +Allow: / + +Sitemap: https://wordle.global/sitemap.xml +""" + response = make_response(content) + response.headers["Content-Type"] = "text/plain" + return response + + +@app.route("/llms.txt") +def llms_txt(): + content = f"""# Wordle Global + +> Free, open-source Wordle in {len(language_codes)}+ languages. A new 5-letter word to guess every day. + +Play at https://wordle.global + +## Languages + +{chr(10).join(f"- [{languages[lc]['language_name']}](https://wordle.global/{lc})" for lc in sorted(language_codes))} + +## About + +- Each day has a new 5-letter word to guess in 6 tries +- Green = correct letter in correct position +- Yellow = correct letter in wrong position +- Gray = letter not in the word +- Free, no account required, works offline (PWA) +- Open source: https://github.com/Hugo0/wordle +""" + response = make_response(content) + response.headers["Content-Type"] = "text/plain; charset=utf-8" + return response # sitemap +SITEMAP_MAX_URLS = 50000 +SITEMAP_BASE_URL = "https://wordle.global" + + @app.route("/sitemap.xml") -def site_map(): +def sitemap_index(): + """Sitemap index pointing to child sitemaps.""" + todays_idx = get_todays_idx() + n_langs = len(language_codes) + total_word_pages = todays_idx * n_langs + n_word_sitemaps = (total_word_pages + SITEMAP_MAX_URLS - 1) // SITEMAP_MAX_URLS + today_str = idx_to_date(todays_idx).strftime("%Y-%m-%d") + response = make_response( - render_template("sitemap.xml", languages=languages, base_url="https://wordle.global") + render_template( + "sitemap_index.xml", + base_url=SITEMAP_BASE_URL, + n_word_sitemaps=n_word_sitemaps, + lastmod=today_str, + ) + ) + response.headers["Content-Type"] = "application/xml" + return response + + +@app.route("/sitemap-main.xml") +def sitemap_main(): + """Sitemap for homepage and language pages.""" + response = make_response( + render_template( + "sitemap_main.xml", + languages=languages, + base_url=SITEMAP_BASE_URL, + ) + ) + response.headers["Content-Type"] = "application/xml" + return response + + +@app.route("/sitemap-words-.xml") +def sitemap_words(page): + """Sitemap for word subpages, paginated at 50K URLs per file.""" + todays_idx = get_todays_idx() + n_langs = len(language_codes) + sorted_langs = sorted(language_codes) + + # Total word pages: todays_idx * n_langs (day 1..todays_idx × all langs) + # Ordered: newest days first (higher priority), then by language + # Page 1 = first 50K entries, page 2 = next 50K, etc. + offset = (page - 1) * SITEMAP_MAX_URLS + total = todays_idx * n_langs + if offset >= total: + return "Not found", 404 + + word_pages = [] + remaining = min(SITEMAP_MAX_URLS, total - offset) + idx = offset + while remaining > 0: + # Convert flat index to (day_idx, lang) — newest days first + day_offset = idx // n_langs + lang_idx = idx % n_langs + d_idx = todays_idx - day_offset + if d_idx < 1: + break + d_date = idx_to_date(d_idx).strftime("%Y-%m-%d") + # Priority: recent words get higher priority (1.0 for today, 0.3 for oldest) + age_ratio = day_offset / max(todays_idx, 1) + priority = round(max(0.3, 1.0 - age_ratio * 0.7), 1) + word_pages.append( + { + "lang": sorted_langs[lang_idx], + "day_idx": d_idx, + "date": d_date, + "priority": priority, + } + ) + idx += 1 + remaining -= 1 + + response = make_response( + render_template( + "sitemap_words.xml", + base_url=SITEMAP_BASE_URL, + word_pages=word_pages, + ) ) response.headers["Content-Type"] = "application/xml" return response @@ -762,7 +1400,7 @@ def site_map(): @app.route("/") def language(lang_code): if lang_code not in language_codes: - return "Language not found" + abort(404) word_list = language_codes_5words[lang_code] cookie_key = f"keyboard_layout_{lang_code}" requested_layout = request.args.get("layout") or request.cookies.get(cookie_key) @@ -779,5 +1417,266 @@ def language(lang_code): return response +############################################################################### +# AI WORD ART IMAGE GENERATION +############################################################################### + + +def build_image_prompt(word, definition_hint=""): + """Build the DALL-E prompt for a word image.""" + return ( + f"A painterly illustration representing the concept of " + f"{word}{definition_hint}. " + f"No text, no letters, no words, no UI elements." + ) + + +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 + + import openai + + try: + client = openai.OpenAI(api_key=api_key) + prompt = build_image_prompt(word, definition_hint) + + 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 or not image_url.startswith("https://"): + return "no_url" + + os.makedirs(cache_dir, exist_ok=True) + + from PIL import Image + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp_path = tmp.name + req = urlreq.Request(image_url) + with urlreq.urlopen(req, timeout=30) as resp: + tmp.write(resp.read()) + + try: + with Image.open(tmp_path) as img: + img.save(cache_path, "WebP", quality=80) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + return "ok" + except (openai.OpenAIError, urlreq.URLError, IOError, OSError) as e: + logging.error(f"Image generation failed for {word}: {e}") + return "error" + + +@app.route("//api/definition/") +def word_definition_api(lang_code, word): + """Return a word definition as JSON. + + Single source of truth for definitions — used by both the game frontend + and the word subpage (server-side rendered). Results are cached to disk. + """ + if lang_code not in languages: + return jsonify({"error": "unknown language"}), 404 + + # Only serve definitions for valid words (daily or supplement) + word_lower = word.lower() + all_words = set(language_codes_5words[lang_code]) | set( + language_codes_5words_supplements.get(lang_code, []) + ) + if word_lower not in all_words: + return jsonify({"error": "unknown word"}), 404 + + result = fetch_definition_cached(word_lower, lang_code) + if result: + return jsonify(result) + return jsonify({"error": "no definition found"}), 404 + + +@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: + abort(404) + + if lang_code not in language_codes: + abort(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: + day_idx = todays_idx + 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") + + # Only generate images for words from Feb 21, 2026 onwards (day 1708) + # Older words get served from cache only — no on-demand generation + IMAGE_MIN_DAY_IDX = 1708 + if day_idx < IMAGE_MIN_DAY_IDX: + return "Image not available for historical words", 404 + + # 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 DALL-E prompt (reuses disk cache) + definition_hint = "" + defn = fetch_definition_cached(word, lang_code) + if defn and defn.get("definition"): + definition_hint = f", which means {defn['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/") +def word_page(lang_code, day_idx): + """Serve a shareable page for a specific daily word.""" + if lang_code not in language_codes: + abort(404) + + # 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: + abort(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) + + # Read cached definition if available (fast disk read, no HTTP) + definition = None + cache_path = os.path.join(WORD_DEFS_DIR, lang_code, f"{word.lower()}.json") + if os.path.exists(cache_path): + try: + with open(cache_path, "r") as f: + loaded = json.load(f) + definition = loaded if loaded else None + except Exception: + pass + + # Map language code to Wiktionary subdomain + wikt_lang_map = {"nb": "no", "nn": "no", "hyw": "hy", "ckb": "ku"} + wikt_lang = wikt_lang_map.get(lang_code, lang_code) + + # Load stats if available (fast — just a local file read) + 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, + word_stats=word_stats, + todays_idx=todays_idx, + config=config, + wikt_lang=wikt_lang, + hreflang_url_pattern=f"https://wordle.global/{{lang}}/word/{day_idx}", + ) + + +@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) + 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 + if len(_stats_seen_ips) < _STATS_MAX_IPS: + _stats_seen_ips[dedup_key] = True + + _update_word_stats(lang_code, day_idx, won, attempts) + return "", 200 + except Exception: + logging.exception("Stats submission failed for %s", lang_code) + return "", 500 + + if __name__ == "__main__": app.run() diff --git a/webapp/data/default_language_config.json b/webapp/data/default_language_config.json index bac478cb..79353835 100644 --- a/webapp/data/default_language_config.json +++ b/webapp/data/default_language_config.json @@ -83,6 +83,12 @@ "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", + "word_art": "Word Art", + "word_art_desc": "AI illustration of the daily word" } } diff --git a/webapp/data/languages/bg/language_config.json b/webapp/data/languages/bg/language_config.json index 0a9c66a8..8998dc9b 100644 --- a/webapp/data/languages/bg/language_config.json +++ b/webapp/data/languages/bg/language_config.json @@ -12,7 +12,7 @@ "subheader": "български език", "next_word": "Следваща дума ", "no_attempts": "Още не сте опитвали думи! ", - "share": "Дял ", + "share": "Сподели", "notification-copied": "Копиран в клипборда ", "notification-partial-word": "Моля, въведете пълна дума " }, diff --git a/webapp/data/languages/br/language_config.json b/webapp/data/languages/br/language_config.json index 0f8d073b..122475f8 100644 --- a/webapp/data/languages/br/language_config.json +++ b/webapp/data/languages/br/language_config.json @@ -12,7 +12,7 @@ "subheader": "brezhoneg", "next_word": "Next word ", "no_attempts": "You haven't tried any words yet! ", - "share": "Share ", + "share": "Rannañ", "notification-copied": "Copied to clipboard ", "notification-partial-word": "Please enter a full word " }, diff --git a/webapp/data/languages/cs/language_config.json b/webapp/data/languages/cs/language_config.json index 4be93915..3c29de3d 100644 --- a/webapp/data/languages/cs/language_config.json +++ b/webapp/data/languages/cs/language_config.json @@ -12,7 +12,7 @@ "subheader": "čeština", "next_word": "Další slovo ", "no_attempts": "Ještě jste nezkoušeli žádná slova! ", - "share": "Podíl ", + "share": "Sdílet", "notification-copied": "Zkopírován do schránky ", "notification-partial-word": "Zadejte prosím úplné slovo " }, diff --git a/webapp/data/languages/de/de_blocklist.txt b/webapp/data/languages/de/de_blocklist.txt index d97b172b..6b0b4456 100644 --- a/webapp/data/languages/de/de_blocklist.txt +++ b/webapp/data/languages/de/de_blocklist.txt @@ -39,3 +39,5 @@ tunte # Political organization acronyms nsdap +mbyte +polit diff --git a/webapp/data/languages/de/de_daily_words.txt b/webapp/data/languages/de/de_daily_words.txt index 08030434..2fe78362 100644 --- a/webapp/data/languages/de/de_daily_words.txt +++ b/webapp/data/languages/de/de_daily_words.txt @@ -1098,7 +1098,6 @@ masse mathe matte mauer -mbyte media meier meile @@ -1315,7 +1314,6 @@ poker polen polig polis -polit polle polyp poren diff --git a/webapp/data/languages/el/language_config.json b/webapp/data/languages/el/language_config.json index 889742c0..2a14ac6a 100644 --- a/webapp/data/languages/el/language_config.json +++ b/webapp/data/languages/el/language_config.json @@ -24,7 +24,7 @@ "subheader": "Ελληνικά", "next_word": "Επόμενη λέξη ", "no_attempts": "Δεν έχετε δοκιμάσει ακόμα λέξεις! ", - "share": "Μερίδιο ", + "share": "Κοινοποίηση", "notification-copied": "Αντιγραφή στο πρόχειρο ", "notification-partial-word": "Παρακαλώ εισάγετε μια πλήρη λέξη " }, diff --git a/webapp/data/languages/en/en_5words_supplement.txt b/webapp/data/languages/en/en_5words_supplement.txt index fc8c9a87..6a2bbfab 100644 --- a/webapp/data/languages/en/en_5words_supplement.txt +++ b/webapp/data/languages/en/en_5words_supplement.txt @@ -10636,3 +10636,4 @@ zygal zygon zymes zymic +slave diff --git a/webapp/data/languages/eo/eo_blocklist.txt b/webapp/data/languages/eo/eo_blocklist.txt new file mode 100644 index 00000000..23bb7fe0 --- /dev/null +++ b/webapp/data/languages/eo/eo_blocklist.txt @@ -0,0 +1,2 @@ +# Esperanto blocklist +arnik diff --git a/webapp/data/languages/eo/eo_daily_words.txt b/webapp/data/languages/eo/eo_daily_words.txt index f0ca4eb7..03ab855f 100644 --- a/webapp/data/languages/eo/eo_daily_words.txt +++ b/webapp/data/languages/eo/eo_daily_words.txt @@ -145,7 +145,6 @@ arkiv arlin armel armen -arnik arsen artik arĥiv diff --git a/webapp/data/languages/es/language_config.json b/webapp/data/languages/es/language_config.json index 5cd1617e..c3b08cce 100644 --- a/webapp/data/languages/es/language_config.json +++ b/webapp/data/languages/es/language_config.json @@ -19,7 +19,7 @@ "subheader": "Español", "next_word": "Palabra siguiente ", "no_attempts": "¡No has probado ninguna palabra todavía! ", - "share": "Cuota ", + "share": "Compartir", "notification-copied": "Copiado al portapapeles ", "notification-partial-word": "Por favor ingrese una palabra completa " }, diff --git a/webapp/data/languages/eu/language_config.json b/webapp/data/languages/eu/language_config.json index 9079e31f..7ae6527d 100644 --- a/webapp/data/languages/eu/language_config.json +++ b/webapp/data/languages/eu/language_config.json @@ -12,7 +12,7 @@ "subheader": "euskara", "next_word": "Hurrengo hitza ", "no_attempts": "Oraindik ez duzu hitzik probatu! ", - "share": "Zati ", + "share": "Partekatu ", "notification-copied": "Arbelean kopiatuta ", "notification-partial-word": "Mesedez, idatzi hitz osoa " }, diff --git a/webapp/data/languages/fo/language_config.json b/webapp/data/languages/fo/language_config.json index d253ebe0..56184290 100644 --- a/webapp/data/languages/fo/language_config.json +++ b/webapp/data/languages/fo/language_config.json @@ -12,7 +12,7 @@ "subheader": "føroyskt", "next_word": "Next word ", "no_attempts": "You haven't tried any words yet! ", - "share": "Share ", + "share": "Deil", "notification-copied": "Copied to clipboard ", "notification-partial-word": "Please enter a full word " }, diff --git a/webapp/data/languages/fur/language_config.json b/webapp/data/languages/fur/language_config.json index 070c070c..28c47593 100644 --- a/webapp/data/languages/fur/language_config.json +++ b/webapp/data/languages/fur/language_config.json @@ -12,7 +12,7 @@ "subheader": "bèle fòòr", "next_word": "Next word ", "no_attempts": "You haven't tried any words yet! ", - "share": "Share ", + "share": "Condivît", "notification-copied": "Copied to clipboard ", "notification-partial-word": "Please enter a full word " }, diff --git a/webapp/data/languages/ga/language_config.json b/webapp/data/languages/ga/language_config.json index aa8099cb..92010766 100644 --- a/webapp/data/languages/ga/language_config.json +++ b/webapp/data/languages/ga/language_config.json @@ -12,7 +12,7 @@ "subheader": "Gaeilge", "next_word": "An chéad fhocal eile ", "no_attempts": "Níor thriail tú aon fhocail go fóill! ", - "share": "Cuir i láthair ", + "share": "Roinn", "notification-copied": "Cóipeáilte go dtí an ghearrthaisce ", "notification-partial-word": "Cuir isteach focal iomlán le do thoil " }, diff --git a/webapp/data/languages/he/language_config.json b/webapp/data/languages/he/language_config.json index 9a300d87..ae12eee4 100644 --- a/webapp/data/languages/he/language_config.json +++ b/webapp/data/languages/he/language_config.json @@ -19,7 +19,7 @@ "subheader": "עברית", "next_word": "המילה הבאה ", "no_attempts": "עדיין לא ניסית מילים כלשהן! ", - "share": "לַחֲלוֹק ", + "share": "שתף", "notification-copied": "הועתק ללוח ", "notification-partial-word": "הזן מילה מלאה " }, diff --git a/webapp/data/languages/hr/hr_blocklist.txt b/webapp/data/languages/hr/hr_blocklist.txt index 6814b6e9..36f0b2a2 100644 --- a/webapp/data/languages/hr/hr_blocklist.txt +++ b/webapp/data/languages/hr/hr_blocklist.txt @@ -1,136 +1,11 @@ -# Croatian blocklist - Proper nouns and foreign words -# These words will be excluded from the daily word rotation - -# Proper nouns (names of people) -mateo -filip -osman -amaro -brčić -libor - -# Proper nouns (places) -jemen -litva -jalta - -# Proper nouns (should be capitalized) -gauss - -# Words with digraphs that are not 5 Croatian letters -# In Croatian, lj, nj, and dž are single letters. -# These 5-character words are actually only 4 Croatian letters. - -# Words with 'lj' digraph (56 words) -belje -bilje -bljak -bolji -brlja -celje -dalji -dolje -dulji -golja -hmelj -hulja -kljun -ključ -kolja -kolje -kolji -kralj -kulja -ljaga -ljama -ljeti -ljeto -ljuba -ljudi -ljulj -ljući -malja -milja -milje -mljet -mrlja -pljas -pljus -polje -ralja -rulja -sljez -solju -udalj -ugalj -uljan -uljar -uljen -uljez -uljni -volja -zelje -zolja -šalju -školj -šljam -šljem -šolja -želja -žilje - -# Words with 'nj' digraph (44 words) -banja -brnja -bunja -crnji -cunja -danji -dinja -donji -dunja -gnjev -gnjil -gnjus -gunja -janja -janje -kranj -krnji -lonja -manji -munja -njega -njemu -njima -njime -njiva -njoka -njome -oganj -prnja -psunj -sanja -sanje -sinji -slunj -tanji -tenja -trnje -tunja -zrnje -šinja -žanje -žanji -žanju -žminj - -# Words with 'dž' digraph (8 words) -bridž -džabe -džudo -fidži -hodža -imidž -odžak -radža +# Croatian blocklist - reported non-words and proper names +vruci +cerek +kadri +smoju +vazda +freud +david +romeo +vinko +jakob diff --git a/webapp/data/languages/hr/hr_daily_words.txt b/webapp/data/languages/hr/hr_daily_words.txt index d9b46990..ebf751ab 100644 --- a/webapp/data/languages/hr/hr_daily_words.txt +++ b/webapp/data/languages/hr/hr_daily_words.txt @@ -164,7 +164,6 @@ bušen cekum celer cener -cerek cerje cesta cezar @@ -332,7 +331,6 @@ frape fraza frend freon -freud front frula funta @@ -501,7 +499,6 @@ jagma jahač jahta jakna -jakob jakov japan jarac @@ -542,7 +539,6 @@ kabel kadar kadet kadli -kadri kafić kagan kairo @@ -1457,7 +1453,6 @@ sluga smeće smeđi smjer -smoju smola smrad smrću @@ -1749,7 +1744,6 @@ varka varoš vatra vazal -vazda vađen vašar važni @@ -1770,7 +1764,6 @@ video vijak vijek vikar -vinko virus viski visok @@ -1814,7 +1807,6 @@ vrsta vrtan vrtić vrtni -vruci vrzin vrčić vršak diff --git a/webapp/data/languages/hr/language_config.json b/webapp/data/languages/hr/language_config.json index 00b9d57a..fd8d4d9c 100644 --- a/webapp/data/languages/hr/language_config.json +++ b/webapp/data/languages/hr/language_config.json @@ -12,7 +12,7 @@ "subheader": "hrvatski jezik", "next_word": "Slijedeća riječ ", "no_attempts": "Još niste isprobali riječi! ", - "share": "Udio ", + "share": "Podijeli", "notification-copied": "Kopirati u međuspremnik ", "notification-partial-word": "Unesite cijelu riječ " }, diff --git a/webapp/data/languages/hu/hu_blocklist.txt b/webapp/data/languages/hu/hu_blocklist.txt new file mode 100644 index 00000000..390b4592 --- /dev/null +++ b/webapp/data/languages/hu/hu_blocklist.txt @@ -0,0 +1,2 @@ +# Hungarian blocklist +izzam diff --git a/webapp/data/languages/hu/language_config.json b/webapp/data/languages/hu/language_config.json index 8d4788fc..6969f102 100644 --- a/webapp/data/languages/hu/language_config.json +++ b/webapp/data/languages/hu/language_config.json @@ -12,7 +12,7 @@ "subheader": "magyar", "next_word": "Következő szó ", "no_attempts": "Még nem próbáltad meg a szót! ", - "share": "Részvény ", + "share": "Megosztás", "notification-copied": "A vágólapra másolt ", "notification-partial-word": "Kérjük, adjon meg egy teljes szót " }, diff --git a/webapp/data/languages/hy/language_config.json b/webapp/data/languages/hy/language_config.json index bc913a49..8e7b2b19 100644 --- a/webapp/data/languages/hy/language_config.json +++ b/webapp/data/languages/hy/language_config.json @@ -12,7 +12,7 @@ "subheader": "Հայերեն", "next_word": "Հաջորդ բառը ", "no_attempts": "Դուք դեռ որեւէ բառ չեք փորձել: ", - "share": "Բաժնետոմս ", + "share": "Կիսվել", "notification-copied": "Պատճենահանվել է clipboard- ին ", "notification-partial-word": "Խնդրում ենք մուտքագրել ամբողջական բառ " }, diff --git a/webapp/data/languages/hyw/language_config.json b/webapp/data/languages/hyw/language_config.json index 2b37ea6f..1a85df93 100644 --- a/webapp/data/languages/hyw/language_config.json +++ b/webapp/data/languages/hyw/language_config.json @@ -12,7 +12,7 @@ "subheader": "արեւմտահայերէն", "next_word": "Next word ", "no_attempts": "You haven't tried any words yet! ", - "share": "Share ", + "share": "Կիսուիլ", "notification-copied": "Copied to clipboard ", "notification-partial-word": "Please enter a full word " }, diff --git a/webapp/data/languages/ia/language_config.json b/webapp/data/languages/ia/language_config.json index 40bc1964..11eaaaf4 100644 --- a/webapp/data/languages/ia/language_config.json +++ b/webapp/data/languages/ia/language_config.json @@ -12,7 +12,7 @@ "subheader": "Interlingua", "next_word": "Next word ", "no_attempts": "You haven't tried any words yet! ", - "share": "Share ", + "share": "Compartir", "notification-copied": "Copied to clipboard ", "notification-partial-word": "Please enter a full word " }, diff --git a/webapp/data/languages/ie/language_config.json b/webapp/data/languages/ie/language_config.json index 5beb49e5..efcdfe73 100644 --- a/webapp/data/languages/ie/language_config.json +++ b/webapp/data/languages/ie/language_config.json @@ -12,7 +12,7 @@ "subheader": "Occidental", "next_word": "Next word ", "no_attempts": "You haven't tried any words yet! ", - "share": "Share ", + "share": "Partir", "notification-copied": "Copied to clipboard ", "notification-partial-word": "Please enter a full word " }, diff --git a/webapp/data/languages/is/language_config.json b/webapp/data/languages/is/language_config.json index feb3a958..9c4ae788 100644 --- a/webapp/data/languages/is/language_config.json +++ b/webapp/data/languages/is/language_config.json @@ -12,7 +12,7 @@ "subheader": "Íslenska", "next_word": "Næsta orð ", "no_attempts": "Þú hefur ekki reynt nein orð ennþá! ", - "share": "1. ", + "share": "Deila", "notification-copied": "Afritað á klemmuspjald ", "notification-partial-word": "Vinsamlegast sláðu inn fullt orð " }, diff --git a/webapp/data/languages/ko/language_config.json b/webapp/data/languages/ko/language_config.json index f7d5d6f6..f4cc270f 100644 --- a/webapp/data/languages/ko/language_config.json +++ b/webapp/data/languages/ko/language_config.json @@ -12,7 +12,7 @@ "subheader": "한국어", "next_word": "다음 단어 ", "no_attempts": "아직 어떤 단어를 시도하지 않았습니다! ", - "share": "공유하다 ", + "share": "공유", "notification-copied": "클립 보드에 복사 ", "notification-partial-word": "전체 단어를 입력하십시오 " }, diff --git a/webapp/data/languages/la/language_config.json b/webapp/data/languages/la/language_config.json index fa8254cc..aaa67be1 100644 --- a/webapp/data/languages/la/language_config.json +++ b/webapp/data/languages/la/language_config.json @@ -12,7 +12,7 @@ "subheader": "latine", "next_word": "Next verbum ", "no_attempts": "Vos autem non temptavit aliqua verba adhuc! ", - "share": "Paro ", + "share": "Communica", "notification-copied": "Copied ad clipboard ", "notification-partial-word": "Please enter plenus verbum " }, diff --git a/webapp/data/languages/ltg/language_config.json b/webapp/data/languages/ltg/language_config.json index 0dc3f7cd..a37d2fd9 100644 --- a/webapp/data/languages/ltg/language_config.json +++ b/webapp/data/languages/ltg/language_config.json @@ -12,7 +12,7 @@ "subheader": "latgaliski", "next_word": "Next word ", "no_attempts": "You haven't tried any words yet! ", - "share": "Share ", + "share": "Daleit", "notification-copied": "Copied to clipboard ", "notification-partial-word": "Please enter a full word " }, diff --git a/webapp/data/languages/mi/language_config.json b/webapp/data/languages/mi/language_config.json index 0e9a5c2d..20636c55 100644 --- a/webapp/data/languages/mi/language_config.json +++ b/webapp/data/languages/mi/language_config.json @@ -16,7 +16,7 @@ "subheader": "", "next_word": "Next word", "no_attempts": "You haven't tried any words yet!", - "share": "Share", + "share": "Tohatoha", "notification-copied": "Copied to clipboard", "notification-word-not-valid": "Word is not valid", "notification-partial-word": "Please enter a full word" diff --git a/webapp/data/languages/nds/language_config.json b/webapp/data/languages/nds/language_config.json index cd30d3dd..1b21aa10 100644 --- a/webapp/data/languages/nds/language_config.json +++ b/webapp/data/languages/nds/language_config.json @@ -12,7 +12,7 @@ "subheader": "Plattdüütsch", "next_word": "Next word ", "no_attempts": "You haven't tried any words yet! ", - "share": "Share ", + "share": "Delen", "notification-copied": "Copied to clipboard ", "notification-partial-word": "Please enter a full word " }, diff --git a/webapp/data/languages/ne/language_config.json b/webapp/data/languages/ne/language_config.json index 44625943..b5e2b408 100644 --- a/webapp/data/languages/ne/language_config.json +++ b/webapp/data/languages/ne/language_config.json @@ -12,7 +12,7 @@ "subheader": "नेपाली", "next_word": "अर्को शब्द ", "no_attempts": "तपाईंले अहिलेसम्म कुनै शब्दहरू प्रयास गर्नुभएन! ", - "share": "भाग ", + "share": "साझा गर्नुहोस्", "notification-copied": "क्लिपबोर्डमा प्रतिलिपि गरियो ", "notification-partial-word": "कृपया पूर्ण शब्द प्रविष्ट गर्नुहोस् " }, diff --git a/webapp/data/languages/nn/language_config.json b/webapp/data/languages/nn/language_config.json index c059ab30..6b9e9b40 100644 --- a/webapp/data/languages/nn/language_config.json +++ b/webapp/data/languages/nn/language_config.json @@ -16,7 +16,7 @@ "subheader": "Norsk Nynorsk", "next_word": "Next word ", "no_attempts": "You haven't tried any words yet! ", - "share": "Share ", + "share": "Del", "notification-copied": "Copied to clipboard ", "notification-partial-word": "Please enter a full word " }, diff --git a/webapp/data/languages/oc/language_config.json b/webapp/data/languages/oc/language_config.json index 8832ae2f..b55616e3 100644 --- a/webapp/data/languages/oc/language_config.json +++ b/webapp/data/languages/oc/language_config.json @@ -12,7 +12,7 @@ "subheader": "occitan", "next_word": "Next word ", "no_attempts": "You haven't tried any words yet! ", - "share": "Share ", + "share": "Partejar", "notification-copied": "Copied to clipboard ", "notification-partial-word": "Please enter a full word " }, diff --git a/webapp/data/languages/pt/language_config.json b/webapp/data/languages/pt/language_config.json index f2fe1628..51d74dcb 100644 --- a/webapp/data/languages/pt/language_config.json +++ b/webapp/data/languages/pt/language_config.json @@ -20,7 +20,7 @@ "subheader": "Português", "next_word": "Próxima palavra ", "no_attempts": "Você ainda não experimentou nenhuma palavra! ", - "share": "Participação ", + "share": "Partilhar", "notification-copied": "Copiado para a área de transferência ", "notification-partial-word": "Por favor, insira uma palavra completa " }, diff --git a/webapp/data/languages/ru/language_config.json b/webapp/data/languages/ru/language_config.json index d8f73bed..3389a47c 100644 --- a/webapp/data/languages/ru/language_config.json +++ b/webapp/data/languages/ru/language_config.json @@ -12,7 +12,7 @@ "subheader": "русский", "next_word": "Следующее слово ", "no_attempts": "Вы еще не пробовали ни слова! ", - "share": "Делиться ", + "share": "Поделиться", "notification-copied": "Скопирован в буфер обмена ", "notification-partial-word": "Пожалуйста, введите полное слово " }, diff --git a/webapp/data/languages/sk/language_config.json b/webapp/data/languages/sk/language_config.json index 9b0c2fa5..e11047db 100644 --- a/webapp/data/languages/sk/language_config.json +++ b/webapp/data/languages/sk/language_config.json @@ -12,7 +12,7 @@ "subheader": "slovenčina", "next_word": "Ďalšie slovo ", "no_attempts": "Ešte ste neskúšali žiadne slová! ", - "share": "zdieľam ", + "share": "Zdieľať", "notification-copied": "Skopírované do schránky ", "notification-partial-word": "Zadajte úplné slovo " }, diff --git a/webapp/data/languages/sl/language_config.json b/webapp/data/languages/sl/language_config.json index cfcc5ee9..1a5e414a 100644 --- a/webapp/data/languages/sl/language_config.json +++ b/webapp/data/languages/sl/language_config.json @@ -12,7 +12,7 @@ "subheader": "Slovenski jezik", "next_word": "Naslednja beseda ", "no_attempts": "Še niste poskusili nobenih besed! ", - "share": "Deliti ", + "share": "Deli", "notification-copied": "Kopirano v odložišče ", "notification-partial-word": "Vnesite polno besedo " }, diff --git a/webapp/data/languages/uk/language_config.json b/webapp/data/languages/uk/language_config.json index e35b3863..1923352a 100644 --- a/webapp/data/languages/uk/language_config.json +++ b/webapp/data/languages/uk/language_config.json @@ -12,7 +12,7 @@ "subheader": "Українська", "next_word": "Наступне слово ", "no_attempts": "Ви ще не пробували жодних слів! ", - "share": "Частка ", + "share": "Поділитися", "notification-copied": "Скопійовано в буфер обміну ", "notification-partial-word": "Введіть повне слово " }, diff --git a/webapp/data/languages/vi/language_config.json b/webapp/data/languages/vi/language_config.json index 3634be99..edd0cff6 100644 --- a/webapp/data/languages/vi/language_config.json +++ b/webapp/data/languages/vi/language_config.json @@ -26,7 +26,7 @@ "subheader": "Tiếng Việt", "next_word": "Từ tiếp theo ", "no_attempts": "Bạn chưa thử bất kỳ từ nào! ", - "share": "Đăng lại ", + "share": "Chia sẻ", "notification-copied": "Sao chép vào clipboard ", "notification-partial-word": "Vui lòng nhập một từ đầy đủ " }, diff --git a/webapp/templates/404.html b/webapp/templates/404.html new file mode 100644 index 00000000..7cd14691 --- /dev/null +++ b/webapp/templates/404.html @@ -0,0 +1,29 @@ + + +{% include 'partials/_dark_mode_init.html' %} + + + {% include 'partials/_base_head.html' %} + Page Not Found — Wordle Global + + + + +
+

404

+

This page doesn't exist.

+ + Play Wordle + + + +
+ + diff --git a/webapp/templates/game.html b/webapp/templates/game.html index e57dad5d..ce64c355 100644 --- a/webapp/templates/game.html +++ b/webapp/templates/game.html @@ -7,7 +7,7 @@ {% include 'partials/_base_head.html' %} {# Page-specific SEO meta tags #} - {% set title = "Wordle " ~ language.config.name_native ~ "/" ~ language.config.name %} + {% set title = "Play Wordle in " ~ language.config.name ~ " — Free Daily Word Game (" ~ language.config.name_native ~ ")" %} {% set description = language.config.meta.description %} {{ title }} @@ -23,6 +23,9 @@ + + {% include 'partials/_hreflang.html' %} + @@ -270,21 +273,22 @@

{{ language.config.u
- +
-

{{ language.config.ui.haptic_feedback or "Haptic Feedback" }}

+

{{ language.config.ui.sound_and_haptics or "Sound & Haptics" }}

- {{ toggle_switch('hapticsEnabled', 'toggleHaptics()', language.config.ui.haptic_feedback or 'Haptic Feedback') }} + {{ toggle_switch('feedbackEnabled', 'toggleFeedback()', language.config.ui.sound_and_haptics or 'Sound & Haptics') }}
- +
-

{{ language.config.ui.sound_effects or "Sound Effects" }}

+

{{ language.config.ui.word_info or "Word Info" }}

+

{{ language.config.ui.word_info_desc or "Definition & AI art after game" }}

- {{ toggle_switch('soundEnabled', 'toggleSound()', language.config.ui.sound_effects or 'Sound Effects') }} + {{ toggle_switch('wordInfoEnabled', 'toggleWordInfo()', language.config.ui.word_info or 'Word Info') }}
@@ -376,40 +380,54 @@

Wordle {{ -
-

-

-

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

+ +
+ + +
- -
-

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

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

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

-

-

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

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

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

[[stats.n_games]]

@@ -462,8 +487,30 @@

+ +
+

+

+

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

+
+ + +
+ +
+

+ + +
+
@@ -485,7 +532,7 @@

0" class="max-h-24 overflow-y-auto border-t border-neutral-200 dark:border-neutral-600 pt-1"> +
[[getLanguageName(code)]] diff --git a/webapp/templates/index.html b/webapp/templates/index.html index eae84560..1f7fc73b 100644 --- a/webapp/templates/index.html +++ b/webapp/templates/index.html @@ -7,8 +7,8 @@ {% include 'partials/_base_head.html' %} {# Page-specific SEO meta tags #} - {% set title = "Wordle Global - " ~ (languages | length) ~ " languages available" %} - {% set description = "Wordle, the daily word puzzle. Free, Open-Source & available in (most) languages" %} + {% set title = "Wordle Global — Play the Free Daily Word Game in " ~ (languages | length) ~ "+ Languages" %} + {% set description = "Play Wordle today in " ~ (languages | length) ~ "+ languages — free, daily 5-letter word puzzle. Guess the word in 6 tries. No account needed. Available in English, Spanish, German, Arabic, Hebrew, Finnish and more." %} {{ title }} @@ -24,10 +24,28 @@ + + {% include 'partials/_hreflang.html' %} + {# Structured data for search engines #} + + diff --git a/webapp/templates/partials/_hreflang.html b/webapp/templates/partials/_hreflang.html new file mode 100644 index 00000000..135491c2 --- /dev/null +++ b/webapp/templates/partials/_hreflang.html @@ -0,0 +1,6 @@ +{# hreflang tags — tells search engines which language version to show to which users. + Uses hreflang_langs (injected globally) and hreflang_url_pattern (set per-page or default). #} +{% for lc in hreflang_langs %} + +{% endfor %} + diff --git a/webapp/templates/sitemap.xml b/webapp/templates/sitemap.xml deleted file mode 100644 index 7c81d1aa..00000000 --- a/webapp/templates/sitemap.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - {{ base_url }} - - {% for language in languages %} - - {{ base_url }}/{{ language }}s - - {% endfor %} - diff --git a/webapp/templates/sitemap_index.xml b/webapp/templates/sitemap_index.xml new file mode 100644 index 00000000..4fedfc5f --- /dev/null +++ b/webapp/templates/sitemap_index.xml @@ -0,0 +1,13 @@ + + + + {{ base_url }}/sitemap-main.xml + {{ lastmod }} + + {% for i in range(1, n_word_sitemaps + 1) %} + + {{ base_url }}/sitemap-words-{{ i }}.xml + {{ lastmod }} + + {% endfor %} + diff --git a/webapp/templates/sitemap_main.xml b/webapp/templates/sitemap_main.xml new file mode 100644 index 00000000..f23cb8c0 --- /dev/null +++ b/webapp/templates/sitemap_main.xml @@ -0,0 +1,17 @@ + + + + {{ base_url }} + 1.0 + + + {{ base_url }}/stats + 0.5 + + {% for language in languages %} + + {{ base_url }}/{{ language }} + 0.9 + + {% endfor %} + diff --git a/webapp/templates/sitemap_words.xml b/webapp/templates/sitemap_words.xml new file mode 100644 index 00000000..fb340c50 --- /dev/null +++ b/webapp/templates/sitemap_words.xml @@ -0,0 +1,10 @@ + + + {% for entry in word_pages %} + + {{ base_url }}/{{ entry.lang }}/word/{{ entry.day_idx }} + {{ entry.date }} + {{ entry.priority }} + + {% endfor %} + diff --git a/webapp/templates/stats.html b/webapp/templates/stats.html new file mode 100644 index 00000000..cf23e0bf --- /dev/null +++ b/webapp/templates/stats.html @@ -0,0 +1,148 @@ + + +{% include 'partials/_dark_mode_init.html' %} + + + {% include 'partials/_base_head.html' %} + + Wordle Global Stats — {{ total_languages }} Languages, {{ "{:,}".format(total_words) }} Words + + + + + + {% include 'partials/_hreflang.html' %} + + + +
+ + {# Header — matches word.html pattern #} +
+ + ← Home + +

Wordle Global Stats

+

+ The free daily word game in {{ total_languages }} languages +

+
+ + {# Global Stats — grid in a card #} +
+

+ Overview +

+
+
+

{{ total_languages }}

+

Languages

+
+
+

{{ "{:,}".format(total_words) }}

+

Total Words

+
+
+

{{ "{:,}".format(todays_idx) }}

+

Days Running

+
+
+

{{ "{:,}".format(total_puzzles) }}

+

Puzzles Served

+
+
+
+ + {# Community Stats (only show if we have data) #} + {% if global_plays > 0 %} +
+

+ Community Stats +

+ {% if stats_since_date %} +

Since {{ stats_since_date }}

+ {% endif %} +
+
+

{{ "{:,}".format(global_plays) }}

+

Games Played

+
+
+

{{ "{:,}".format(global_wins) }}

+

Games Won

+
+
+

{{ global_win_rate }}%

+

Win Rate

+
+
+
+ {% endif %} + + {# Word count bar chart #} +
+

+ Words by Language +

+
+ {% set max_words = lang_stats[0].n_words if lang_stats else 1 %} + {% for lang in lang_stats %} +
+ + {{ lang.code }} + +
+
+
+ {{ "{:,}".format(lang.n_words) }} +
+ {% endfor %} +
+
+ + {# Language list #} +
+

+ All Languages ({{ total_languages }}) +

+
+ {% for lang in lang_stats %} +
+
+ + {{ lang.name }} + + {% if lang.name_native and lang.name_native != lang.name %} + {{ lang.name_native }} + {% endif %} +
+
+ {{ "{:,}".format(lang.n_words) }} words + {% if lang.n_supplement > 0 %} + +{{ "{:,}".format(lang.n_supplement) }} + {% endif %} + {% if global_plays > 0 and lang.total_plays > 0 %} + {{ lang.win_rate }}% + {% endif %} +
+
+ {% endfor %} +
+
+ + {# CTA — matches word.html and 404 pattern #} + + +

+ + Open source on GitHub + +

+
+ + diff --git a/webapp/templates/word.html b/webapp/templates/word.html new file mode 100644 index 00000000..3cde0890 --- /dev/null +++ b/webapp/templates/word.html @@ -0,0 +1,223 @@ + + +{% 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 "" %} + {% if def_text %} + {% set description = "The Wordle word for " ~ lang_name ~ " #" ~ day_idx ~ " (" ~ word_date.strftime('%B %d, %Y') ~ ") was " ~ word.upper() ~ ". " ~ pos_text ~ def_text %} + {% else %} + {% set description = "The Wordle word for " ~ lang_name ~ " #" ~ day_idx ~ " (" ~ word_date.strftime('%B %d, %Y') ~ ") was " ~ word.upper() ~ ". Can you guess it in 6 tries?" %} + {% endif %} + {{ title }} + + + + + + {# Word-specific AI art as social preview (generates on first crawl if not cached) #} + + + + + + + + + + {% include 'partials/_hreflang.html' %} + + + + +
+ + {# Header #} +
+ + ← Play Wordle {{ lang_name_native }} + +

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

+

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

+
+ + {# AI Word Art Image — loads async, hides on error #} + + + {# Word Tiles #} +
+ {% for letter in word %} +
+ {{ letter }} +
+ {% endfor %} +
+ + {# Definition — server-rendered if cached, async-fetched otherwise #} + {% if definition and definition.definition %} +
+
+ Definition + {% if definition.part_of_speech %} + {{ definition.part_of_speech }} + {% endif %} +
+

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

+ + Wiktionary ↗ + +
+ {% else %} + + + {% 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 #} + +
+ + + +