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' : '');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> : {};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue