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

@ -152,8 +152,21 @@ function step(t: string): string {
return m ? (m[1]!.startsWith('\t') ? '\t' : m[1]!.length >= 4 && m[1]!.length % 4 === 0 && !/\n {2}\S/.test(t) ? ' ' : ' ') : ' ';
}
/** JSON files are edited as text too (their own formatting, e.g. one scene per line in the shots copy file, stays):
* values as JSON, keys in double quotes, no trailing commas. Set for the duration of one setByPath call. */
let JSON_MODE = false;
/** A JSON literal: one line when short, else indented like the line it sits on. */
function jsonLit(v: unknown, ind: string): string {
const one = JSON.stringify(v);
if (one.length <= 100 || typeof v !== 'object' || v === null) return one;
return JSON.stringify(v, null, 2).split('\n').map((l, i) => (i ? ind + l : l)).join('\n');
}
const lit = (v: unknown, ind: string): string => (JSON_MODE ? jsonLit(v, ind) : toTs(v, ind));
/** A literal for `v` in the style of the value it replaces (the quote character of an old string). */
function literal(v: unknown, ind: string, old?: string): string {
if (JSON_MODE) return jsonLit(v, ind);
if (typeof v === 'string' && old && (old[0] === '"' || old[0] === '`') && !v.includes('\n')) {
if (old[0] === '"') return JSON.stringify(v);
if (!/[`$\\]/.test(v)) return `\`${v}\``;
@ -167,8 +180,13 @@ const plain = (src: string): boolean => /^(['"`]|-?\d|true\b|false\b|null\b|unde
/** The config text with the value at `path` set (undefined: removed). */
export function setByPath(text: string, file: string, path: string | PathPart[], value: unknown): string {
const parts = typeof path === 'string' ? parsePath(path) : path;
if (file.endsWith('.json')) return setJson(text, parts, value);
let open = rootObject(text);
JSON_MODE = file.endsWith('.json');
try { return setInText(text, parts, value); } finally { JSON_MODE = false; }
}
function setInText(text: string, parts: PathPart[], value: unknown): string {
let open = JSON_MODE ? text.search(/\S/) : rootObject(text);
if (JSON_MODE && text[open] !== '{') throw new Error('the JSON file is not an object');
for (let n = 0; n < parts.length; n++) {
const part = parts[n]!;
const last = n === parts.length - 1;
@ -235,26 +253,27 @@ function evalString(s: string): string {
}
function insertMember(t: string, open: number, close: number, list: Member[], key: string, value: unknown): string {
const k = /^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key.replace(/'/g, '\\\'')}'`;
const k = JSON_MODE ? JSON.stringify(key) : /^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key.replace(/'/g, '\\\'')}'`;
const end = JSON_MODE ? '' : ',';
const body = t.slice(open + 1, close);
if (!body.includes('\n')) {
const lit = toTs(value, lineIndent(t, open));
const l1 = lit(value, lineIndent(t, open));
const inner = body.trim().replace(/,$/, '');
return `${t.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${k}: ${lit} ${t.slice(close)}`;
return `${t.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${k}: ${l1} ${t.slice(close)}`;
}
const lastM = list.at(-1);
const ind = lastM ? lineIndent(t, lastM.start) : lineIndent(t, open) + step(t);
const lit = toTs(value, ind);
const l2 = lit(value, ind);
if (lastM) {
const needComma = t.slice(lastM.valueEnd, lastM.end).indexOf(',') < 0;
// after the last member's comma and any comment on the same line
let at = lastM.end;
let at = needComma ? lastM.valueEnd : lastM.end;
const nl = t.indexOf('\n', at);
if (nl >= 0 && nl < close && /^[ \t]*(\/\/[^\n]*)?$/.test(t.slice(at, nl))) at = nl;
const pre = needComma ? t.slice(0, lastM.valueEnd) + ',' + t.slice(lastM.valueEnd, at) : t.slice(0, at);
return `${pre}\n${ind}${k}: ${lit},${t.slice(at)}`;
return `${pre}\n${ind}${k}: ${l2}${end}${t.slice(at)}`;
}
return `${t.slice(0, open + 1)}\n${ind}${k}: ${lit},${t.slice(open + 1)}`;
return `${t.slice(0, open + 1)}\n${ind}${k}: ${l2}${end}${t.slice(open + 1)}`;
}
function removeMember(t: string, m: Member, list: Member[], open: number, close: number): string {
@ -266,32 +285,14 @@ function removeMember(t: string, m: Member, list: Member[], open: number, close:
const start = ownLine ? ls : m.start;
let out = t.slice(0, start) + t.slice(end);
if (list.length === 1 && !t.slice(open, close).includes('\n')) out = t.slice(0, open + 1) + t.slice(close);
else if (JSON_MODE && m === list.at(-1) && list.length > 1) {
// JSON has no trailing comma: drop the one after the member that is now the last
const prev = list[list.length - 2]!;
const comma = t.indexOf(',', prev.valueEnd);
if (comma >= 0 && comma < prev.end) out = out.slice(0, comma) + out.slice(comma + 1);
}
else if (m === list.at(-1) && t.slice(m.valueEnd, m.end).indexOf(',') < 0 && list.length > 1) {
// the previous member keeps a trailing comma: valid in TS and JS
}
return out;
}
function setJson(text: string, parts: PathPart[], value: unknown): string {
const root = JSON.parse(text) as Record<string, unknown>;
let cur: unknown = root;
for (let n = 0; n < parts.length; n++) {
const p = parts[n]!;
const last = n === parts.length - 1;
if ('key' in p) {
if (typeof cur !== 'object' || cur === null || Array.isArray(cur)) throw new Error(`${pathText(parts.slice(0, n))} is not an object`);
const o = cur as Record<string, unknown>;
if (last) { if (value === undefined) delete o[p.key]; else o[p.key] = value; break; }
if (o[p.key] === undefined) { if (value === undefined) return text; o[p.key] = {}; }
cur = o[p.key];
} else {
if (!Array.isArray(cur)) throw new Error(`${pathText(parts.slice(0, n))} is not a list`);
const i = 'index' in p ? p.index : cur.findIndex(x => x && typeof x === 'object' && (x as Record<string, unknown>)[p.match] === p.eq);
if (i < 0 || i >= cur.length) throw new Error(`${pathText(parts.slice(0, n + 1))}: no such element in the config`);
if (last) { if (value === undefined) cur.splice(i, 1); else cur[i] = value; break; }
cur = cur[i];
}
}
const ind = /\n([ \t]+)"/.exec(text)?.[1] ?? ' ';
return JSON.stringify(root, null, ind) + (text.endsWith('\n') ? '\n' : '');
}

View file

@ -420,7 +420,9 @@ export interface EditableListing { lang: string; language: string; status: strin
/** project.editable (`edit --show --json`): what the desktop's editors show and change. */
export interface EditableResult {
root: string;
files: { config: string; play: string | null; apple: string | null; contact: string };
files: { config: string; play: string | null; apple: string | null; contact: string; shotsCopy?: string | null };
/** The screenshot captions (shots copy file): language -> scene -> eyebrow, title (two lines), sub. */
shotsCopy?: Record<string, Record<string, { eyebrow?: string; title?: string[]; sub?: string } & Record<string, unknown>>> | null;
format: 'ts' | 'js' | 'json';
/** The app folder is a git work tree; which of the editable files have uncommitted changes. storebound never commits. */
git: { repo: boolean; uncommitted: string[] };
@ -435,6 +437,8 @@ export interface EditableResult {
products: Record<string, unknown>[]; subscriptions: Record<string, unknown>[];
dataSafety: Record<string, unknown> | null; appPrivacy: Record<string, unknown> | null; reviewAccess: Record<string, unknown> | null;
accounts: Record<string, unknown> | null; social: Record<string, unknown> | null;
/** Console form answers (Play content rating, target audience, App content declarations; the App Store record). */
console?: Record<string, unknown> | null;
/** Screenshot scenes in store order and the feature graphic (shots). */
shots: { scenes: { id: string; frames?: { src: string }[] }[]; feature: Record<string, unknown> | null } | null;
};

View file

@ -58,12 +58,16 @@ export function editableValues(l: Loaded): EditableResult {
apple.push({ lang: lang.apple, language: lang.id, status: s?.status ?? null, exists: !!s, values: s?.values ?? {} });
}
}
const files = [path, ...(playFile ? [playFile] : []), ...(appleFile ? [appleFile] : [])];
let shotsCopy: EditableResult['shotsCopy'] = null;
const copyFile = config.shots?.copy ? resolve(config.shots.copy) : null;
try { if (copyFile && existsSync(copyFile)) shotsCopy = JSON.parse(readFileSync(copyFile, 'utf8')) as EditableResult['shotsCopy']; } catch { /* broken JSON: shots check names it */ }
const files = [path, ...(copyFile ? [copyFile] : []), ...(playFile ? [playFile] : []), ...(appleFile ? [appleFile] : [])];
const { signing: _s, build: _b, adhoc: _a, ...appleRest } = config.apple ?? ({} as NonNullable<typeof config.apple>);
const { build: _pb, ...playRest } = config.play ?? ({} as NonNullable<typeof config.play>);
return {
root,
files: { config: rel(path), play: playFile ? rel(playFile) : null, apple: appleFile ? rel(appleFile) : null, contact: rel(contactFile) },
files: { config: rel(path), play: playFile ? rel(playFile) : null, apple: appleFile ? rel(appleFile) : null, contact: rel(contactFile), shotsCopy: copyFile ? rel(copyFile) : null },
shotsCopy,
format: path.endsWith('.json') ? 'json' : path.endsWith('.js') ? 'js' : 'ts',
git: gitState(root, files),
appName: config.app.name,
@ -74,6 +78,7 @@ export function editableValues(l: Loaded): EditableResult {
config: JSON.parse(JSON.stringify({
app: config.app, apple: config.apple ? appleRest : null, play: config.play ? playRest : null, products: config.products ?? [], subscriptions: config.subscriptions ?? [],
dataSafety: config.dataSafety ?? null, appPrivacy: config.appPrivacy ?? null, reviewAccess: config.reviewAccess ?? null, accounts: config.accounts ?? null, social: config.social ?? null,
console: config.console ?? null,
shots: config.shots ? { scenes: config.shots.scenes, feature: config.shots.feature ?? null } : null,
})) as EditableResult['config'],
contact,

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> : {};