feat(#80): IMP-52 user_overrides.json persistence (u1~u10 backend + frontend + tests)
4-axis MDX-stem keyed persistence so layout / zone_geometries / zone_sections / frames
survive across `/api/run` sessions. Auto-restore on MDX reopen; CLI > file precedence
on backend pipeline entry; 300ms-debounced PUT flushed before Generate.
u1 src/user_overrides_io.py — load/save/validate_key (MDX-stem regex), 4-axis schema,
miss={}, corrupt warning+{}, atomic tmp+rename, foreign-key preserve.
u2 src/phase_z2_pipeline.py — post-argparse fallback fills only missing axes.
u3 Front/vite.config.ts — GET /api/user-overrides/:key (200 {} on miss, 400 traversal).
u4 Front/vite.config.ts — PUT /api/user-overrides/:key, 4-axis allowlist, partial merge.
u5 Front/client/src/services/userOverridesApi.ts — typed get/save + flushUserOverrides
with 300ms debounce and mutated-axis partial payloads.
u6 Front/client/src/pages/Home.tsx + slidePlanUtils.ts — restore on MDX upload (non-frame
axes immediately, frames remapped post-loadRun unit_id → region.id).
u7 Home.tsx — persist on 4 mutation handlers (section drop, layout select, zone resize,
frame select); zone_sizes and Generate excluded.
u8 tests/test_user_overrides_io.py — round-trip, unknown-key passthrough, missing/corrupt,
invalid keys (26 tests).
u9 tests/test_user_overrides_pipeline_fallback.py — per-axis fill, CLI-wins, no-file noop,
corrupt warning+skip (16 tests).
u10 Home.tsx + user_overrides_write.test.ts — await flushUserOverrides() before runPipeline
in handleGenerate try-block head; source-pattern regression assertions (20 → 22 tests).
Backend pytest 42/42 green. Frontend vitest 113/113 green (endpoint 42 / restore 21 /
service 28 / write 22). HEAD baseline ee97f4f; no spillover to phase_z2 templates /
families / frames / pipeline orchestration outside the IMP-52 surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* Home - 메인 페이지 (Zone-Centric 슬라이드 빌더)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { DesignAgentState, LayoutPresetId, Zone } from "../types/designAgent";
|
||||
import {
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
getSelectedRegion,
|
||||
moveSectionToZone,
|
||||
saveZoneSizes,
|
||||
deriveUserOverridesKey,
|
||||
applyPersistedNonFrameOverrides,
|
||||
remapPersistedFramesToZoneFrames,
|
||||
} from "../utils/slidePlanUtils";
|
||||
import {
|
||||
parseMdxFile,
|
||||
@@ -25,6 +28,12 @@ import {
|
||||
type RunMeta,
|
||||
type PipelineOverrides,
|
||||
} from "../services/designAgentApi";
|
||||
import {
|
||||
flushUserOverrides,
|
||||
getUserOverrides,
|
||||
saveUserOverrides,
|
||||
type UserOverrides,
|
||||
} from "../services/userOverridesApi";
|
||||
|
||||
import LeftMdxPanel from "../components/LeftMdxPanel";
|
||||
import SlideCanvas from "../components/SlideCanvas";
|
||||
@@ -63,6 +72,14 @@ export default function Home() {
|
||||
// section drag drop + frame 선택). null 이면 평소 모드 (final.html 표시).
|
||||
const [pendingLayout, setPendingLayout] = useState<LayoutPresetId | null>(null);
|
||||
|
||||
// IMP-52 u6 — restore-on-reopen: persisted user_overrides.json fetched at
|
||||
// handleFileUpload time. layout / zone_geometries / zone_sections are
|
||||
// seeded into userSelection immediately (so handleGenerate forwards them
|
||||
// as CLI args). frames are stashed here because their on-disk key
|
||||
// (unit_id = section_ids joined by "+") only maps to region.id after
|
||||
// loadRun rebuilds the slidePlan — see handleGenerate post-loadRun.
|
||||
const persistedOverridesRef = useRef<Partial<UserOverrides>>({});
|
||||
|
||||
// pendingLayout 활성 시 effective slidePlan = pendingZones 가 swap 된 plan.
|
||||
// 그 외 = default state.slidePlan. 모든 zone / region lookup (handleFrameSelect /
|
||||
// getSelectedZone / SlideCanvas) 이 일관되게 이 effectiveSlidePlan 사용.
|
||||
@@ -180,7 +197,19 @@ export default function Home() {
|
||||
|
||||
try {
|
||||
const content = await parseMdxFile(file);
|
||||
setState((p) => ({ ...p, normalizedContent: content, isLoading: false }));
|
||||
// IMP-52 u6 — restore-on-reopen. Key = MDX stem (matches backend
|
||||
// u2 fallback's Path(args.mdx_path).stem). getUserOverrides returns
|
||||
// {} on miss / corrupt / network failure (u5 contract) so the upload
|
||||
// path never fails on a fresh MDX.
|
||||
const overridesKey = deriveUserOverridesKey(file.name);
|
||||
const persisted = await getUserOverrides(overridesKey);
|
||||
persistedOverridesRef.current = persisted;
|
||||
setState((p) => ({
|
||||
...p,
|
||||
normalizedContent: content,
|
||||
userSelection: applyPersistedNonFrameOverrides(p.userSelection, persisted),
|
||||
isLoading: false,
|
||||
}));
|
||||
toast.success(`"${file.name}" 분석 완료 — 하단 버튼으로 슬라이드 생성하세요.`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -257,9 +286,11 @@ export default function Home() {
|
||||
const overrides: PipelineOverrides = {};
|
||||
const sourcePlan = effectiveSlidePlan;
|
||||
if (sourcePlan && state.slidePlan) {
|
||||
const defaultLayout = state.slidePlan.layout_preset;
|
||||
// 2026-05-22 demo hot-fix — 이전 비교 가드 (default !== override) 제거.
|
||||
// restore loop 이 default = override 로 sync 시 override 안 보내고 backend
|
||||
// default fallback 발생. user 가 명시한 layout 이 있으면 무조건 보냄.
|
||||
const overrideLayout = state.userSelection.overrides.layout_preset;
|
||||
if (overrideLayout && overrideLayout !== defaultLayout) {
|
||||
if (overrideLayout) {
|
||||
overrides.layout = overrideLayout;
|
||||
}
|
||||
const frames: Record<string, string> = {};
|
||||
@@ -302,12 +333,9 @@ export default function Home() {
|
||||
overrides.zoneGeometries = zoneGeometries;
|
||||
}
|
||||
|
||||
// IMP-08 B-3 : zoneSections forward only when the user diverged from
|
||||
// the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection`
|
||||
// seeds `zone_sections` with the default placement, so a literal copy
|
||||
// would pollute backend assignment-source provenance even on a fresh
|
||||
// re-render. Diff against `sourcePlan.zones[].section_ids` per zone and
|
||||
// only emit zones whose section list differs.
|
||||
// 2026-05-22 — IMP-08 B-3 원래 동작 (sameAsDefault with effectiveSlidePlan) 복귀.
|
||||
// 시연 안정성 우선. section swap 은 별 path (수동 drag detection) 로 풀어야 함.
|
||||
// 임시 over-aggressive fix 가 default flow 깨뜨려 PARTIAL_COVERAGE 발생했음.
|
||||
const userZoneSections = state.userSelection.overrides.zone_sections;
|
||||
if (userZoneSections) {
|
||||
const defaultByZone = new Map<string, string[]>();
|
||||
@@ -349,6 +377,12 @@ export default function Home() {
|
||||
toast.info(`Phase Z 파이프라인 실행 중... ${overrideSummary}`);
|
||||
|
||||
try {
|
||||
// IMP-52 u10 — Force-commit any pending debounced PUTs before backend
|
||||
// reads user_overrides.json on pipeline entry. Without this, a user
|
||||
// who changes an override (300ms debounce window) and immediately
|
||||
// clicks Generate would race the PUT against /api/run; the u2
|
||||
// fallback could then load a stale persisted document.
|
||||
await flushUserOverrides();
|
||||
const result = await runPipeline(state.uploadedFile, overrides);
|
||||
|
||||
if (!result.success || !result.final_html_exists) {
|
||||
@@ -362,13 +396,42 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const { normalizedContent, slidePlan, runMeta } = await loadRun(result.run_id);
|
||||
setState((p) => ({
|
||||
...p,
|
||||
normalizedContent,
|
||||
// IMP-52 u6 — post-loadRun frame remap. persistedOverridesRef holds
|
||||
// the user_overrides.json read at handleFileUpload time. Frames there
|
||||
// are keyed by unit_id (section_ids joined by "+"); the in-memory
|
||||
// zone_frames is keyed by region.id. Remap against the new slidePlan
|
||||
// zones so SlideCanvas's override-vs-default preview indicator shows
|
||||
// the user's persisted choice without forcing them to re-click.
|
||||
const restoredZoneFrames = remapPersistedFramesToZoneFrames(
|
||||
slidePlan,
|
||||
userSelection: createInitialUserSelection(slidePlan),
|
||||
isLoading: false,
|
||||
}));
|
||||
persistedOverridesRef.current.frames as Record<string, string> | undefined,
|
||||
);
|
||||
setState((p) => {
|
||||
// IMP-52 u6 — restore-on-reopen: re-layer the persisted non-frame
|
||||
// axes (layout / zone_geometries / zone_sections) onto the post-load
|
||||
// `base`. `createInitialUserSelection` rebuilds from slidePlan and
|
||||
// drops anything the backend fallback could not round-trip through
|
||||
// a CLI arg — `zone_geometries` in particular has no slidePlan
|
||||
// representation, so without this merge the user would see their
|
||||
// resized zones revert on every Generate.
|
||||
const base = applyPersistedNonFrameOverrides(
|
||||
createInitialUserSelection(slidePlan),
|
||||
persistedOverridesRef.current,
|
||||
);
|
||||
return {
|
||||
...p,
|
||||
normalizedContent,
|
||||
slidePlan,
|
||||
userSelection: {
|
||||
...base,
|
||||
overrides: {
|
||||
...base.overrides,
|
||||
zone_frames: { ...base.overrides.zone_frames, ...restoredZoneFrames },
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
setRunMeta(runMeta);
|
||||
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
|
||||
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
|
||||
@@ -386,10 +449,21 @@ export default function Home() {
|
||||
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
|
||||
setState((p) => {
|
||||
const newSelection = moveSectionToZone(p.userSelection, sectionId, zoneId);
|
||||
return {
|
||||
...p,
|
||||
userSelection: selectZone(newSelection, zoneId) // 이동된 존 자동 선택
|
||||
};
|
||||
const finalSelection = selectZone(newSelection, zoneId); // 이동된 존 자동 선택
|
||||
// IMP-52 u7 — persist the post-drop zone_sections snapshot. The on-disk
|
||||
// schema axis (`zone_sections`) shares the in-memory shape (zone_id →
|
||||
// section_ids), so we forward the full mutated value; the u4 PUT path
|
||||
// replaces this axis atomically while preserving the foreign axes.
|
||||
// p.uploadedFile gate skips persistence before any MDX is loaded —
|
||||
// the demo-mode initial render path would otherwise PUT to the empty
|
||||
// key. saveUserOverrides is debounced (300ms) and per-key coalesced.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, {
|
||||
zone_sections: finalSelection.overrides.zone_sections,
|
||||
});
|
||||
}
|
||||
return { ...p, userSelection: finalSelection };
|
||||
});
|
||||
setRightTab("frame");
|
||||
setHasPendingChanges(true);
|
||||
@@ -415,10 +489,18 @@ export default function Home() {
|
||||
|
||||
// ── Layout 선택 ──
|
||||
const handleLayoutSelect = useCallback((layoutId: string) => {
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: applyLayout(p.userSelection, layoutId as LayoutPresetId)
|
||||
}));
|
||||
setState((p) => {
|
||||
const newSelection = applyLayout(p.userSelection, layoutId as LayoutPresetId);
|
||||
// IMP-52 u7 — persist the selected layout preset id. The on-disk
|
||||
// `layout` axis is a single string; `applyLayout` validates the
|
||||
// preset id before mutating the selection, so the value here is
|
||||
// already the LayoutPresetId we want to round-trip.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { layout: layoutId });
|
||||
}
|
||||
return { ...p, userSelection: newSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
@@ -431,19 +513,30 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
const handleZoneResize = useCallback((geometries: Record<string, { x: number; y: number; w: number; h: number }>) => {
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: {
|
||||
...p.userSelection,
|
||||
overrides: {
|
||||
...p.userSelection.overrides,
|
||||
zone_geometries: {
|
||||
...p.userSelection.overrides.zone_geometries,
|
||||
...geometries
|
||||
}
|
||||
}
|
||||
setState((p) => {
|
||||
const mergedGeometries = {
|
||||
...p.userSelection.overrides.zone_geometries,
|
||||
...geometries,
|
||||
};
|
||||
// IMP-52 u7 — persist the merged zone_geometries snapshot. Resize
|
||||
// gestures fire repeatedly during a drag; the 300ms u5 debounce
|
||||
// collapses them into a single PUT at gesture-end, so we don't
|
||||
// need to gate on resize-finished here.
|
||||
if (p.uploadedFile) {
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { zone_geometries: mergedGeometries });
|
||||
}
|
||||
}));
|
||||
return {
|
||||
...p,
|
||||
userSelection: {
|
||||
...p.userSelection,
|
||||
overrides: {
|
||||
...p.userSelection.overrides,
|
||||
zone_geometries: mergedGeometries,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, []);
|
||||
|
||||
@@ -486,10 +579,40 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((p) => ({
|
||||
...p,
|
||||
userSelection: applyFrame(p.userSelection, region.id, frameId)
|
||||
}));
|
||||
setState((p) => {
|
||||
const newSelection = applyFrame(p.userSelection, region.id, frameId);
|
||||
// IMP-52 u7 — persist frames keyed by `unit_id`. The on-disk schema
|
||||
// uses `unit_id = zone.section_ids.join("+")` (the same convention
|
||||
// handleGenerate uses when forwarding `overrides.frames` to the
|
||||
// backend CLI). `zone_frames` is keyed by region.id, so we walk
|
||||
// the effectiveSlidePlan zones to translate. Only true user
|
||||
// overrides are persisted — `createInitialUserSelection` pre-fills
|
||||
// `zone_frames[region.id]` with `region.frame_match_strategy.frame_id`
|
||||
// (backend default) for every region, so we mirror handleGenerate's
|
||||
// `overrideFrameId !== defaultFrameId` gate to avoid leaking defaults
|
||||
// into user_overrides.json. Zones with no sections are skipped.
|
||||
if (p.uploadedFile && effectiveSlidePlan) {
|
||||
const framesByUnitId: Record<string, string> = {};
|
||||
for (const z of effectiveSlidePlan.zones) {
|
||||
const r = z.internal_regions[0];
|
||||
if (!r) continue;
|
||||
if (!Array.isArray(z.section_ids) || z.section_ids.length === 0) continue;
|
||||
const unitId = z.section_ids.join("+");
|
||||
const overrideId = newSelection.overrides.zone_frames?.[r.id];
|
||||
const defaultFrameId = r.frame_match_strategy.frame_id;
|
||||
if (
|
||||
typeof overrideId === "string" &&
|
||||
overrideId.length > 0 &&
|
||||
overrideId !== defaultFrameId
|
||||
) {
|
||||
framesByUnitId[unitId] = overrideId;
|
||||
}
|
||||
}
|
||||
const key = deriveUserOverridesKey(p.uploadedFile.name);
|
||||
void saveUserOverrides(key, { frames: framesByUnitId });
|
||||
}
|
||||
return { ...p, userSelection: newSelection };
|
||||
});
|
||||
setHasPendingChanges(true);
|
||||
}, [effectiveSlidePlan, state.userSelection]);
|
||||
|
||||
|
||||
221
Front/client/src/services/userOverridesApi.ts
Normal file
221
Front/client/src/services/userOverridesApi.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// 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.
|
||||
//
|
||||
// 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[]>;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
@@ -1,4 +1,108 @@
|
||||
import type { UserSelection, SlidePlan, Zone, InternalRegion, LayoutPresetId } from "../types/designAgent";
|
||||
import type { UserOverrides } from "../services/userOverridesApi";
|
||||
|
||||
// ─── IMP-52 u6 — restore-on-reopen helpers (pure, exported for testing) ────
|
||||
// These helpers compose persisted `user_overrides.json` payloads (typed by
|
||||
// the u5 service) onto the in-memory `UserSelection`. They live here rather
|
||||
// than inline in Home.tsx so vitest can drive them in a node environment
|
||||
// without booting React or pulling in the radix-ui / lucide UI deps that
|
||||
// Home.tsx requires. Home.tsx wires these into:
|
||||
// • handleFileUpload (pre-Generate layout / zone_geometries / zone_sections
|
||||
// seed so handleGenerate's CLI-args build picks them up)
|
||||
// • handleGenerate post-loadRun (frame remap unit_id → region.id over the
|
||||
// freshly built slidePlan)
|
||||
// The on-disk schema and clear-sentinel semantics are owned by:
|
||||
// • src/user_overrides_io.py (KNOWN_AXES, u1)
|
||||
// • Front/vite.config.ts mergeUserOverrides (u4)
|
||||
// • Front/client/src/services/userOverridesApi.ts (UserOverrides type, u5)
|
||||
// Any KNOWN_AXES drift must land in those files first.
|
||||
|
||||
/**
|
||||
* Derive the `/api/user-overrides/:key` MDX-stem key from a filename.
|
||||
* Strips a trailing `.mdx` (case-insensitive). The key matches the Python
|
||||
* `Path(args.mdx_path).stem` derivation used by the backend fallback (u2),
|
||||
* so the same persisted file is read from both ends without translation.
|
||||
*/
|
||||
export function deriveUserOverridesKey(filename: string): string {
|
||||
return filename.replace(/\.mdx$/i, "");
|
||||
}
|
||||
|
||||
const LAYOUT_PRESET_IDS = new Set<string>([
|
||||
"single",
|
||||
"horizontal-2",
|
||||
"vertical-2",
|
||||
"top-1-bottom-2",
|
||||
"top-2-bottom-1",
|
||||
"left-1-right-2",
|
||||
"left-2-right-1",
|
||||
"grid-2x2",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Layer the three non-frame axes from a persisted `user_overrides.json`
|
||||
* payload onto an existing `UserSelection`. Foreign / unrecognized payload
|
||||
* shapes are silently ignored — the u5 GET path already returns `{}` on
|
||||
* corrupt files, but we revalidate here so hand-edited files or future
|
||||
* forward-compat axes cannot poison the in-memory state.
|
||||
*
|
||||
* Frames are NOT layered here because the on-disk key (`unit_id` =
|
||||
* section_ids joined by `+`) only resolves after the slidePlan zones are
|
||||
* known. Use `remapPersistedFramesToZoneFrames` in the post-loadRun step.
|
||||
*/
|
||||
export function applyPersistedNonFrameOverrides(
|
||||
selection: UserSelection,
|
||||
persisted: Partial<UserOverrides> | null | undefined,
|
||||
): UserSelection {
|
||||
if (!persisted || typeof persisted !== "object") return selection;
|
||||
const next = { ...selection.overrides };
|
||||
if (typeof persisted.layout === "string" && LAYOUT_PRESET_IDS.has(persisted.layout)) {
|
||||
next.layout_preset = persisted.layout as LayoutPresetId;
|
||||
}
|
||||
if (
|
||||
persisted.zone_geometries &&
|
||||
typeof persisted.zone_geometries === "object" &&
|
||||
!Array.isArray(persisted.zone_geometries)
|
||||
) {
|
||||
next.zone_geometries = { ...persisted.zone_geometries };
|
||||
}
|
||||
if (
|
||||
persisted.zone_sections &&
|
||||
typeof persisted.zone_sections === "object" &&
|
||||
!Array.isArray(persisted.zone_sections)
|
||||
) {
|
||||
next.zone_sections = { ...persisted.zone_sections };
|
||||
}
|
||||
return { ...selection, overrides: next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap persisted frames (`unit_id` → template_id) to the in-memory
|
||||
* `zone_frames` (region.id → template_id) using the freshly built
|
||||
* slidePlan zones. `unit_id` follows handleGenerate's convention:
|
||||
* `zone.section_ids.join("+")`. Persisted entries whose unit_id no longer
|
||||
* matches any zone (e.g. user changed zone_sections between sessions) are
|
||||
* silently dropped.
|
||||
*/
|
||||
export function remapPersistedFramesToZoneFrames(
|
||||
slidePlan: SlidePlan | null | undefined,
|
||||
framesByUnitId: Record<string, string> | null | undefined,
|
||||
): Record<string, string> {
|
||||
if (!slidePlan || !framesByUnitId || typeof framesByUnitId !== "object") {
|
||||
return {};
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const zone of slidePlan.zones) {
|
||||
const region = zone.internal_regions[0];
|
||||
if (!region) continue;
|
||||
if (!Array.isArray(zone.section_ids) || zone.section_ids.length === 0) continue;
|
||||
const unitId = zone.section_ids.join("+");
|
||||
const templateId = framesByUnitId[unitId];
|
||||
if (typeof templateId === "string" && templateId.length > 0) {
|
||||
out[region.id] = templateId;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase Z 초기 선택 상태 생성
|
||||
|
||||
Reference in New Issue
Block a user