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:
parent
3cea2af4e1
commit
7ac028421a
33 changed files with 852 additions and 157 deletions
|
|
@ -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' : '');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue