// IMP-52 u5 — typed frontend client for `/api/user-overrides/:key` (GET + PUT). // // The on-disk schema (KNOWN_AXES) and endpoint contract are owned by: // • src/user_overrides_io.py (Python — backend pipeline fallback, u1/u2) // • Front/vite.config.ts (handleGet/PutUserOverrides, u3/u4) // This module is the typed view used by Home.tsx restore-on-reopen (u6) and // the four mutation handlers (u7). It does NOT own the schema — any change // to KNOWN_AXES must land in u1/u4 first, then reflect here. // // IMP-51 (#79) u3 — added `image_overrides` (5th axis). `image_id` → percent- // of-slide {x,y,w,h}. Mirrors src/user_overrides_io.py KNOWN_AXES (u1) and // Front/vite.config.ts KNOWN_USER_OVERRIDES_AXES (u2). Backend stamper + // render-time CSS injection ride on u4~u7; the SlideCanvas drag/resize // handles that drive this axis ride on u8~u11. // // Contract (Stage 2 unit u5 summary): // • Typed `getUserOverrides(key)` → returns `Partial` from // the GET endpoint. Missing / corrupt / non-object payloads degrade to // `{}` so the frontend reopen flow never crashes on a fresh MDX. // • Typed `saveUserOverrides(key, partial)` → schedules a 300ms-debounced // PUT carrying ONLY the axes the user has mutated since the last flush. // Per-axis coalescing: a later call overwrites the same axis in the // pending payload; axes the user did not mutate are NOT sent (the // server-side merge in u4 preserves them on disk). // • Per-key debounce buckets — rapid edits to MDX "03" do not delay the // flush for MDX "04". // • Explicit clear sentinel: `partial[axis] = null` forwards to the PUT // body verbatim so u4 `mergeUserOverrides` can `delete` the axis on disk. // • `flushUserOverrides()` / `flushUserOverrides(key)` force an immediate // PUT (used by tests + Home.tsx Generate flow to ensure outstanding // writes commit before pipeline run). const ENDPOINT_BASE = "/api/user-overrides"; const DEBOUNCE_MS = 300; // ── Schema (mirror of backend KNOWN_AXES; see header comment) ─────────────── /** unit_id → template_id. unit_id = source_section_ids joined by "+". */ export type FramesOverride = Record; /** zone_id → 0-1 normalized geometry inside slide-body. */ export type ZoneGeometryOverride = { x: number; y: number; w: number; h: number; }; export type ZoneGeometriesOverride = Record; /** zone_id → ordered list of section_ids assigned to that zone. */ export type ZoneSectionsOverride = Record; /** * IMP-51 #79 u3 — image_id → percent-of-slide geometry. Matches the user- * content image selector `.slide img[data-image-role="user-content"]` * (stamper in u4) and the render-time CSS injection map (u7). Coordinates * are slide-absolute percent (0–100) so SlideCanvas drag handles (u8~u11) * map 1:1 with the persisted axis without per-zone transforms. */ export type ImageOverride = { x: number; y: number; w: number; h: number; }; export type ImageOverridesOverride = Record; /** Full on-disk schema. All axes optional — file may carry any subset. */ export interface UserOverrides { layout: string; frames: FramesOverride; zone_geometries: ZoneGeometriesOverride; zone_sections: ZoneSectionsOverride; image_overrides: ImageOverridesOverride; } /** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */ export type UserOverridesPartial = { [K in keyof UserOverrides]?: UserOverrides[K] | null; }; // ── Per-key debounce buckets ──────────────────────────────────────────────── type PendingBucket = { partial: UserOverridesPartial; timer: ReturnType | null; waiters: Array<{ resolve: (merged: Partial) => void; reject: (err: unknown) => void; }>; }; const buckets = new Map(); function getBucket(key: string): PendingBucket { let b = buckets.get(key); if (!b) { b = { partial: {}, timer: null, waiters: [] }; buckets.set(key, b); } return b; } // ── GET ───────────────────────────────────────────────────────────────────── /** * Fetch the persisted user_overrides for `key` (MDX stem). Returns `{}` on * any failure mode (network error, 4xx/5xx, non-object body) so the caller * can use it unconditionally during MDX reopen without branching on * error paths. */ export async function getUserOverrides( key: string, ): Promise> { let res: Response; try { res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, { method: "GET", headers: { Accept: "application/json" }, }); } catch { return {}; } if (!res.ok) return {}; let parsed: unknown; try { parsed = await res.json(); } catch { return {}; } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { return {}; } return parsed as Partial; } // ── PUT (debounced) ───────────────────────────────────────────────────────── async function flushBucket( key: string, bucket: PendingBucket, ): Promise { const payload = bucket.partial; const waiters = bucket.waiters; bucket.partial = {}; bucket.timer = null; bucket.waiters = []; let merged: Partial = {}; try { const res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (res.ok) { try { const parsed = (await res.json()) as unknown; if ( typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ) { merged = parsed as Partial; } } catch { // server returned 200 with non-JSON body → treat as empty merged } } else { const err = new Error(`PUT ${ENDPOINT_BASE}/${key} → ${res.status}`); waiters.forEach((w) => w.reject(err)); return; } } catch (err) { waiters.forEach((w) => w.reject(err)); return; } waiters.forEach((w) => w.resolve(merged)); } /** * Schedule a debounced PUT to persist the mutated axes. Resolves with the * server-side merged document when the debounced PUT eventually fires. * Multiple rapid calls for the same `key` coalesce into a single PUT; * a later call's value for a given axis overrides an earlier pending value. * Calls for different `key`s are isolated. */ export function saveUserOverrides( key: string, partial: UserOverridesPartial, ): Promise> { const bucket = getBucket(key); // Per-axis coalescing — later mutations replace earlier pending values. for (const axis of Object.keys(partial) as Array) { bucket.partial[axis] = partial[axis] as never; } const p = new Promise>((resolve, reject) => { bucket.waiters.push({ resolve, reject }); }); if (bucket.timer !== null) clearTimeout(bucket.timer); bucket.timer = setTimeout(() => { void flushBucket(key, bucket); }, DEBOUNCE_MS); return p; } /** * Force-flush pending debounced writes. With no arg, flushes ALL pending * keys (used before pipeline runs so the backend reads the latest file). * With a key, flushes only that key's bucket. * * Resolves after every flushed bucket's PUT completes. Per-bucket errors * are swallowed at the flush level — the original caller's * saveUserOverrides() promise still rejects to its owner via the waiter. */ export async function flushUserOverrides(key?: string): Promise { const targets: Array<[string, PendingBucket]> = []; if (key !== undefined) { const b = buckets.get(key); if (b && b.timer !== null) targets.push([key, b]); } else { buckets.forEach((b, k) => { if (b.timer !== null) targets.push([k, b]); }); } const flushPromises = targets.map(([k, b]) => { if (b.timer !== null) { clearTimeout(b.timer); b.timer = null; } return flushBucket(k, b); }); await Promise.all(flushPromises); } /** Test-only — clears all pending buckets without firing PUTs. */ export function __resetUserOverridesBuckets_FOR_TEST(): void { buckets.forEach((b) => { if (b.timer !== null) clearTimeout(b.timer); }); buckets.clear(); }