import { useCallback, useEffect, useRef } from 'react'; import { useClient } from 'hooks/useClient'; import { useGlobal } from 'hooks/useGlobal'; import { readableError } from './errors'; /** * The state of `derived/` — what is running, what is outstanding, and how to start a pass. * * ── Why this is shared at all ── * * The button moved to the panel's action bar, beside Build and Publish, because syncing is one of the * things somebody does to a site rather than a detail of one card. The rest of it — the progress bar, * the failures, the run history — stayed in Administration, where there is room to read it. * * So one subject is drawn in two places at once, and they must not disagree. A header saying "Sync * now" beside a card saying "3 changed" is two components each deciding what is true. * * ── `useGlobal`, not a context ── * * A slot in the app-wide query cache, keyed by name: whoever reads the key gets the value, whoever * writes it re-renders every reader. No provider, so nothing has to wrap the bar and the panel * together, and a third place that wants this later just reads the key. * * ── One reader FETCHES, everyone else reads ── * * `useGlobal` shares the value, not the work: mount the same effects twice and the endpoint is polled * twice a second. So the fetching is a separate hook with exactly one caller — `SyncButton`, which * lives in the bar and is therefore mounted whenever anything in this plugin is open. The * Administration card is mounted only sometimes, which is precisely why it must not be the one asking. */ export type SyncError = { file: string; message: string }; /** One line of the run's transcript. Mirrors LogLine in api/sync.ts. */ export type LogLine = { /** Monotonic within a run. What `?since=` is asked with, so each line transfers once. */ n: number; at: string; level: 'info' | 'step' | 'done' | 'warn' | 'error'; /** The file this is about, or blank for the run's own lines. */ file: string; text: string; }; export type SyncState = { running: boolean; total: number | null; done: number; built: number; skipped: number; /** Ladders renamed into place because their original moved, rather than rebuilt. */ moved: number; /** Derived folders removed because their original is gone. Whole-tree syncs only. */ pruned: number; errors: SyncError[]; current: string[]; /** Only the lines the server had not already sent — see the `since` note in the feed. */ log: LogLine[]; startedAt: string | null; finishedAt: string | null; fatal: string | null; }; export type Run = { startedAt: string; finishedAt: string; trigger: 'manual' | 'watcher'; scope: string; total: number; built: number; skipped: number; moved: number; pruned: number; errors: SyncError[]; fatal: string | null; }; export type Reconciliation = { originals: number; current: number; missing: string[]; stale: string[] }; export type Ledger = { runs: Run[]; reconciliation: Reconciliation }; /** How often to ask, while something is happening. Fast enough to look live, slow enough to be free. */ const POLL_MS = 1000; /** The slot. One name, so a third reader is a one-line addition rather than a refactor. */ const KEY = ['fofos-admin', 'media-sync']; type Shared = { state: SyncState | null; ledger: Ledger | null; /** * The transcript, ACCUMULATED — the server sends deltas and this is the whole of it. * * Kept here rather than in the modal so closing the modal does not throw away the run's history. * Somebody who opens it, watches, closes it and opens it again is watching one run, not two. */ log: LogLine[]; /** Which run `log` belongs to, so a new one clears it. See the note where it is compared. */ logRun: string | null; /** A start request is in flight — distinct from `state.running`, which is the SERVER's answer. */ busy: boolean; error: string | null; }; const EMPTY: Shared = { state: null, ledger: null, log: [], logRun: null, busy: false, error: null }; export type MediaSync = Shared & { /** A pass is under way. */ running: boolean; /** How far through, or null while the walk is still counting — nothing honest to divide by yet. */ percent: number | null; /** Files with no ladder plus files whose ladder is stale. Zero means `derived/` is current. */ outstanding: number; /** Whether the ledger has been read at all. Before that `outstanding` is 0 because it is unknown. */ known: boolean; start: () => Promise; }; /** Read the sync, and start one. Any number of callers. */ export const useMediaSync = (): MediaSync => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [shared, setShared] = useGlobal(KEY, EMPTY); const start = useCallback(async () => { setShared((was) => ({ ...was, busy: true, error: null })); try { const result = await clientRef.current.post<{ started: boolean; state: SyncState }>( '/fofos-admin/_config/media/sync', {}, ); setShared((was) => ({ ...was, state: result.state, busy: false })); } catch (caught) { setShared((was) => ({ ...was, busy: false, error: readableError(caught) })); } // `setShared` is rebuilt every render by useGlobal — see the note in SitePreview about verbs from // useClient. Left out of the deps on purpose, and it closes over nothing that changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const total = shared.state?.total ?? null; const done = shared.state?.done ?? 0; const reconciliation = shared.ledger?.reconciliation ?? null; return { ...shared, running: shared.state?.running ?? false, percent: total && total > 0 ? Math.round((done / total) * 100) : null, outstanding: (reconciliation?.missing.length ?? 0) + (reconciliation?.stale.length ?? 0), known: reconciliation !== null, start, }; }; /** * Keep the slot current. EXACTLY ONE caller — `SyncButton`. * * Separate from reading because `useGlobal` shares the value and not the work: two components running * these effects would poll the same endpoint twice a second and re-read the ledger twice per run. */ export const useMediaSyncFeed = (): void => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [shared, setShared] = useGlobal(KEY, EMPTY); const setRef = useRef(setShared); setRef.current = setShared; // The latest value, for the poll to read WITHOUT being rebuilt when it changes — an interval whose // callback identity changed every line would be torn down and restarted on every line. const sharedRef = useRef(shared); sharedRef.current = shared; const load = useCallback(async () => { const held = sharedRef.current; const since = held.log.at(-1)?.n; try { /* Ask only for what we do not have. A long run's transcript is thousands of lines and this polls every second, so without `since` the same megabyte would arrive over and over. On the first poll of a run there is nothing held, so `since` is absent and the server sends the lot — which is what a modal opened mid-run wants. */ const state = await clientRef.current.get( since === undefined ? '/fofos-admin/_config/media/sync' : `/fofos-admin/_config/media/sync?since=${since}`, ); setRef.current((was) => { /* A NEW run resets the transcript. `n` restarts at 1 each run, so appending across a boundary would interleave two runs and put the numbers out of order. `startedAt` is what identifies a run — comparing it is cheaper and more honest than watching `running` go false then true, which misses a run that began and ended between two polls. */ const run = state.startedAt; const fresh = run !== was.logRun; const log = fresh ? state.log : [...was.log, ...state.log]; return { ...was, state, log, logRun: run }; }); } catch { // Owner-only. Root Location has already said so once if that is the reason. setRef.current((was) => ({ ...was, state: null })); } }, []); /* The ledger is NOT polled. It walks the whole tree reading manifests, so asking for it once a second beside a progress bar would have every tick re-read the library. It is read on arrival and again when a run stops, which are the only two moments its answer can have changed. */ const loadLedger = useCallback(async () => { try { const ledger = await clientRef.current.get('/fofos-admin/_config/media/ledger'); setRef.current((was) => ({ ...was, ledger })); } catch { setRef.current((was) => ({ ...was, ledger: null })); } }, []); useEffect(() => { void load(); void loadLedger(); }, [load, loadLedger]); const running = shared.state?.running ?? false; useEffect(() => { if (!running) return; const timer = setInterval(() => void load(), POLL_MS); return () => clearInterval(timer); }, [running, load]); // Re-read when a run STOPS. `running` going true→false is the edge, so a finished sync updates what // is left to do without the panel being reopened. const wasRunning = useRef(false); useEffect(() => { if (wasRunning.current && !running) void loadLedger(); wasRunning.current = running; }, [running, loadLedger]); };