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
|
|
@ -768,6 +768,15 @@ export interface ShipConfig {
|
|||
/** Words `whats-new check` must not flag as internal jargon (a product or game term that collides), any case. */
|
||||
allowWords?: string[];
|
||||
};
|
||||
/** Uploads (`apple upload`, `play upload`). */
|
||||
upload?: {
|
||||
/** Shell command run before any byte is sent; `{file}` is the IPA / AAB path. A nonzero exit refuses the upload
|
||||
* (ship-step error code PRE_CHECK_FAILED). `--pre-check` overrides it. */
|
||||
preCheck?: string;
|
||||
/** Where the pre-check evidence JSON (file, sha256, size, command, exit code, stdout tail, time, storebound
|
||||
* version) is written. `--evidence` overrides it. */
|
||||
evidence?: string;
|
||||
};
|
||||
/** Native builds (`apple build`, `play build`). */
|
||||
build?: {
|
||||
/** Shell command run in the project root before the archive / bundle, e.g. "npm run build && npx cap sync" for a
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function playGamesPart(config: ShipConfig, p: PlanIds): Part {
|
|||
{ n: 5, text: `Credentials > Add credential > Android: pick ${pkg}; it links to an OAuth client in Google Cloud. Create it at ${GOOGLE_CLOUD_CREDENTIALS} > Create credentials > OAuth client ID > Android (package ${pkg}, the SHA-1 of step 4) > Create, then back in Play Console pick that client > Save changes.`, click: ['Add credential', 'Save changes'], answers: ['package', 'sha1'] },
|
||||
{ n: 6, text: `Only with a game server (ask-owner): Add credential > Game server: create a Web application OAuth client at ${GOOGLE_CLOUD_CREDENTIALS}, pick it, Save changes. Its client id goes into the game's requestServerSideAccess; the client secret stays on the server (never in chat or files here).`, answers: ['webClient'] },
|
||||
{ n: 7, text: 'Copy the project id (Configuration page, "Project ID" / Get resources): write it to games.playGames.appId, and put the resources XML into android/app/src/main/res/values/games-ids.xml. `storebound check` then compares it with the manifest\'s com.google.android.gms.games.APP_ID.' },
|
||||
{ n: 8, text: `Leaderboards (${lb}) and achievements (${ach}) come from the config: run \`storebound play games --dry\`, then without --dry (the Configuration API; drafts only). Play needs at least 5 achievements before it publishes the games project.` },
|
||||
{ n: 8, text: `Leaderboards (${lb}) and achievements (${ach}) come from the config: run \`storebound play games --dry\`, then without --dry (the Configuration API; drafts only). If it answers 403 SERVICE_DISABLED / accessNotConfigured, the Google Play Game Services Publishing API is off in the service account's Cloud project: open https://console.cloud.google.com/apis/library/gamesconfiguration.googleapis.com?project=<project id of the service account key> (the error names the project), press Enable, wait a minute, run it again. Play needs at least 5 achievements before it publishes the games project.` },
|
||||
{ n: 9, text: 'Testers: Play Games Services > Testers lists who can sign in before publishing (the owner adds them; storebound never invites testers).' },
|
||||
{ n: 10, text: `Stop before "${PGS_PUBLISH}" (Review and publish): the owner presses it. Then read the page text and pipe it to \`storebound console verify play-games-services --from-text -\`.` },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { homedir, tmpdir } from 'node:os';
|
|||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
import * as rt from './rt';
|
||||
|
||||
export interface ServiceAccount { client_email: string; private_key: string; token_uri?: string }
|
||||
export interface ServiceAccount { client_email: string; private_key: string; token_uri?: string; project_id?: string }
|
||||
export interface AscKey { keyId: string; issuerId: string; pem: string }
|
||||
export interface ReviewContact { firstName: string; lastName: string; phone: string; email: string }
|
||||
|
||||
|
|
@ -201,7 +201,7 @@ export function parseServiceAccount(text: string): ServiceAccount {
|
|||
const j = JSON.parse(text) as Partial<ServiceAccount>;
|
||||
if (typeof j.client_email !== 'string' || typeof j.private_key !== 'string' || !j.private_key.includes('PRIVATE KEY'))
|
||||
throw new Error('not a Google service account JSON key (client_email / private_key missing)');
|
||||
return { client_email: j.client_email, private_key: j.private_key, token_uri: j.token_uri };
|
||||
return { client_email: j.client_email, private_key: j.private_key, token_uri: j.token_uri, ...(typeof j.project_id === 'string' ? { project_id: j.project_id } : {}) };
|
||||
}
|
||||
|
||||
export function playServiceAccount(root: string): ServiceAccount {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ export interface GamesConfig {
|
|||
gameCenter?: boolean;
|
||||
/** Play Games Services: the games project id (the digits of games_APP_ID / com.google.android.gms.games.APP_ID). */
|
||||
playGames?: { appId?: string };
|
||||
/** Game Center only: config language id (or its apple locale) -> the locale the live Game Center uses, e.g. { en: 'en-GB' } when the app's English is en-GB there but the language says en-US. `storebound apple game-center --locale-map en=en-GB` overrides. */
|
||||
localeMap?: Record<string, string>;
|
||||
leaderboards?: GameLeaderboard[];
|
||||
achievements?: GameAchievement[];
|
||||
}
|
||||
|
|
@ -78,6 +80,10 @@ export function validateGames(g: GamesConfig | undefined, languages: Language[]
|
|||
if (g.gameCenter !== undefined && typeof g.gameCenter !== 'boolean') e.push('games.gameCenter: true | false');
|
||||
if (g.playGames !== undefined && (typeof g.playGames !== 'object' || (g.playGames.appId !== undefined && !/^\d{6,20}$/.test(String(g.playGames.appId)))))
|
||||
e.push('games.playGames.appId: the digits of the Play Games Services project id (games_APP_ID)');
|
||||
if (g.localeMap !== undefined) {
|
||||
if (!g.localeMap || typeof g.localeMap !== 'object' || Array.isArray(g.localeMap)) e.push('games.localeMap: an object { en: "en-GB" } (config language id or locale -> Game Center locale)');
|
||||
else for (const [k, v] of Object.entries(g.localeMap)) if (typeof v !== 'string' || !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(v)) e.push(`games.localeMap.${k}: "${String(v)}" is not a locale like en-GB`);
|
||||
}
|
||||
const langs = new Set(languages.map(l => l.id));
|
||||
const seen = new Set<string>();
|
||||
const checkId = (kind: string, id: unknown) => {
|
||||
|
|
@ -133,7 +139,41 @@ export function changedFields(have: Record<string, unknown> | undefined, want: R
|
|||
return Object.keys(want).filter(k => JSON.stringify(have?.[k] ?? null) !== JSON.stringify(want[k] ?? null));
|
||||
}
|
||||
|
||||
const appleLocale = (languages: Language[], id: string): string | undefined => languages.find(l => l.id === id)?.apple;
|
||||
const appleLocale = (languages: Language[], id: string, map: Record<string, string> = {}): string | undefined => {
|
||||
const apple = languages.find(l => l.id === id)?.apple;
|
||||
return map[id] ?? (apple ? map[apple] : undefined) ?? apple;
|
||||
};
|
||||
|
||||
/** The effective Game Center locale map: games.localeMap, then --locale-map en=en-GB,tr=tr (flag entries win). */
|
||||
export function gcLocaleMap(g: GamesConfig | undefined, flag: string[] = []): Record<string, string> {
|
||||
const out: Record<string, string> = { ...(g?.localeMap ?? {}) };
|
||||
for (const item of flag.flatMap(x => x.split(',')).map(x => x.trim()).filter(Boolean)) {
|
||||
const m = /^([A-Za-z0-9_-]+)=([A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*)$/.exec(item);
|
||||
if (!m) throw new Error(`--locale-map "${item}": use <config language id or locale>=<Game Center locale>, e.g. en=en-GB (several: en=en-GB,pt=pt-PT)`);
|
||||
out[m[1]!] = m[2]!;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A suggestion per config language whose Game Center locale is not on the live Game Center while exactly one other
|
||||
* variant of the same language is (en-US configured, only en-GB live): the map entry that stops the spurious creates. */
|
||||
export function suggestLocaleMap(languages: Language[], liveLocales: string[], map: Record<string, string> = {}): { id: string; from: string; to: string }[] {
|
||||
const live = [...new Set(liveLocales)];
|
||||
if (!live.length) return [];
|
||||
const out: { id: string; from: string; to: string }[] = [];
|
||||
for (const l of languages) {
|
||||
const from = appleLocale(languages, l.id, map);
|
||||
if (!from || live.includes(from) || map[l.id] || map[l.apple ?? '']) continue;
|
||||
const base = from.split('-')[0]!.toLowerCase();
|
||||
const same = live.filter(x => x.split('-')[0]!.toLowerCase() === base);
|
||||
if (same.length === 1) out.push({ id: l.id, from, to: same[0]! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The live locales of a Game Center read (every leaderboard and achievement localization). */
|
||||
export const liveGcLocales = (have: { leaderboards: GcExisting[]; achievements: GcExisting[] }): string[] =>
|
||||
[...have.leaderboards, ...have.achievements].flatMap(x => x.localizations.map(l => String(l.attributes.locale ?? '')).filter(Boolean));
|
||||
|
||||
export function gcLeaderboardAttrs(l: GameLeaderboard): Record<string, unknown> {
|
||||
return {
|
||||
|
|
@ -151,7 +191,7 @@ export interface GcExisting { id: string; attributes: Record<string, unknown>; l
|
|||
|
||||
/** Game Center plan: matched by vendorIdentifier; create what is missing, PATCH what differs, keep the rest. Archived
|
||||
* items on the store are left alone (an archived vendor id cannot be reused; the plan names it). */
|
||||
export function planGameCenter(g: GamesConfig, languages: Language[], have: { leaderboards: GcExisting[]; achievements: GcExisting[] }): ItemPlan[] {
|
||||
export function planGameCenter(g: GamesConfig, languages: Language[], have: { leaderboards: GcExisting[]; achievements: GcExisting[] }, localeMap: Record<string, string> = {}): ItemPlan[] {
|
||||
const out: ItemPlan[] = [];
|
||||
const one = (kind: ItemPlan['kind'], id: string, attrs: Record<string, unknown>, locs: Record<string, Record<string, unknown>>, list: GcExisting[]) => {
|
||||
const cur = list.find(x => x.attributes.vendorIdentifier === id);
|
||||
|
|
@ -166,7 +206,7 @@ export function planGameCenter(g: GamesConfig, languages: Language[], have: { le
|
|||
for (const l of (g.leaderboards ?? []).filter(x => onStore(x, 'apple'))) {
|
||||
const locs: Record<string, Record<string, unknown>> = {};
|
||||
for (const [k, v] of Object.entries(l.locales)) {
|
||||
const loc = appleLocale(languages, k);
|
||||
const loc = appleLocale(languages, k, localeMap);
|
||||
if (loc) locs[loc] = { name: v.name, ...(v.suffix ? { formatterSuffix: v.suffix } : {}) };
|
||||
}
|
||||
one('leaderboard', l.id, gcLeaderboardAttrs(l), locs, have.leaderboards);
|
||||
|
|
@ -174,7 +214,7 @@ export function planGameCenter(g: GamesConfig, languages: Language[], have: { le
|
|||
for (const a of (g.achievements ?? []).filter(x => onStore(x, 'apple'))) {
|
||||
const locs: Record<string, Record<string, unknown>> = {};
|
||||
for (const [k, v] of Object.entries(a.locales)) {
|
||||
const loc = appleLocale(languages, k);
|
||||
const loc = appleLocale(languages, k, localeMap);
|
||||
if (loc) locs[loc] = { name: v.name, beforeEarnedDescription: v.description, afterEarnedDescription: v.earned ?? v.description };
|
||||
}
|
||||
one('achievement', a.id, gcAchievementAttrs(a), locs, have.achievements);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const PLAY_USERS_URL = 'https://play.google.com/console/developers/users-
|
|||
const TITLES: Record<string, string> = {
|
||||
'playdeveloperreporting.googleapis.com': 'Google Play Developer Reporting API',
|
||||
'androidpublisher.googleapis.com': 'Google Play Android Developer API',
|
||||
'gamesconfiguration.googleapis.com': 'Google Play Game Services Publishing API',
|
||||
'storage.googleapis.com': 'Cloud Storage API',
|
||||
'storage-api.googleapis.com': 'Cloud Storage API',
|
||||
};
|
||||
|
|
@ -26,6 +27,7 @@ const TITLES: Record<string, string> = {
|
|||
const NEEDS: Record<string, string> = {
|
||||
'playdeveloperreporting.googleapis.com': 'Crash and ANR data',
|
||||
'androidpublisher.googleapis.com': 'Reading and updating the app on Google Play',
|
||||
'gamesconfiguration.googleapis.com': 'Play Games Services leaderboards and achievements (storebound play games)',
|
||||
'storage.googleapis.com': 'Play statistics (store listing visitors, installs)',
|
||||
'storage-api.googleapis.com': 'Play statistics (store listing visitors, installs)',
|
||||
};
|
||||
|
|
|
|||
72
cli/lib/pre-check.ts
Normal file
72
cli/lib/pre-check.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// The pre-upload check of `apple upload` and `play upload`: a shell command the project owns (a store-readiness gate,
|
||||
// a policy scan of the binary) runs before any byte is sent.
|
||||
// --pre-check "<cmd with {file}>" (config: upload.preCheck) {file} is replaced by the quoted IPA / AAB path
|
||||
// --evidence <path.json> (config: upload.evidence) what ran, written also when the check fails
|
||||
// A nonzero exit refuses the upload: ShipStepError PRE_CHECK_FAILED (exit 1 under the ship-step contract), nothing
|
||||
// is sent. The evidence ({file, sha256, size, command, exitCode, stdoutTail, timestamp, storeboundVersion}) goes to
|
||||
// --evidence and into the --json / --result-file result as `preCheck`.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { basename, dirname } from 'node:path';
|
||||
import type { Args } from './args';
|
||||
import type { ShipConfig } from './config';
|
||||
import { log } from './log';
|
||||
import { pump } from './runner';
|
||||
import * as rt from './rt';
|
||||
import { SHIP_EXIT, ShipStepError, shipExtra, shipPhase } from './ship-step';
|
||||
|
||||
export interface PreCheckEvidence {
|
||||
file: string; sha256: string; size: number; command: string; exitCode: number;
|
||||
stdoutTail: string[]; timestamp: string; storeboundVersion: string;
|
||||
}
|
||||
|
||||
/** The command with {file} replaced by the POSIX-quoted path (sh -c); a command without {file} gets none. */
|
||||
export function preCheckCommand(template: string, file: string): string {
|
||||
const q = `'${file.replace(/\\/g, '/').replace(/'/g, `'\\''`)}'`;
|
||||
return template.split('{file}').join(q);
|
||||
}
|
||||
|
||||
function sha256File(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); }
|
||||
|
||||
/**
|
||||
* Runs the pre-check for `file` when --pre-check / upload.preCheck is set (else returns null). Throws PRE_CHECK_FAILED
|
||||
* on a nonzero exit, after writing the evidence. `shown` is the file name for the evidence (not the absolute path).
|
||||
*/
|
||||
export async function runPreCheck(o: { args: Args; config: ShipConfig; root: string; resolve: (p: string) => string; file: string; shown: string; tag: string }): Promise<PreCheckEvidence | null> {
|
||||
const flag = o.args.get('pre-check');
|
||||
if (flag === 'true') throw new Error('--pre-check "<command with {file}>" needs a command');
|
||||
const template = (flag ?? o.config.upload?.preCheck)?.trim();
|
||||
const evFlag = o.args.get('evidence');
|
||||
if (evFlag === 'true') throw new Error('--evidence <path.json> needs a path');
|
||||
const evPath = evFlag ?? o.config.upload?.evidence;
|
||||
if (!template) {
|
||||
if (evFlag) throw new Error('--evidence needs --pre-check (or upload.preCheck in the config)');
|
||||
return null;
|
||||
}
|
||||
const command = preCheckCommand(template, o.file);
|
||||
shipPhase('pre-check', command);
|
||||
log(o.tag, `pre-check: $ ${command}`);
|
||||
const p = rt.spawn(['sh', '-c', command], { cwd: o.root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
|
||||
const tail: string[] = [];
|
||||
const each = (line: string, kind: 'stdout' | 'stderr'): void => {
|
||||
if (kind === 'stdout') { tail.push(line); if (tail.length > 20) tail.shift(); }
|
||||
console.error(` ${line}`);
|
||||
};
|
||||
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
|
||||
const exitCode = Number(await p.exited);
|
||||
const version = ((await import('../../package.json')) as { version: string }).version;
|
||||
const ev: PreCheckEvidence = {
|
||||
file: o.shown.split('\\').join('/'), sha256: sha256File(o.file), size: statSync(o.file).size, command, exitCode,
|
||||
stdoutTail: tail, timestamp: new Date().toISOString(), storeboundVersion: version,
|
||||
};
|
||||
if (evPath) {
|
||||
const abs = o.resolve(evPath);
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
writeFileSync(abs, JSON.stringify(ev, null, 2) + '\n');
|
||||
log(o.tag, `pre-check evidence: ${basename(abs)}`);
|
||||
}
|
||||
shipExtra({ preCheck: ev });
|
||||
if (exitCode !== 0) throw new ShipStepError(`pre-check failed (exit ${exitCode}), nothing was uploaded: ${command}${tail.length ? `\n${tail.slice(-5).join('\n')}` : ''}`, SHIP_EXIT.failure, 'PRE_CHECK_FAILED');
|
||||
log(o.tag, 'pre-check: ok');
|
||||
return ev;
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ type Fields = Partial<Pick<ShipResult, 'track' | 'version' | 'build' | 'state' |
|
|||
interface Run {
|
||||
key: string; step: string; store: 'apple' | 'play';
|
||||
progress: boolean;
|
||||
fields: Fields; noop: boolean; warnings: string[]; detail?: unknown; declined?: string;
|
||||
fields: Fields; extra?: Record<string, unknown>; noop: boolean; warnings: string[]; detail?: unknown; declined?: string;
|
||||
}
|
||||
const RUN = Symbol.for('storebound.shipRun');
|
||||
const g = globalThis as unknown as Record<symbol, Run | null | undefined>;
|
||||
|
|
@ -69,6 +69,8 @@ export const shipRun = (): Run | null => g[RUN] ?? null;
|
|||
|
||||
/** What the command did: fields of the final result (later calls overwrite earlier ones). */
|
||||
export function shipReport(f: Fields): void { const r = shipRun(); if (r) Object.assign(r.fields, Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined))); }
|
||||
/** Extra keys of the final result (e.g. the preCheck evidence), kept also when the step fails. */
|
||||
export function shipExtra(x: Record<string, unknown>): void { const r = shipRun(); if (r) r.extra = { ...r.extra, ...x }; }
|
||||
/** Nothing to do (the build is on the store already, the release is there): exit 7 with what is there. */
|
||||
export function shipNoop(f: Fields = {}): void { const r = shipRun(); if (r) { r.noop = true; shipReport(f); } }
|
||||
/** A warning for the result's warnings[] (the log line is the command's own). */
|
||||
|
|
@ -178,7 +180,7 @@ export function buildResult(run: Run, exit: ShipExitCode, error?: ShipErrorInfo)
|
|||
...(f.artifact ? { artifact: f.artifact } : {}),
|
||||
...(error ? { error } : {}),
|
||||
};
|
||||
return { ...out, ...Object.fromEntries(Object.entries(own).filter(([k]) => !(k in out) && !CONTRACT_KEYS.has(k))) } as ShipResult & Record<string, unknown>;
|
||||
return { ...out, ...(run.extra ?? {}), ...Object.fromEntries(Object.entries(own).filter(([k]) => !(k in out) && !CONTRACT_KEYS.has(k))) } as ShipResult & Record<string, unknown>;
|
||||
}
|
||||
const CONTRACT_KEYS = new Set(['ok', 'step', 'store', 'track', 'version', 'build', 'state', 'url', 'warnings', 'artifact', 'error']);
|
||||
|
||||
|
|
|
|||
75
cli/lib/shots-copy-translate.ts
Normal file
75
cli/lib/shots-copy-translate.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// 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}`;
|
||||
Loading…
Add table
Add a link
Reference in a new issue