// Screenshot copy translation (`storebound shots copy translate`): fills the languages of shots.copy (store/shots/ // copy.json: language id -> scene id -> {eyebrow, title [line1, line2], sub, badges}) from the source language's copy. // Pure. Like the listing (lib/listing-translate.ts) the CLI never calls a translation service: the agent translates the // sections marked "translate", or `--translate ""` runs a command of your own once per caption field. A // language written by this command is machine-translated until a human read it: the marker lives next to the copy file // (copy.translations.json), because copy.json itself has no place for comments: // { "de": { "from": "en", "status": "translate" | "draft" } } // translate: the source text was copied in, still to translate draft: machine-translated, review then `shots copy reviewed` // Caption limits (characters, per line / field), so a translation still fits the template without the overflow // `shots check` would flag: eyebrow 30, each of the two title lines 28, sub 90, a badge 18. A translation over its limit // is not kept (the source text stays and the language stays "translate"). import type { SceneCopy } from '../shots/lib'; export type CopyStatus = 'translate' | 'draft'; export type CopyMarks = Record; export const CAPTION_LIMITS = { eyebrow: 30, title: 28, sub: 90, badge: 18 } as const; export const marksPath = (copyPath: string): string => copyPath.replace(/\.json$/i, '') + '.translations.json'; export interface CopyField { key: string; text: string; limit: number } /** The translatable strings of a scene, in a fixed order (key: eyebrow, title.0, title.1, sub, badge..<0|1>). */ export function sceneFields(sc: SceneCopy): CopyField[] { const out: CopyField[] = []; if (sc.eyebrow) out.push({ key: 'eyebrow', text: sc.eyebrow, limit: CAPTION_LIMITS.eyebrow }); sc.title.forEach((t, i) => { if (t) out.push({ key: `title.${i}`, text: t, limit: CAPTION_LIMITS.title }); }); if (sc.sub) out.push({ key: 'sub', text: sc.sub, limit: CAPTION_LIMITS.sub }); (sc.badges ?? []).forEach((b, i) => b.forEach((t, j) => { if (t) out.push({ key: `badge.${i}.${j}`, text: t, limit: CAPTION_LIMITS.badge }); })); return out; } /** The scene with the given field texts put in (keys of sceneFields; a missing key keeps the source text). */ export function applyFields(src: SceneCopy, texts: Record): SceneCopy { const t = (key: string, d: string): string => texts[key] ?? d; const out: SceneCopy = { title: [t('title.0', src.title[0]), t('title.1', src.title[1])] }; if (src.eyebrow !== undefined) out.eyebrow = t('eyebrow', src.eyebrow); if (src.sub !== undefined) out.sub = t('sub', src.sub); if (src.badges) out.badges = src.badges.map((b, i) => b.map((x, j) => (x === undefined ? x : t(`badge.${i}.${j}`, x))) as [string, string?]); return out; } /** Characters the way a person counts them (code points). */ export const chars = (s: string): number => [...s].length; export interface SceneCheck { scene: string; field: string; chars: number; limit: number } /** Fields of a language's copy over their caption limit. */ export function overLimit(copy: Record | undefined): SceneCheck[] { const out: SceneCheck[] = []; for (const [scene, sc] of Object.entries(copy ?? {})) for (const f of sceneFields(sc)) if (chars(f.text) > f.limit) out.push({ scene, field: f.key, chars: chars(f.text), limit: f.limit }); return out; } /** Parses the marks file; anything unreadable counts as no marks. */ export function parseMarks(text: string | null): CopyMarks { if (!text) return {}; try { const j = JSON.parse(text) as Record; return Object.fromEntries(Object.entries(j).filter(([, v]) => v && (v.status === 'translate' || v.status === 'draft') && typeof v.from === 'string') .map(([k, v]) => [k, { from: v.from as string, status: v.status as CopyStatus }])); } catch { return {}; } } /** One line per marked language for `storebound check` / `shots copy check`; empty when nothing is marked. */ export function marksNotes(marks: CopyMarks, where: string): string[] { const todo = Object.entries(marks).filter(([, m]) => m.status === 'translate').map(([k]) => k); const drafts = Object.entries(marks).filter(([, m]) => m.status === 'draft').map(([k]) => k); return [ ...(todo.length ? [`${where}: not translated yet (status=translate): ${todo.join(', ')} (storebound shots copy translate --to ${todo.join(',')})`] : []), ...(drafts.length ? [`${where}: machine-translated drafts not reviewed: ${drafts.join(', ')} (storebound shots copy reviewed --lang after reading them)`] : []), ]; } /** The fix to name in "no copy" problems of validateCopy. */ export const copyFix = (lang: string): string => `storebound shots copy translate --to ${lang}`;