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' : '');
}