store-only apps edit their listing on the store (storebound listing live / set: old -> new per field with the checks, a PATCH of only the edited fields; serve listing.set is the one store-only write, its token bound to the store ids; policy.rs STORE_ONLY_WRITES, write_with_confirm for store targets); screenshot captions per language and scene edited as a text edit of the shots copy JSON (JSON files keep their layout); Play content rating, target audience and App content answers in App info; docs/console-parity.md after the editors (73 rows: 55 complete, 18 with the reason) and tests/console-parity.test.ts keeping it honest

This commit is contained in:
CRTkafa 2026-10-14 00:53:38 +08:00
parent 3cea2af4e1
commit 7ac028421a
33 changed files with 852 additions and 157 deletions

View file

@ -3,6 +3,7 @@
// pass. Sources:
// - storebound.config.ts / .js / .json: one value by path (config-path.ts), the rest of the file unchanged
// - store/listing.md (Google Play) and store/appstore.md (App Store): one field block (listing-edit.ts)
// - the shots copy file (shots.copy, JSON): screenshot captions per language and scene, as a text edit too
// - .storebound/credentials.json reviewContact (App Review contact: personal data, never in the config or a log line)
// The plan itself is pure (texts in, texts out); cli/edit.ts reads and writes the files.
import { compactDiff } from './diff';
@ -14,7 +15,9 @@ import { appleLocaleIssues, emailIssue, type FieldIssue, playLangIssues, urlIssu
export type EditOp =
| { kind: 'config'; path: string; value?: unknown; remove?: boolean }
| { kind: 'listing'; store: 'play' | 'apple'; lang: string; field: string; value: string }
| { kind: 'contact'; field: ContactField; value: string };
| { kind: 'contact'; field: ContactField; value: string }
/** A screenshot caption in the shots copy file (shots.copy): eyebrow, title line 1 / 2 (title[0], title[1]) or sub. */
| { kind: 'shotsCopy'; lang: string; scene: string; field: 'eyebrow' | 'title' | 'sub'; line?: 0 | 1; value: string };
export type ContactField = 'firstName' | 'lastName' | 'phone' | 'email';
export const CONTACT_FIELDS: readonly ContactField[] = ['firstName', 'lastName', 'phone', 'email'];
@ -29,6 +32,7 @@ export interface EditSources {
play: { file: string; rel: string; text: string | null } | null;
apple: { file: string; rel: string; text: string | null } | null;
contact: { file: string; rel: string; text: string | null };
shotsCopy?: { file: string; rel: string; text: string | null } | null;
appName: string;
appleName: string;
}
@ -58,12 +62,20 @@ export function checkOp(o: unknown, i: number): EditOp {
if (e.store === 'play' ? !isPlayField(field) : !isAppleField(field)) throw new Error(`edit ${i + 1}: ${String(e.store)} has no field ${field}`);
return { kind: 'listing', store: e.store, lang, field, value: str('value', 20_000) };
}
if (e.kind === 'shotsCopy') {
const lang = str('lang', 40), scene = str('scene', 60);
if (!/^[A-Za-z0-9_-]+$/.test(lang) || !/^[A-Za-z0-9_-]+$/.test(scene)) throw new Error(`edit ${i + 1}: lang and scene are ids`);
if (e.field !== 'eyebrow' && e.field !== 'title' && e.field !== 'sub') throw new Error(`edit ${i + 1}: caption field eyebrow, title or sub`);
if (e.line !== undefined && e.line !== 0 && e.line !== 1) throw new Error(`edit ${i + 1}: line 0 or 1`);
if (e.field === 'title' && e.line === undefined) throw new Error(`edit ${i + 1}: the title needs line 0 or 1`);
return { kind: 'shotsCopy', lang, scene, field: e.field, ...(e.field === 'title' ? { line: e.line as 0 | 1 } : {}), value: str('value', 400) };
}
if (e.kind === 'contact') {
const field = str('field', 20) as ContactField;
if (!CONTACT_FIELDS.includes(field)) throw new Error(`edit ${i + 1}: contact field ${field}`);
return { kind: 'contact', field, value: str('value', 200) };
}
throw new Error(`edit ${i + 1}: kind config, listing or contact`);
throw new Error(`edit ${i + 1}: kind config, listing, shotsCopy or contact`);
}
/** Plain JSON data only (no functions), not too deep or big. */
@ -127,6 +139,23 @@ export function planEdits(ops: EditOp[], src: EditSources, getPath: (path: strin
changes.push({ what: `${op.store === 'play' ? 'Google Play' : 'App Store'} ${op.lang} ${op.field}`, file: f.rel, from: r.from, to: op.value.trim() || null, ...(r.codes.length > 1 ? { also: r.codes.filter(c => c !== op.lang) } : {}) });
touched[op.store].add(op.lang);
if (op.store === 'apple') onlyWhatToTest.set(op.lang, (onlyWhatToTest.get(op.lang) ?? true) && op.field === 'whatToTest');
} else if (op.kind === 'shotsCopy') {
if (!src.shotsCopy) throw new Error('this app has no shots copy file (shots.copy in the config)');
const t = read(src.shotsCopy) || '{}\n';
let cur: unknown;
try { cur = JSON.parse(t); } catch (e) { throw new Error(`${src.shotsCopy.rel}: not JSON (${(e as Error).message})`); }
const scene = ((cur as Record<string, Record<string, Record<string, unknown>>>)[op.lang] ?? {})[op.scene] ?? {};
const title = Array.isArray(scene.title) ? [...(scene.title as string[])] : [];
const from = op.field === 'title' ? title[op.line!] ?? null : (scene[op.field] as string | undefined) ?? null;
let next: string;
if (op.field === 'title') {
// the title is two lines: set one, keep the other
while (title.length < 2) title.push('');
title[op.line!] = op.value;
next = setByPath(t, src.shotsCopy.file, [{ key: op.lang }, { key: op.scene }, { key: 'title' }], title);
} else next = setByPath(t, src.shotsCopy.file, [{ key: op.lang }, { key: op.scene }, { key: op.field }], op.value);
texts.set(src.shotsCopy.file, next);
changes.push({ what: `screenshot ${op.lang} ${op.scene} ${op.field}${op.field === 'title' ? ` line ${op.line! + 1}` : ''}`, file: src.shotsCopy.rel, from, to: op.value || null });
} else {
read(src.contact);
contact ??= src.contact.text ? JSON.parse(src.contact.text) as Record<string, unknown> : {};