75 lines
4.8 KiB
TypeScript
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}`;
|