// Screenshot caption copy per language (shots.copy, store/shots/copy.json). // storebound shots copy translate --to de,es [--from en] [--translate ""] [--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 { 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 : 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 )`); 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 = {}; 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 '); } async function check(ctx: Ctx): Promise { 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 { 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 | --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 "", 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 { 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 ""] [--force] [--dry] | check | reviewed --lang de | --all'); }