storebound/cli/shots/copy.ts

125 lines
8.2 KiB
TypeScript

// Screenshot caption copy per language (shots.copy, store/shots/copy.json).
// storebound shots copy translate --to de,es [--from en] [--translate "<command>"] [--force] [--dry] [--json]
// fills the missing languages (and the missing scenes of a language) from the source language: without --translate
// the source text is copied in and marked status=translate for your agent to translate in place (listing-localization
// skill); with --translate your command runs once per caption field with the source text on stdin and
// STOREBOUND_SOURCE_LANG, STOREBOUND_TARGET_LANG, STOREBOUND_TARGET_LANG_ID, STOREBOUND_FIELD, STOREBOUND_LIMIT in the
// environment; its stdout is the translation (status=draft). A translation over the caption limit is not kept.
// Scenes already there are kept unless --force. The marker lives in copy.translations.json next to the copy file.
// storebound shots copy check [--json] marked languages, caption limits; exit 1 on a problem
// storebound shots copy reviewed --lang de[,es] | --all [--dry] a human read the copy: remove its marker
// --to / --from / --lang take config language ids, App Store locales or Play codes. Local files only.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { playCodes } from '../lib/config';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import * as rt from '../lib/rt';
import { applyFields, type CopyMarks, chars, marksNotes, marksPath, overLimit, parseMarks, sceneFields } from '../lib/shots-copy-translate';
import { resolveLanguages } from '../lib/whats-new';
import type { CopyFile } from './lib';
const TAG = 'shots-copy';
function readCopy(path: string): CopyFile {
if (!existsSync(path)) throw new Error(`${path}: no copy file (storebound init creates store/shots/copy.json)`);
return JSON.parse(readFileSync(path, 'utf8')) as CopyFile;
}
const readMarks = (path: string): CopyMarks => parseMarks(existsSync(marksPath(path)) ? readFileSync(marksPath(path), 'utf8') : null);
const write = (path: string, v: unknown): void => writeFileSync(path, JSON.stringify(v, null, 2) + '\n');
async function translate(ctx: Ctx): Promise<void> {
const { config, resolve } = ctx.loaded;
const a = ctx.args;
const shots = need(config.shots, 'shots');
const path = resolve(shots.copy);
const targets = resolveLanguages(config.languages, a.list('to'));
if (!targets.length) throw new Error('--to <languages>: config language ids, App Store locales or Play codes (add a language to languages[] in the config first)');
const primary = config.apple?.primaryLocale ?? config.play?.defaultLanguage;
const want = a.get('from');
const src = want ? resolveLanguages(config.languages, [want])[0]! : config.languages.find(l => l.apple === primary || playCodes(l).includes(primary ?? '')) ?? config.languages[0]!;
const copy = readCopy(path);
const from = copy[src.id];
if (!from) throw new Error(`${path}: no copy for the source language ${src.id} (--from <language>)`);
const hook = a.get('translate');
const dry = a.has('dry');
const marks = readMarks(path);
const rows: { lang: string; scenes: number; kept: number; status: string; problems: string[] }[] = [];
const runHook = (text: string, l: (typeof targets)[number], field: string, limit: number): string | null => {
const r = rt.spawnSync(['sh', '-c', hook!], { stdin: new TextEncoder().encode(text), stdout: 'pipe', stderr: 'pipe',
env: { ...process.env, STOREBOUND_SOURCE_LANG: src.apple ?? src.id, STOREBOUND_TARGET_LANG: l.apple ?? playCodes(l)[0] ?? l.id, STOREBOUND_TARGET_LANG_ID: l.id, STOREBOUND_FIELD: field, STOREBOUND_LIMIT: String(limit) } });
const out = r.stdout.toString().trim();
if (r.exitCode !== 0 || !out) { warn(TAG, `${l.id} ${field}: --translate failed (exit ${r.exitCode}): ${r.stderr.toString().trim().slice(0, 200)}`); return null; }
return out;
};
for (const l of targets) {
if (l === src) { warn(TAG, `${l.id} is the source language: skipped`); continue; }
const cur = { ...(copy[l.id] ?? {}) };
let written = 0, kept = 0, failed = false;
const problems: string[] = [];
for (const scene of shots.scenes) {
const sc = from[scene.id];
if (!sc) continue;
if (cur[scene.id] && !a.has('force')) { kept++; continue; }
const texts: Record<string, string> = {};
if (hook && !dry) for (const f of sceneFields(sc)) {
const t = runHook(f.text, l, `${scene.id}.${f.key}`, f.limit);
if (t === null) { failed = true; continue; }
const one = t.replace(/\s*\n\s*/g, ' ');
if (chars(one) > f.limit) { failed = true; problems.push(`${scene.id}.${f.key}: ${chars(one)} > ${f.limit} characters (source text kept)`); continue; }
texts[f.key] = one;
}
cur[scene.id] = applyFields(sc, texts);
written++;
}
const status = hook && !dry && !failed ? 'draft' : 'translate';
rows.push({ lang: l.id, scenes: written, kept, status: written ? status : 'kept', problems });
if (!written || dry) continue;
copy[l.id] = cur;
marks[l.id] = { from: src.id, status };
}
if (!dry && rows.some(r => r.scenes)) { write(path, copy); write(marksPath(path), marks); }
if (a.has('json')) { printJson({ copy: path, from: src.id, dry, languages: rows }); return; }
for (const r of rows) log(TAG, `${r.lang}: ${r.scenes ? `${r.scenes} scene(s) ${dry ? 'would be ' : ''}written (${r.status})` : 'nothing to write'}${r.kept ? `, ${r.kept} kept (--force replaces)` : ''}${r.problems.length ? `; ${r.problems.join('; ')}` : ''}`);
const todo = rows.filter(r => r.scenes && r.status === 'translate').length;
done(TAG, dry ? 'translation planned (dry run, nothing written)' : todo ? `${todo} language(s) to translate in place (edit copy.json, keep the marker in copy.translations.json), then storebound shots copy check` : 'drafts written: read them, storebound shots copy check, then storebound shots copy reviewed --lang <ids>');
}
async function check(ctx: Ctx): Promise<void> {
const { config, resolve } = ctx.loaded;
const shots = need(config.shots, 'shots');
const path = resolve(shots.copy);
const copy = readCopy(path);
const marks = readMarks(path);
const over = Object.fromEntries(Object.keys(copy).map(l => [l, overLimit(copy[l])] as const).filter(([, v]) => v.length));
const missing = config.languages.map(l => l.id).filter(id => !copy[id]);
if (ctx.args.has('json')) printJson({ copy: path, marks, overLimit: over, missing });
else {
for (const n of marksNotes(marks, path)) warn(TAG, n);
for (const [l, v] of Object.entries(over)) for (const o of v) warn(TAG, `${l} ${o.scene}.${o.field}: ${o.chars} > ${o.limit} characters`);
if (missing.length) warn(TAG, `no copy for ${missing.join(', ')}: storebound shots copy translate --to ${missing.join(',')}`);
}
if (Object.keys(over).length || missing.length) process.exitCode = 1;
else if (!ctx.args.has('json')) done(TAG, 'shots copy ok');
}
async function reviewed(ctx: Ctx): Promise<void> {
const { config, resolve } = ctx.loaded;
const path = resolve(need(config.shots, 'shots').copy);
const marks = readMarks(path);
const ids = ctx.args.has('all') ? Object.keys(marks) : resolveLanguages(config.languages, ctx.args.list('lang')).map(l => l.id);
if (!ids.length) throw new Error('--lang <languages> | --all');
const cleared = ids.filter(id => marks[id]);
const refused = cleared.filter(id => marks[id]!.status === 'translate');
if (refused.length) throw new Error(`${refused.join(', ')}: still status=translate (the source text is in place): translate it first (storebound shots copy translate --translate "<command>", or edit ${path})`);
if (!ctx.args.has('dry')) { for (const id of cleared) delete marks[id]; write(marksPath(path), marks); }
done(TAG, `${cleared.length ? cleared.join(', ') : 'nothing'} marked reviewed${ctx.args.has('dry') ? ' (dry run)' : ''}`);
}
export async function run(ctx: Ctx): Promise<void> {
const sub = ctx.args._[0];
if (sub === 'translate') return translate(ctx);
if (sub === 'check') return check(ctx);
if (sub === 'reviewed') return reviewed(ctx);
throw new Error('usage: storebound shots copy translate --to de,es [--from en] [--translate "<command>"] [--force] [--dry] | check | reviewed --lang de | --all');
}