// 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) // - 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'; 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 } /** 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']; 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 }; shotsCopy?: { file: string; rel: string; text: string | null } | 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; 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 === '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, shotsCopy 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); 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 } { const texts = new Map(); const before = new Map(); const rel = new Map(); 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(), apple: new Set() }; const onlyWhatToTest = new Map(); let contact: Record | 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 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>>)[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 : {}; const rc = { ...((contact.reviewContact ?? {}) as Record) }; 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)[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)[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; }