storebound/cli/lib/shots-copy-translate.ts

75 lines
4.8 KiB
TypeScript

// 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 "<command>"` 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<string, { from: string; status: CopyStatus }>;
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.<n>.<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<string, string>): 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<string, SceneCopy> | 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<string, { from?: unknown; status?: unknown }>;
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 <ids> 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}`;