desktop editors: edit in place what App Store Connect and Play Console let the owner edit (listings per language for both stores with live counters and the check / scan rules, store details, App info, age rating, App Review, Play store settings, Data safety and App Privacy answers, promotion blocks, catalog names / prices / offers, TestFlight information, scene order); storebound edit (project.edit) saves into the config by path keeping comments and formatting, the listing files and the review contact, dry run with the diff and the checks first; Apply shows each store's dry run in the window; unsaved edits survive tab switches with undo / redo; tests/edit.test.ts, apps/desktop/tests/edits.test.ts, boot check editor case

This commit is contained in:
CRTkafa 2026-10-14 00:35:29 +08:00
parent 714352ec18
commit 3cea2af4e1
37 changed files with 3020 additions and 54 deletions

View file

@ -0,0 +1,107 @@
// Unsaved edits of the window's editors, per app: one pending value per field (a config path, a listing field of one
// store and language, an App Review contact field), with undo / redo. Kept for the whole session, outside the tabs,
// so switching tabs or apps never loses them; the tab bar marks apps and tabs with unsaved edits, and closing the
// window asks first (beforeunload). Saving turns them into the ops of `storebound edit` (project.edit). Pure apart
// from the subscriber list (tests/edits.test.ts).
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: 'firstName' | 'lastName' | 'phone' | 'email'; value: string };
/** Which editor (tab) a pending edit belongs to, for the unsaved marks. */
export type EditArea = 'listings' | 'appinfo' | 'catalog' | 'testflight' | 'art';
export interface Pending { key: string; op: EditOp; area: EditArea; /** The saved value, to show "was" and to drop an edit typed back to it. */ saved: unknown }
export const keyOf = (op: EditOp): string =>
op.kind === 'config' ? `config:${op.path}` : op.kind === 'listing' ? `listing:${op.store}:${op.lang}:${op.field}` : `contact:${op.field}`;
const valueOf = (op: EditOp): unknown => (op.kind === 'config' ? (op.remove ? undefined : op.value) : op.value);
const same = (a: unknown, b: unknown): boolean => {
const n = (v: unknown) => (v === undefined || v === null || v === '' ? null : v);
return JSON.stringify(n(a)) === JSON.stringify(n(b));
};
interface ProjectEdits { pending: Map<string, Pending>; undo: Map<string, Pending>[]; redo: Map<string, Pending>[]; lastKey: string | null; lastAt: number }
/** Typing into one field within this time is one undo step. */
export const COALESCE_MS = 1200;
export class EditStore {
private byProject = new Map<string, ProjectEdits>();
private subs = new Set<() => void>();
private version = 0;
constructor(private now: () => number = () => Date.now()) {}
private of(project: string): ProjectEdits {
let p = this.byProject.get(project);
if (!p) { p = { pending: new Map(), undo: [], redo: [], lastKey: null, lastAt: 0 }; this.byProject.set(project, p); }
return p;
}
private emit(): void { this.version++; for (const s of this.subs) s(); }
subscribe = (fn: () => void): (() => void) => { this.subs.add(fn); return () => { this.subs.delete(fn); }; };
snapshot = (): number => this.version;
/** Sets a field; a value equal to the saved one drops the pending edit. */
set(project: string, op: EditOp, area: EditArea, saved: unknown): void {
const p = this.of(project);
const key = keyOf(op);
const t = this.now();
if (!(p.lastKey === key && t - p.lastAt < COALESCE_MS)) { p.undo.push(new Map(p.pending)); if (p.undo.length > 200) p.undo.shift(); }
p.redo = [];
p.lastKey = key; p.lastAt = t;
if (same(valueOf(op), saved)) p.pending.delete(key);
else p.pending.set(key, { key, op, area, saved });
this.emit();
}
/** The pending value of a field, or `undefined` when it has none (use the saved value). */
get(project: string, key: string): Pending | undefined { return this.byProject.get(project)?.pending.get(key); }
list(project: string, area?: EditArea): Pending[] {
const all = [...(this.byProject.get(project)?.pending.values() ?? [])];
return area ? all.filter(p => p.area === area) : all;
}
count(project: string, area?: EditArea): number { return this.list(project, area).length; }
/** Every app with unsaved edits. */
dirtyProjects(): string[] { return [...this.byProject].filter(([, p]) => p.pending.size > 0).map(([k]) => k); }
canUndo(project: string): boolean { return (this.byProject.get(project)?.undo.length ?? 0) > 0; }
canRedo(project: string): boolean { return (this.byProject.get(project)?.redo.length ?? 0) > 0; }
undo(project: string): void {
const p = this.of(project);
const prev = p.undo.pop();
if (!prev) return;
p.redo.push(new Map(p.pending));
p.pending = prev; p.lastKey = null;
this.emit();
}
redo(project: string): void {
const p = this.of(project);
const next = p.redo.pop();
if (!next) return;
p.undo.push(new Map(p.pending));
p.pending = next; p.lastKey = null;
this.emit();
}
/** Drops the pending edits of one area (or all): after a save, or "Discard". Discard can be undone. */
clear(project: string, area?: EditArea, o: { undoable?: boolean } = {}): void {
const p = this.of(project);
if (o.undoable) p.undo.push(new Map(p.pending));
for (const [k, v] of p.pending) if (!area || v.area === area) p.pending.delete(k);
p.lastKey = null;
if (!o.undoable) { p.undo = []; p.redo = []; }
this.emit();
}
/** The ops of `storebound edit` for one area (or all), in a stable order. */
ops(project: string, area?: EditArea): EditOp[] { return this.list(project, area).sort((a, b) => a.key.localeCompare(b.key)).map(p => p.op); }
}
/** The window's one store of unsaved edits. */
export const edits = new EditStore();

View file

@ -15,6 +15,7 @@ export const TTL: Record<string, number> = {
'catalog.diff': 30 * MIN,
'prices.current': 30 * MIN,
'project.listings': 30 * MIN,
'project.editable': 30 * MIN,
'apple.ppo_status': 30 * MIN,
validate: 30 * MIN,
scan: 30 * MIN,
@ -38,7 +39,7 @@ export function isStale(method: string, at: string | null | undefined, now = Dat
export function lanesOf(method: string, o: { ios: boolean; android: boolean }): Lane[] {
if (method.startsWith('apple.')) return ['apple'];
if (method.startsWith('play.')) return ['play'];
if (['project.listings', 'scan', 'analytics.compare', 'aso.audit', 'reviews.drafts', 'project.declarations', 'project.renders', 'project.icon'].includes(method)) return ['local'];
if (['project.listings', 'project.editable', 'scan', 'analytics.compare', 'aso.audit', 'reviews.drafts', 'project.declarations', 'project.renders', 'project.icon'].includes(method)) return ['local'];
const l: Lane[] = [];
if (o.ios) l.push('apple');
if (o.android) l.push('play');