upload --pre-check/--evidence, shots copy translate, game-center --locale-map, play games API-disabled hint
This commit is contained in:
parent
29a9683ab7
commit
8ffa27d4f8
24 changed files with 598 additions and 27 deletions
125
cli/shots/copy.ts
Normal file
125
cli/shots/copy.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// 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');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue