244 lines
8.6 KiB
TypeScript
244 lines
8.6 KiB
TypeScript
// 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<UserOverrides>` 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<string, string>;
|
||
|
||
/** zone_id → 0-1 normalized geometry inside slide-body. */
|
||
export type ZoneGeometryOverride = {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
};
|
||
export type ZoneGeometriesOverride = Record<string, ZoneGeometryOverride>;
|
||
|
||
/** zone_id → ordered list of section_ids assigned to that zone. */
|
||
export type ZoneSectionsOverride = Record<string, string[]>;
|
||
|
||
/**
|
||
* 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<string, ImageOverride>;
|
||
|
||
/** 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<typeof setTimeout> | null;
|
||
waiters: Array<{
|
||
resolve: (merged: Partial<UserOverrides>) => void;
|
||
reject: (err: unknown) => void;
|
||
}>;
|
||
};
|
||
|
||
const buckets = new Map<string, PendingBucket>();
|
||
|
||
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<Partial<UserOverrides>> {
|
||
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<UserOverrides>;
|
||
}
|
||
|
||
// ── PUT (debounced) ─────────────────────────────────────────────────────────
|
||
|
||
async function flushBucket(
|
||
key: string,
|
||
bucket: PendingBucket,
|
||
): Promise<void> {
|
||
const payload = bucket.partial;
|
||
const waiters = bucket.waiters;
|
||
bucket.partial = {};
|
||
bucket.timer = null;
|
||
bucket.waiters = [];
|
||
|
||
let merged: Partial<UserOverrides> = {};
|
||
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<UserOverrides>;
|
||
}
|
||
} 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<Partial<UserOverrides>> {
|
||
const bucket = getBucket(key);
|
||
// Per-axis coalescing — later mutations replace earlier pending values.
|
||
for (const axis of Object.keys(partial) as Array<keyof UserOverridesPartial>) {
|
||
bucket.partial[axis] = partial[axis] as never;
|
||
}
|
||
const p = new Promise<Partial<UserOverrides>>((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<void> {
|
||
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();
|
||
}
|