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:
parent
714352ec18
commit
3cea2af4e1
37 changed files with 3020 additions and 54 deletions
107
apps/desktop/src/lib/edits.ts
Normal file
107
apps/desktop/src/lib/edits.ts
Normal 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();
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue