Files
C.E.L_Slide_test2/Front/client/src/services/userOverridesApi.ts
T

244 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (0100) 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();
}