From dcdc3d62f7297336b2e2297cdc8189cbbc193d5b Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Mon, 29 Jun 2026 18:49:52 -0700 Subject: [PATCH] feat(i18n): manifest translation extract/merge tool + en catalog Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_i18n_translate.py | 51 ++ tools/i18n/__init__.py | 0 tools/i18n/catalogs/en.json | 1209 ++++++++++++++++++++++++++++++ tools/i18n/translate_manifest.py | 79 ++ 4 files changed, 1339 insertions(+) create mode 100644 tests/test_i18n_translate.py create mode 100644 tools/i18n/__init__.py create mode 100644 tools/i18n/catalogs/en.json create mode 100644 tools/i18n/translate_manifest.py diff --git a/tests/test_i18n_translate.py b/tests/test_i18n_translate.py new file mode 100644 index 0000000..b321336 --- /dev/null +++ b/tests/test_i18n_translate.py @@ -0,0 +1,51 @@ +import copy + +import pytest + +from tools.i18n.translate_manifest import extract_en_catalog, merge_catalog + +MANIFEST = { + "clips": [ + {"id": "cosmos", "strings": {"en": { + "detected.star": ["star", "young star"], + "measure.distance": "≈7,500 ly", + }}}, + ] +} + + +def test_extract_pulls_every_en_string(): + cat = extract_en_catalog(MANIFEST) + assert cat == {"cosmos": {"detected.star": ["star", "young star"], "measure.distance": "≈7,500 ly"}} + + +def test_merge_writes_lang_and_leaves_en(): + m = copy.deepcopy(MANIFEST) + cat = {"cosmos": {"detected.star": ["estrella", "estrella joven"], "measure.distance": "≈7.500 al"}} + merge_catalog(m, "es", cat) + s = m["clips"][0]["strings"] + assert s["en"]["measure.distance"] == "≈7,500 ly" # en untouched + assert s["es"]["detected.star"] == ["estrella", "estrella joven"] + + +def test_merge_is_idempotent(): + m = copy.deepcopy(MANIFEST) + cat = {"cosmos": {"detected.star": ["estrella", "estrella joven"], "measure.distance": "≈7.500 al"}} + merge_catalog(m, "es", cat) + once = copy.deepcopy(m) + merge_catalog(m, "es", cat) + assert m == once + + +def test_merge_rejects_tier_length_mismatch(): + m = copy.deepcopy(MANIFEST) + bad = {"cosmos": {"detected.star": ["estrella"], "measure.distance": "≈7.500 al"}} # 1 tier vs en's 2 + with pytest.raises(ValueError): + merge_catalog(m, "es", bad) + + +def test_merge_rejects_type_mismatch(): + m = copy.deepcopy(MANIFEST) + bad = {"cosmos": {"detected.star": "estrella", "measure.distance": "≈7.500 al"}} # str vs en's list + with pytest.raises(ValueError): + merge_catalog(m, "es", bad) diff --git a/tools/i18n/__init__.py b/tools/i18n/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/i18n/catalogs/en.json b/tools/i18n/catalogs/en.json new file mode 100644 index 0000000..52726da --- /dev/null +++ b/tools/i18n/catalogs/en.json @@ -0,0 +1,1209 @@ +{ + "cosmos": { + "detected.nebula": [ + "cloud", + "nebula", + "emission nebula", + "emission nebula · the Carina star-forming region" + ], + "detected.star": [ + "star", + "young star", + "protostar", + "protostar · a newborn star, <1 Myr old" + ], + "measure.distance": "≈7,500 ly", + "feel.wonder": [ + "wow", + "wonder", + "awe", + "transcendent awe" + ], + "feel.vastness": [ + "big", + "vastness", + "immensity", + "a dizzying immensity" + ], + "feel.insignificance": [ + "small", + "smallness", + "insignificance", + "a humbling insignificance" + ], + "feel.longing": [ + "want", + "longing", + "yearning", + "an aching yearning" + ] + }, + "cosmos_galaxies": { + "detected.galaxy": [ + "galaxy", + "spiral galaxy", + "barred spiral", + "barred spiral · ~10¹¹ stars" + ], + "measure.distance": "~Mly", + "feel.wonder": [ + "wow", + "wonder", + "awe", + "transcendent awe" + ], + "feel.vastness": [ + "big", + "vastness", + "immensity", + "a dizzying immensity" + ], + "feel.insignificance": [ + "small", + "smallness", + "insignificance", + "a humbling insignificance" + ], + "feel.longing": [ + "want", + "longing", + "yearning", + "an aching yearning" + ] + }, + "cosmos_orion": { + "feel.wonder": [ + "wow", + "wonder", + "awe", + "transcendent awe" + ], + "feel.vastness": [ + "big", + "vastness", + "immensity", + "a dizzying immensity" + ], + "feel.insignificance": [ + "small", + "smallness", + "insignificance", + "a humbling insignificance" + ], + "feel.longing": [ + "want", + "longing", + "yearning", + "an aching yearning" + ] + }, + "cosmos_tarantula": { + "feel.wonder": [ + "wow", + "wonder", + "awe", + "transcendent awe" + ], + "feel.vastness": [ + "big", + "vastness", + "immensity", + "a dizzying immensity" + ], + "feel.insignificance": [ + "small", + "smallness", + "insignificance", + "a humbling insignificance" + ], + "feel.longing": [ + "want", + "longing", + "yearning", + "an aching yearning" + ] + }, + "cosmos_westerlund": { + "feel.wonder": [ + "wow", + "wonder", + "awe", + "transcendent awe" + ], + "feel.vastness": [ + "big", + "vastness", + "immensity", + "a dizzying immensity" + ], + "feel.insignificance": [ + "small", + "smallness", + "insignificance", + "a humbling insignificance" + ], + "feel.longing": [ + "want", + "longing", + "yearning", + "an aching yearning" + ] + }, + "cosmos_southernring": { + "feel.wonder": [ + "wow", + "wonder", + "awe", + "transcendent awe" + ], + "feel.vastness": [ + "big", + "vastness", + "immensity", + "a dizzying immensity" + ], + "feel.insignificance": [ + "small", + "smallness", + "insignificance", + "a humbling insignificance" + ], + "feel.longing": [ + "want", + "longing", + "yearning", + "an aching yearning" + ] + }, + "cosmos_carina_eso": { + "feel.wonder": [ + "wow", + "wonder", + "awe", + "transcendent awe" + ], + "feel.vastness": [ + "big", + "vastness", + "immensity", + "a dizzying immensity" + ], + "feel.insignificance": [ + "small", + "smallness", + "insignificance", + "a humbling insignificance" + ], + "feel.longing": [ + "want", + "longing", + "yearning", + "an aching yearning" + ] + }, + "orbit_planetearth": { + "detected.cloud_band": [ + "clouds", + "cloud band", + "cumulus field", + "cumulus field · tropical convection" + ], + "detected.limb": [ + "edge", + "Earth’s limb", + "atmospheric limb", + "atmospheric limb · ~100 km of air" + ], + "measure.altitude": "~408 km", + "feel.serenity": [ + "calm", + "serenity", + "peace", + "a quiet, weightless peace" + ], + "feel.fragility": [ + "thin", + "fragility", + "vulnerability", + "a tender fragility" + ], + "feel.unity": [ + "one", + "unity", + "belonging", + "a borderless belonging" + ], + "feel.distance": [ + "far", + "distance", + "remoteness", + "an exquisite remoteness" + ] + }, + "orbit_bluemarble": { + "detected.globe": [ + "Earth", + "the globe", + "terrestrial planet", + "terrestrial planet · 12,742 km across" + ], + "feel.serenity": [ + "calm", + "serenity", + "peace", + "a quiet, weightless peace" + ], + "feel.fragility": [ + "thin", + "fragility", + "vulnerability", + "a tender fragility" + ], + "feel.unity": [ + "one", + "unity", + "belonging", + "a borderless belonging" + ], + "feel.distance": [ + "far", + "distance", + "remoteness", + "an exquisite remoteness" + ] + }, + "orbit_aurora2025": { + "feel.serenity": [ + "calm", + "serenity", + "peace", + "a quiet, weightless peace" + ], + "feel.fragility": [ + "thin", + "fragility", + "vulnerability", + "a tender fragility" + ], + "feel.unity": [ + "one", + "unity", + "belonging", + "a borderless belonging" + ], + "feel.distance": [ + "far", + "distance", + "remoteness", + "an exquisite remoteness" + ] + }, + "orbit_citylights": { + "feel.serenity": [ + "calm", + "serenity", + "peace", + "a quiet, weightless peace" + ], + "feel.fragility": [ + "thin", + "fragility", + "vulnerability", + "a tender fragility" + ], + "feel.unity": [ + "one", + "unity", + "belonging", + "a borderless belonging" + ], + "feel.distance": [ + "far", + "distance", + "remoteness", + "an exquisite remoteness" + ] + }, + "orbit_helene": { + "feel.serenity": [ + "calm", + "serenity", + "peace", + "a quiet, weightless peace" + ], + "feel.fragility": [ + "thin", + "fragility", + "vulnerability", + "a tender fragility" + ], + "feel.unity": [ + "one", + "unity", + "belonging", + "a borderless belonging" + ], + "feel.distance": [ + "far", + "distance", + "remoteness", + "an exquisite remoteness" + ] + }, + "orbit_epic": { + "feel.serenity": [ + "calm", + "serenity", + "peace", + "a quiet, weightless peace" + ], + "feel.fragility": [ + "thin", + "fragility", + "vulnerability", + "a tender fragility" + ], + "feel.unity": [ + "one", + "unity", + "belonging", + "a borderless belonging" + ], + "feel.distance": [ + "far", + "distance", + "remoteness", + "an exquisite remoteness" + ] + }, + "sky_grca_templesa": { + "feel.exhilaration": [ + "whee", + "exhilaration", + "elation", + "a soaring elation" + ], + "feel.freedom": [ + "free", + "freedom", + "release", + "a boundless release" + ], + "feel.lightness": [ + "light", + "lightness", + "buoyancy", + "a weightless buoyancy" + ], + "feel.vertigo": [ + "whoa", + "vertigo", + "giddiness", + "a giddy, falling vertigo" + ] + }, + "sky_greenland_landice": { + "feel.exhilaration": [ + "whee", + "exhilaration", + "elation", + "a soaring elation" + ], + "feel.freedom": [ + "free", + "freedom", + "release", + "a boundless release" + ], + "feel.lightness": [ + "light", + "lightness", + "buoyancy", + "a weightless buoyancy" + ], + "feel.vertigo": [ + "whoa", + "vertigo", + "giddiness", + "a giddy, falling vertigo" + ] + }, + "sky_greenland_suture": { + "feel.exhilaration": [ + "whee", + "exhilaration", + "elation", + "a soaring elation" + ], + "feel.freedom": [ + "free", + "freedom", + "release", + "a boundless release" + ], + "feel.lightness": [ + "light", + "lightness", + "buoyancy", + "a weightless buoyancy" + ], + "feel.vertigo": [ + "whoa", + "vertigo", + "giddiness", + "a giddy, falling vertigo" + ] + }, + "sky_jungle_amazon": { + "feel.exhilaration": [ + "whee", + "exhilaration", + "elation", + "a soaring elation" + ], + "feel.freedom": [ + "free", + "freedom", + "release", + "a boundless release" + ], + "feel.lightness": [ + "light", + "lightness", + "buoyancy", + "a weightless buoyancy" + ], + "feel.vertigo": [ + "whoa", + "vertigo", + "giddiness", + "a giddy, falling vertigo" + ] + }, + "sky_jungle_waterfall": { + "feel.exhilaration": [ + "whee", + "exhilaration", + "elation", + "a soaring elation" + ], + "feel.freedom": [ + "free", + "freedom", + "release", + "a boundless release" + ], + "feel.lightness": [ + "light", + "lightness", + "buoyancy", + "a weightless buoyancy" + ], + "feel.vertigo": [ + "whoa", + "vertigo", + "giddiness", + "a giddy, falling vertigo" + ] + }, + "sky_coast_cliffspain": { + "feel.exhilaration": [ + "whee", + "exhilaration", + "elation", + "a soaring elation" + ], + "feel.freedom": [ + "free", + "freedom", + "release", + "a boundless release" + ], + "feel.lightness": [ + "light", + "lightness", + "buoyancy", + "a weightless buoyancy" + ], + "feel.vertigo": [ + "whoa", + "vertigo", + "giddiness", + "a giddy, falling vertigo" + ] + }, + "sky_mtn_castlecrags": { + "feel.exhilaration": [ + "whee", + "exhilaration", + "elation", + "a soaring elation" + ], + "feel.freedom": [ + "free", + "freedom", + "release", + "a boundless release" + ], + "feel.lightness": [ + "light", + "lightness", + "buoyancy", + "a weightless buoyancy" + ], + "feel.vertigo": [ + "whoa", + "vertigo", + "giddiness", + "a giddy, falling vertigo" + ] + }, + "sky_mtn_rocky": { + "feel.exhilaration": [ + "whee", + "exhilaration", + "elation", + "a soaring elation" + ], + "feel.freedom": [ + "free", + "freedom", + "release", + "a boundless release" + ], + "feel.lightness": [ + "light", + "lightness", + "buoyancy", + "a weightless buoyancy" + ], + "feel.vertigo": [ + "whoa", + "vertigo", + "giddiness", + "a giddy, falling vertigo" + ] + }, + "coast_birdrock": { + "detected.surf": [ + "waves", + "surf", + "breaking swell", + "breaking swell · wind-driven, ~10 s period" + ], + "detected.searock": [ + "rock", + "sea stack", + "coastal sea stack", + "sea stack · wave-cut residual rock" + ], + "feel.ease": [ + "nice", + "ease", + "calm", + "an unhurried calm" + ], + "feel.nostalgia": [ + "miss", + "nostalgia", + "wistfulness", + "a salt-air wistfulness" + ], + "feel.belonging": [ + "home", + "belonging", + "rootedness", + "a tidal rootedness" + ], + "feel.melancholy": [ + "sad", + "melancholy", + "longing", + "a soft seaward longing" + ] + }, + "coast_surfgrass": { + "detected.surfgrass": [ + "grass", + "surfgrass", + "Phyllospadix", + "Phyllospadix · a marine seagrass, not algae" + ], + "detected.coralline": [ + "pink", + "coralline algae", + "crustose coralline", + "crustose coralline · calcified red algae" + ], + "feel.ease": [ + "nice", + "ease", + "calm", + "an unhurried calm" + ], + "feel.nostalgia": [ + "miss", + "nostalgia", + "wistfulness", + "a salt-air wistfulness" + ], + "feel.belonging": [ + "home", + "belonging", + "rootedness", + "a tidal rootedness" + ], + "feel.melancholy": [ + "sad", + "melancholy", + "longing", + "a soft seaward longing" + ] + }, + "coast_kelp": { + "feel.ease": [ + "nice", + "ease", + "calm", + "an unhurried calm" + ], + "feel.nostalgia": [ + "miss", + "nostalgia", + "wistfulness", + "a salt-air wistfulness" + ], + "feel.belonging": [ + "home", + "belonging", + "rootedness", + "a tidal rootedness" + ], + "feel.melancholy": [ + "sad", + "melancholy", + "longing", + "a soft seaward longing" + ] + }, + "coast_otters": { + "feel.ease": [ + "nice", + "ease", + "calm", + "an unhurried calm" + ], + "feel.nostalgia": [ + "miss", + "nostalgia", + "wistfulness", + "a salt-air wistfulness" + ], + "feel.belonging": [ + "home", + "belonging", + "rootedness", + "a tidal rootedness" + ], + "feel.melancholy": [ + "sad", + "melancholy", + "longing", + "a soft seaward longing" + ] + }, + "coast_kalaloch": { + "feel.ease": [ + "nice", + "ease", + "calm", + "an unhurried calm" + ], + "feel.nostalgia": [ + "miss", + "nostalgia", + "wistfulness", + "a salt-air wistfulness" + ], + "feel.belonging": [ + "home", + "belonging", + "rootedness", + "a tidal rootedness" + ], + "feel.melancholy": [ + "sad", + "melancholy", + "longing", + "a soft seaward longing" + ] + }, + "coast_seals": { + "feel.ease": [ + "nice", + "ease", + "calm", + "an unhurried calm" + ], + "feel.nostalgia": [ + "miss", + "nostalgia", + "wistfulness", + "a salt-air wistfulness" + ], + "feel.belonging": [ + "home", + "belonging", + "rootedness", + "a tidal rootedness" + ], + "feel.melancholy": [ + "sad", + "melancholy", + "longing", + "a soft seaward longing" + ] + }, + "coast_mist": { + "feel.ease": [ + "nice", + "ease", + "calm", + "an unhurried calm" + ], + "feel.nostalgia": [ + "miss", + "nostalgia", + "wistfulness", + "a salt-air wistfulness" + ], + "feel.belonging": [ + "home", + "belonging", + "rootedness", + "a tidal rootedness" + ], + "feel.melancholy": [ + "sad", + "melancholy", + "longing", + "a soft seaward longing" + ] + }, + "reef_lionfish": { + "detected.lionfish": [ + "fish", + "lionfish", + "Pterois volitans", + "Pterois volitans · venomous spines, invasive in the Atlantic" + ], + "detected.snapper": [ + "fish", + "blue snapper", + "Lutjanus", + "Lutjanus · schools over reef structure" + ], + "measure.depth": "−18 m", + "feel.delight": [ + "fun", + "delight", + "joy", + "a darting, bright joy" + ], + "feel.abundance": [ + "full", + "abundance", + "richness", + "a teeming richness" + ], + "feel.curiosity": [ + "look", + "curiosity", + "fascination", + "an absorbed fascination" + ], + "feel.immersion": [ + "in", + "immersion", + "absorption", + "a held, breathless absorption" + ] + }, + "reef_spawning": { + "detected.school": [ + "fish", + "fish school", + "snapper aggregation", + "snapper aggregation · synchronized spawning run" + ], + "detected.coral": [ + "coral", + "reef coral", + "scleractinian", + "scleractinian · reef-building stony coral" + ], + "measure.depth": "−22 m", + "feel.delight": [ + "fun", + "delight", + "joy", + "a darting, bright joy" + ], + "feel.abundance": [ + "full", + "abundance", + "richness", + "a teeming richness" + ], + "feel.curiosity": [ + "look", + "curiosity", + "fascination", + "an absorbed fascination" + ], + "feel.immersion": [ + "in", + "immersion", + "absorption", + "a held, breathless absorption" + ] + }, + "reef_hawkfish": { + "detected.parrotfish": [ + "fish", + "parrotfish", + "Bolbometopon muricatum", + "B. muricatum · humphead, grazes reef into sand" + ], + "measure.depth": "−12 m", + "feel.delight": [ + "fun", + "delight", + "joy", + "a darting, bright joy" + ], + "feel.abundance": [ + "full", + "abundance", + "richness", + "a teeming richness" + ], + "feel.curiosity": [ + "look", + "curiosity", + "fascination", + "an absorbed fascination" + ], + "feel.immersion": [ + "in", + "immersion", + "absorption", + "a held, breathless absorption" + ] + }, + "reef_coralspacific": { + "detected.coral": [ + "coral", + "coral colony", + "Pacific scleractinian", + "Pacific scleractinian · a symbiosis with algae" + ], + "detected.spotfish": [ + "fish", + "spotted fish", + "reef damselfish", + "reef damselfish · territorial over its coral" + ], + "feel.delight": [ + "fun", + "delight", + "joy", + "a darting, bright joy" + ], + "feel.abundance": [ + "full", + "abundance", + "richness", + "a teeming richness" + ], + "feel.curiosity": [ + "look", + "curiosity", + "fascination", + "an absorbed fascination" + ], + "feel.immersion": [ + "in", + "immersion", + "absorption", + "a held, breathless absorption" + ] + }, + "reef_redsea": { + "feel.delight": [ + "fun", + "delight", + "joy", + "a darting, bright joy" + ], + "feel.abundance": [ + "full", + "abundance", + "richness", + "a teeming richness" + ], + "feel.curiosity": [ + "look", + "curiosity", + "fascination", + "an absorbed fascination" + ], + "feel.immersion": [ + "in", + "immersion", + "absorption", + "a held, breathless absorption" + ] + }, + "reef_flowergarden": { + "feel.delight": [ + "fun", + "delight", + "joy", + "a darting, bright joy" + ], + "feel.abundance": [ + "full", + "abundance", + "richness", + "a teeming richness" + ], + "feel.curiosity": [ + "look", + "curiosity", + "fascination", + "an absorbed fascination" + ], + "feel.immersion": [ + "in", + "immersion", + "absorption", + "a held, breathless absorption" + ] + }, + "abyss_wow": { + "detected.jelly": [ + "jelly", + "comb jelly", + "Ctenophora", + "Ctenophora · swims by beating rows of cilia" + ], + "detected.siphonophore": [ + "chain", + "siphonophore", + "Siphonophorae", + "Siphonophorae · a colony of clones, can exceed 40 m" + ], + "measure.depth": "−1,200 m", + "feel.unease": [ + "uh", + "unease", + "disquiet", + "a creeping disquiet" + ], + "feel.fascination": [ + "ooh", + "fascination", + "wonder", + "a forbidden wonder" + ], + "feel.isolation": [ + "alone", + "isolation", + "solitude", + "a crushing solitude" + ], + "feel.dread": [ + "fear", + "dread", + "foreboding", + "a slow, cold foreboding" + ] + }, + "abyss_midwaterexp": { + "detected.medusa": [ + "jelly", + "white medusa", + "Scyphozoa", + "Scyphozoa · the swimming medusa stage of a jellyfish" + ], + "detected.worm": [ + "worm", + "red worm", + "polychaete", + "polychaete · a free-swimming bristle worm" + ], + "measure.depth": "−1,600 m", + "feel.unease": [ + "uh", + "unease", + "disquiet", + "a creeping disquiet" + ], + "feel.fascination": [ + "ooh", + "fascination", + "wonder", + "a forbidden wonder" + ], + "feel.isolation": [ + "alone", + "isolation", + "solitude", + "a crushing solitude" + ], + "feel.dread": [ + "fear", + "dread", + "foreboding", + "a slow, cold foreboding" + ] + }, + "abyss_hiding": { + "detected.jelly": [ + "jelly", + "crimson jelly", + "Scyphozoa", + "Scyphozoa · red is invisible in the lightless deep" + ], + "measure.depth": "−2,140 m", + "feel.unease": [ + "uh", + "unease", + "disquiet", + "a creeping disquiet" + ], + "feel.fascination": [ + "ooh", + "fascination", + "wonder", + "a forbidden wonder" + ], + "feel.isolation": [ + "alone", + "isolation", + "solitude", + "a crushing solitude" + ], + "feel.dread": [ + "fear", + "dread", + "foreboding", + "a slow, cold foreboding" + ] + }, + "abyss_bigfin": { + "feel.unease": [ + "uh", + "unease", + "disquiet", + "a creeping disquiet" + ], + "feel.fascination": [ + "ooh", + "fascination", + "wonder", + "a forbidden wonder" + ], + "feel.isolation": [ + "alone", + "isolation", + "solitude", + "a crushing solitude" + ], + "feel.dread": [ + "fear", + "dread", + "foreboding", + "a slow, cold foreboding" + ] + }, + "abyss_dandelion": { + "feel.unease": [ + "uh", + "unease", + "disquiet", + "a creeping disquiet" + ], + "feel.fascination": [ + "ooh", + "fascination", + "wonder", + "a forbidden wonder" + ], + "feel.isolation": [ + "alone", + "isolation", + "solitude", + "a crushing solitude" + ], + "feel.dread": [ + "fear", + "dread", + "foreboding", + "a slow, cold foreboding" + ] + }, + "abyss_octopus": { + "feel.unease": [ + "uh", + "unease", + "disquiet", + "a creeping disquiet" + ], + "feel.fascination": [ + "ooh", + "fascination", + "wonder", + "a forbidden wonder" + ], + "feel.isolation": [ + "alone", + "isolation", + "solitude", + "a crushing solitude" + ], + "feel.dread": [ + "fear", + "dread", + "foreboding", + "a slow, cold foreboding" + ] + }, + "abyss_seapig": { + "feel.unease": [ + "uh", + "unease", + "disquiet", + "a creeping disquiet" + ], + "feel.fascination": [ + "ooh", + "fascination", + "wonder", + "a forbidden wonder" + ], + "feel.isolation": [ + "alone", + "isolation", + "solitude", + "a crushing solitude" + ], + "feel.dread": [ + "fear", + "dread", + "foreboding", + "a slow, cold foreboding" + ] + } +} diff --git a/tools/i18n/translate_manifest.py b/tools/i18n/translate_manifest.py new file mode 100644 index 0000000..1ccf406 --- /dev/null +++ b/tools/i18n/translate_manifest.py @@ -0,0 +1,79 @@ +"""Extract a manifest's English annotation strings and merge authored +translations back into each clip's `strings` dict (no schema change). + +Workflow: + 1. `extract` writes tools/i18n/catalogs/en.json — the English strings. + 2. A translator (human or LLM) authors es.json / fr.json / ja.json with the + SAME structure (lists stay lists of the same length). + 3. `merge` validates structure and writes strings[lang] into the manifest. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def extract_en_catalog(manifest: dict) -> dict: + out = {} + for clip in manifest.get("clips", []): + en = (clip.get("strings") or {}).get("en") + if en: + out[clip["id"]] = en + return out + + +def _check_shape(key: str, en_val, tr_val): + if isinstance(en_val, list): + if not isinstance(tr_val, list) or len(tr_val) != len(en_val): + raise ValueError(f"{key}: expected list of {len(en_val)} tiers, got {tr_val!r}") + elif not isinstance(tr_val, str): + raise ValueError(f"{key}: expected string, got {tr_val!r}") + + +def merge_catalog(manifest: dict, lang: str, catalog: dict) -> dict: + if lang == "en": + raise ValueError("refusing to overwrite the en source") + for clip in manifest.get("clips", []): + en = (clip.get("strings") or {}).get("en") + tr = catalog.get(clip["id"]) + if not en or not tr: + continue + for key, en_val in en.items(): + if key not in tr: + raise ValueError(f"{clip['id']}/{key}: missing in {lang} catalog") + _check_shape(f"{clip['id']}/{key}", en_val, tr[key]) + clip["strings"][lang] = {k: tr[k] for k in en} # en key order, lang values + return manifest + + +def _main(argv=None): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("command", choices=["extract", "merge"]) + ap.add_argument("--manifest", default="simulator/sample_media/manifest.json") + ap.add_argument("--catalog-dir", default="tools/i18n/catalogs") + ap.add_argument("--lang", help="merge: target language code") + args = ap.parse_args(argv) + + mpath = Path(args.manifest) + manifest = json.loads(mpath.read_text(encoding="utf-8")) + cdir = Path(args.catalog_dir) + + if args.command == "extract": + cdir.mkdir(parents=True, exist_ok=True) + (cdir / "en.json").write_text( + json.dumps(extract_en_catalog(manifest), ensure_ascii=False, indent=1) + "\n", + encoding="utf-8", + ) + print(f"wrote {cdir / 'en.json'}") + else: + if not args.lang: + ap.error("merge requires --lang") + catalog = json.loads((cdir / f"{args.lang}.json").read_text(encoding="utf-8")) + merge_catalog(manifest, args.lang, catalog) + mpath.write_text(json.dumps(manifest, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") + print(f"merged {args.lang} into {mpath}") + + +if __name__ == "__main__": + _main()