storebound/cli/lib/edit.ts

188 lines
12 KiB
TypeScript

// The desktop's editors write through here (`storebound edit`, serve method project.edit): a list of edits of the
// app folder's sources of truth, planned as text edits, checked, shown as a diff, and written only when the checks
// 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)
// - .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';
import { setByPath } from './config-path';
import type { AppleField } from './copy';
import { appleSection, isAppleField, isPlayField, playSection, setAppleField, setPlayField } from './listing-edit';
import { appleLocaleIssues, emailIssue, type FieldIssue, playLangIssues, urlIssue } from './listing-rules';
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 };
export type ContactField = 'firstName' | 'lastName' | 'phone' | 'email';
export const CONTACT_FIELDS: readonly ContactField[] = ['firstName', 'lastName', 'phone', 'email'];
export interface EditChange { what: string; file: string; from: string | null; to: string | null; also?: string[] }
export interface EditIssue extends FieldIssue { where: string }
export interface EditFile { file: string; rel: string; created: boolean; diff: string[]; masked?: boolean }
export interface EditPlan { changes: EditChange[]; files: EditFile[]; issues: EditIssue[]; errors: number }
/** What the plan needs to know about the folder: the files by role and their current text. */
export interface EditSources {
config: { file: string; rel: string; text: string };
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 };
appName: string;
appleName: string;
}
/** Checks one edit's shape (it comes from the window or a terminal). Throws with the reason. */
export function checkOp(o: unknown, i: number): EditOp {
if (!o || typeof o !== 'object' || Array.isArray(o)) throw new Error(`edit ${i + 1}: an object`);
const e = o as Record<string, unknown>;
const str = (k: string, max: number): string => {
if (typeof e[k] !== 'string' || (e[k] as string).length > max) throw new Error(`edit ${i + 1}: ${k} must be text (at most ${max} characters)`);
return e[k] as string;
};
if (e.kind === 'config') {
const path = str('path', 300);
if (!/^[A-Za-z_$][\w$-]*(\.[A-Za-z0-9_$-]+|\[\d+\]|\[[A-Za-z_$][\w$]*=[^\]]{1,200}\])*$/.test(path)) throw new Error(`edit ${i + 1}: path ${path} is not a config path`);
if (/^(account|scan)\b/.test(path)) throw new Error(`edit ${i + 1}: ${path} is not edited from here`);
if (e.remove !== undefined && typeof e.remove !== 'boolean') throw new Error(`edit ${i + 1}: remove is true or false`);
if (e.value === undefined && e.remove !== true) throw new Error(`edit ${i + 1}: a value (or remove: true)`);
checkValue(e.value, `edit ${i + 1}`, 0);
return { kind: 'config', path, ...(e.remove === true ? { remove: true } : { value: e.value }) };
}
if (e.kind === 'listing') {
if (e.store !== 'play' && e.store !== 'apple') throw new Error(`edit ${i + 1}: store play or apple`);
const lang = str('lang', 20);
if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) throw new Error(`edit ${i + 1}: ${lang} is not a language code`);
const field = str('field', 30);
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 === '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`);
}
/** Plain JSON data only (no functions), not too deep or big. */
function checkValue(v: unknown, at: string, depth: number): void {
if (depth > 8) throw new Error(`${at}: value nested too deep`);
if (v === null || v === undefined || typeof v === 'boolean' || typeof v === 'string') return;
if (typeof v === 'number') { if (!Number.isFinite(v)) throw new Error(`${at}: a finite number`); return; }
if (Array.isArray(v)) { if (v.length > 500) throw new Error(`${at}: list too long`); for (const x of v) checkValue(x, at, depth + 1); return; }
if (typeof v === 'object') { const e = Object.entries(v as Record<string, unknown>); if (e.length > 300) throw new Error(`${at}: too many keys`); for (const [, x] of e) checkValue(x, at, depth + 1); return; }
throw new Error(`${at}: plain data only`);
}
export const parseOps = (json: string): EditOp[] => {
let raw: unknown;
try { raw = JSON.parse(json); } catch (e) { throw new Error(`--ops: not JSON (${(e as Error).message})`); }
const list = Array.isArray(raw) ? raw : [raw];
if (!list.length) throw new Error('--ops: no edits');
if (list.length > 400) throw new Error('--ops: at most 400 edits at once');
return list.map(checkOp);
};
const show = (v: unknown): string | null => (v === undefined || v === null ? null : typeof v === 'string' ? v : JSON.stringify(v));
const maskValue = (field: ContactField, v: string): string => (!v ? '' : field === 'email' ? `${v[0]}***${v.slice(v.indexOf('@'))}` : field === 'phone' ? `${v.slice(0, 3)}***` : `${v[0]}***`);
/** Values the config paths of the editors hold: URL and email fields get their format checked. */
const URL_PATHS = /^(app\.(privacyPolicyUrl|termsUrl|supportUrl|marketingUrl|website)|accounts\.deletionUrl|dataSafety\.partialDeletionUrl|play\.video)$/;
const EMAIL_PATHS = /^app\.contactEmail$/;
/**
* The plan of a list of edits: new file texts, what changes (old -> new), the diff per file and the checks. Pure.
* `getPath` reads a value of the loaded config by path (for the "from" side of config edits).
*/
export function planEdits(ops: EditOp[], src: EditSources, getPath: (path: string) => unknown): { plan: EditPlan; texts: Map<string, string> } {
const texts = new Map<string, string>();
const before = new Map<string, string | null>();
const rel = new Map<string, string>();
const read = (f: { file: string; rel: string; text: string | null }): string => {
if (!texts.has(f.file)) { texts.set(f.file, f.text ?? ''); before.set(f.file, f.text); rel.set(f.file, f.rel); }
return texts.get(f.file)!;
};
const changes: EditChange[] = [];
const issues: EditIssue[] = [];
const touched = { play: new Set<string>(), apple: new Set<string>() };
const onlyWhatToTest = new Map<string, boolean>();
let contact: Record<string, unknown> | null = null;
for (const op of ops) {
if (op.kind === 'config') {
const t = read(src.config);
const value = op.remove ? undefined : op.value;
const next = setByPath(t, src.config.file, op.path, value);
texts.set(src.config.file, next);
changes.push({ what: op.path, file: src.config.rel, from: show(getPath(op.path)), to: show(value) });
if (typeof value === 'string' && URL_PATHS.test(op.path)) { const u = urlIssue(value); if (u) issues.push({ level: 'error', field: op.path, where: op.path, msg: u }); }
if (typeof value === 'string' && EMAIL_PATHS.test(op.path)) { const u = emailIssue(value); if (u) issues.push({ level: 'error', field: op.path, where: op.path, msg: u }); }
} else if (op.kind === 'listing') {
const f = op.store === 'play' ? src.play : src.apple;
if (!f) throw new Error(`this app has no ${op.store === 'play' ? 'Google Play' : 'App Store'} section in the config`);
const t = read(f);
const r = op.store === 'play' ? setPlayField(t, op.lang, op.field as never, op.value) : setAppleField(t, op.lang, op.field as AppleField | 'whatToTest', op.value);
texts.set(f.file, r.md);
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 {
read(src.contact);
contact ??= src.contact.text ? JSON.parse(src.contact.text) as Record<string, unknown> : {};
const rc = { ...((contact.reviewContact ?? {}) as Record<string, string>) };
const from = rc[op.field] ?? '';
rc[op.field] = op.value.trim();
contact.reviewContact = rc;
if (op.field === 'email') { const u = emailIssue(op.value.trim()); if (u) issues.push({ level: 'error', field: 'email', where: 'App Review contact', msg: u }); }
texts.set(src.contact.file, JSON.stringify(contact, null, 2) + '\n');
changes.push({ what: `App Review contact ${op.field}`, file: src.contact.rel, from: from ? maskValue(op.field, from) : null, to: op.value.trim() ? maskValue(op.field, op.value.trim()) : null });
}
}
// the touched languages as they would be saved, with the checks of `check` / `check --aso` / `scan`
if (src.play) for (const lang of touched.play) {
const s = playSection(texts.get(src.play.file) ?? '', lang);
if (s) for (const x of playLangIssues(lang, s.values, src.appName)) issues.push({ ...x, where: `Google Play ${lang}` });
}
if (src.apple) for (const loc of touched.apple) {
const s = appleSection(texts.get(src.apple.file) ?? '', loc);
if (s && onlyWhatToTest.get(loc)) { const n = [...(s.values.whatToTest ?? '')].length; if (n > 4000) issues.push({ level: 'error', field: 'whatToTest', where: `TestFlight ${loc}`, msg: `What to Test is ${n} characters; TestFlight takes 4000` }); continue; }
if (s) for (const x of appleLocaleIssues(loc, s.values, src.appleName)) issues.push({ ...x, where: `App Store ${loc}` });
}
const files: EditFile[] = [];
for (const [file, after] of texts) {
const b = before.get(file) ?? null;
if (b === after) continue;
const masked = file === src.contact.file;
files.push({ file, rel: rel.get(file)!, created: b === null, diff: masked ? [] : compactDiff(b ?? '', after) , ...(masked ? { masked: true } : {}) });
}
for (const [file] of [...texts]) if (!files.some(f => f.file === file)) texts.delete(file);
return { plan: { changes, files, issues, errors: issues.filter(i => i.level === 'error').length }, texts };
}
/** A value of a plain object by config path (`products[id=x].listings.en.title`); undefined when missing. */
export function getByPath(root: unknown, path: string): unknown {
let cur: unknown = root;
const re = /([A-Za-z0-9_$-]+)|\[(\d+)\]|\[([A-Za-z_$][\w$]*)=([^\]]+)\]/g;
for (let m = re.exec(path); m; m = re.exec(path)) {
if (cur === null || cur === undefined) return undefined;
if (m[1] !== undefined) cur = (cur as Record<string, unknown>)[m[1]];
else if (m[2] !== undefined) cur = Array.isArray(cur) ? cur[Number(m[2])] : undefined;
else cur = Array.isArray(cur) ? cur.find(x => x && typeof x === 'object' && (x as Record<string, unknown>)[m![3]!] === m![4]) : undefined;
}
return cur;
}
/** Text lines of a plan for a terminal and the confirm dialog (personal data masked). */
export function planLines(p: EditPlan): string[] {
const cut = (s: string | null): string => (s === null ? '(none)' : s.length > 70 ? `"${s.slice(0, 67).replace(/\n/g, ' ')}..."` : `"${s.replace(/\n/g, ' ')}"`);
const out: string[] = [];
for (const c of p.changes) out.push(`${c.what}: ${cut(c.from)} -> ${cut(c.to)}${c.also?.length ? ` (the same section also serves ${c.also.join(', ')})` : ''}`);
for (const i of p.issues) out.push(`${i.level === 'error' ? 'ERROR' : i.level === 'warn' ? 'warn ' : 'hint '} [${i.where}] ${i.msg}`);
for (const f of p.files) {
out.push(`--- ${f.rel}${f.created ? ' (new file)' : ''}`);
if (f.masked) out.push(' (personal data: not shown)');
else out.push(...f.diff.map(l => ` ${l}`));
}
return out;
}