// Single source of truth for the read-only monitor widget's GET-param contract. // Used by the bare /widget page (parse) and the /widget/builder UI (parse + // serialize), so a link the builder copies always renders the way it previewed. import { defaultLayout, parseLayoutParam, reconcileLayout, serializeLayout, type Layout, } from "./placement"; import type { SectionFlags } from "./sections"; export type WidgetConfig = { // Which sections to render. jobs: boolean; workers: boolean; // Optional: only show active jobs for this channel slug. channel?: string; // Poll cadence in seconds (clamped >= 1). pollSeconds: number; // Denser layout: drops per-task detail rows, keeps the job-level bars. compact: boolean; // Show the small "Workers" / "Active" section headers. showTitles: boolean; // When nothing is active, collapse to a tiny "Idle" line instead of the // full empty-state boxes. hideIdle: boolean; // Show the batch/channel-level aggregate progress bar per job. Turn this off // (with headingProgress on) to show only the individual per-task bars. jobBar: boolean; // Carry the batch progress (e.g. "5/10 · ~2m left") as text in each job's // one-line heading — useful when the job bar is hidden. headingProgress: boolean; // Show ETA estimates wherever they appear (the job-bar caption and the // heading progress text). Off = counts only, no "~Xm left". eta: boolean; // Show the free-disk indicator strip (when the disk gate is enabled). disk: boolean; // Show the cleanable-data indicator strip (total reclaimable audio across // channels not excluded from the cleanup total). Off by default. cleanable: boolean; // Show worker names in the Workers strip. Off = colored dots only (denser). workerLabels: boolean; // Opt-in interactive controls (Pause/Resume Transcriptions, Drain all). Off by // default — the widget stays read-only unless this is enabled. controls: boolean; // Show the "Needs work" strip — a compact list of channels with videos to // download or transcribe. Off by default (mirrors the opt-in cleanable strip). actionable: boolean; // Show the "Needs cleaning" strip — the channels holding the reclaimable // audio the cleanable indicator totals, each with a Clean audio button when // `controls` is on. Off by default (mirrors the opt-in cleanable strip). cleanChannels: boolean; // Show the in-widget settings gear that opens the config overlay in place. On // by default; turn off to bake a locked-down shared/embedded link. settings: boolean; // Show the interactive Sync button. Channel-aware: syncs just `channel` when // the widget is pinned to one, else sweeps all channels. Off by default. sync: boolean; // Show the last-sync readout strip (last full sync-all + last individual // channel sync when newer). Off by default. lastSync: boolean; // Show the auto-sync scheduler status strip (on/off, next due, last run). Off // by default. scheduler: boolean; // Show the backfill strip: how much derived data the existing corpus is // missing, split into what the lane can reach now and what needs its media // re-acquired first. Off by default, like every strip that is only meaningful // once a feature is turned on. backfill: boolean; // Readouts show absolute (locale) time instead of relative "5m ago". Off by // default (relative). syncTimeAbsolute: boolean; // window.confirm before a full Sync-all sweep. Off by default. (A pinned // single-channel Sync is never gated.) syncConfirm: boolean; // Where each enabled section sits: rows of cells, each cell a stack of // sections (see ./placement). Derived, not authoritative — the booleans above // still decide what is on, and a layout is normalized against them on parse // (see parseLayoutParam), so the two can never disagree. Defaults to one cell // in one auto row, in the widget's original order, which is why every link // written before layouts existed is unchanged. layout: Layout; // Let the grid collapse back to a single stack below a container-query width, // for a widget you intend to resize. Off by default: a layout you arranged is // honored at every size unless you ask for this. stackNarrow: boolean; }; const WIDGET_FLAG_DEFAULTS: SectionFlags = { jobs: true, workers: true, channel: undefined, pollSeconds: 2, compact: false, showTitles: true, hideIdle: false, jobBar: true, headingProgress: false, eta: true, disk: true, cleanable: false, workerLabels: true, controls: false, actionable: false, cleanChannels: false, settings: true, sync: false, lastSync: false, scheduler: false, backfill: false, syncTimeAbsolute: false, syncConfirm: false, stackNarrow: false, }; // Split in two because the default layout is a function of the default // visibility flags — one column holding whatever is on — rather than a literal // that could drift away from them. export const WIDGET_DEFAULTS: WidgetConfig = { ...WIDGET_FLAG_DEFAULTS, layout: defaultLayout(WIDGET_FLAG_DEFAULTS), }; // Next's searchParams give each key as string | string[] | undefined. type RawParams = Record; function first(v: string | string[] | undefined): string | undefined { return Array.isArray(v) ? v[0] : v; } // Parse a "0"/"1" (also accepts "true"/"false") flag, falling back to a default. function parseBool( v: string | string[] | undefined, fallback: boolean, ): boolean { const s = first(v); if (s === undefined) return fallback; if (s === "1" || s === "true") return true; if (s === "0" || s === "false") return false; return fallback; } export function parseWidgetConfig(params: RawParams): WidgetConfig { const channel = first(params.channel)?.trim(); const pollRaw = Number(first(params.poll)); const pollSeconds = Number.isFinite(pollRaw) && pollRaw >= 1 ? Math.min(3600, Math.round(pollRaw)) : WIDGET_DEFAULTS.pollSeconds; const flags: SectionFlags = { jobs: parseBool(params.jobs, WIDGET_DEFAULTS.jobs), workers: parseBool(params.workers, WIDGET_DEFAULTS.workers), channel: channel || undefined, pollSeconds, compact: parseBool(params.compact, WIDGET_DEFAULTS.compact), showTitles: parseBool(params.titles, WIDGET_DEFAULTS.showTitles), hideIdle: first(params.idle) === "hide" ? true : WIDGET_DEFAULTS.hideIdle, jobBar: parseBool(params.jobbar, WIDGET_DEFAULTS.jobBar), headingProgress: parseBool(params.headtext, WIDGET_DEFAULTS.headingProgress), eta: parseBool(params.eta, WIDGET_DEFAULTS.eta), disk: parseBool(params.disk, WIDGET_DEFAULTS.disk), cleanable: parseBool(params.clean, WIDGET_DEFAULTS.cleanable), workerLabels: parseBool(params.wnames, WIDGET_DEFAULTS.workerLabels), controls: parseBool(params.controls, WIDGET_DEFAULTS.controls), actionable: parseBool(params.act, WIDGET_DEFAULTS.actionable), cleanChannels: parseBool(params.cleanlist, WIDGET_DEFAULTS.cleanChannels), settings: parseBool(params.gear, WIDGET_DEFAULTS.settings), sync: parseBool(params.sync, WIDGET_DEFAULTS.sync), lastSync: parseBool(params.lastsync, WIDGET_DEFAULTS.lastSync), scheduler: parseBool(params.sched, WIDGET_DEFAULTS.scheduler), backfill: parseBool(params.backfill, WIDGET_DEFAULTS.backfill), syncTimeAbsolute: parseBool(params.abstime, WIDGET_DEFAULTS.syncTimeAbsolute), syncConfirm: parseBool(params.syncask, WIDGET_DEFAULTS.syncConfirm), stackNarrow: parseBool(params.stack, WIDGET_DEFAULTS.stackNarrow), }; // Last, because normalizing a layout means knowing which sections are on. return { ...flags, layout: parseLayoutParam(first(params.l), first(params.g), flags), }; } // Apply a config change and keep the layout consistent with it: a section // switched off leaves the board, one switched on joins the last cell of the last // row. Every edit surface (the builder board, the in-widget menu) goes through // this so neither can produce a config whose layout param disagrees with its // booleans. A patch carrying an explicit `layout` — a drag, a reorder — is // reconciled too, which is a no-op when the caller already placed things // correctly. export function patchWidgetConfig( config: WidgetConfig, patch: Partial, ): WidgetConfig { const next = { ...config, ...patch }; return { ...next, layout: reconcileLayout(next.layout, next) }; } // Serialize a config to a query string, omitting anything left at its default so // shared links stay short. Returns "" when every value is default. export function buildWidgetQuery(config: WidgetConfig): string { const sp = new URLSearchParams(); if (config.jobs !== WIDGET_DEFAULTS.jobs) sp.set("jobs", config.jobs ? "1" : "0"); if (config.workers !== WIDGET_DEFAULTS.workers) sp.set("workers", config.workers ? "1" : "0"); if (config.channel) sp.set("channel", config.channel); if (config.pollSeconds !== WIDGET_DEFAULTS.pollSeconds) sp.set("poll", String(config.pollSeconds)); if (config.compact !== WIDGET_DEFAULTS.compact) sp.set("compact", config.compact ? "1" : "0"); if (config.showTitles !== WIDGET_DEFAULTS.showTitles) sp.set("titles", config.showTitles ? "1" : "0"); if (config.hideIdle !== WIDGET_DEFAULTS.hideIdle) sp.set("idle", "hide"); if (config.jobBar !== WIDGET_DEFAULTS.jobBar) sp.set("jobbar", config.jobBar ? "1" : "0"); if (config.headingProgress !== WIDGET_DEFAULTS.headingProgress) sp.set("headtext", config.headingProgress ? "1" : "0"); if (config.eta !== WIDGET_DEFAULTS.eta) sp.set("eta", config.eta ? "1" : "0"); if (config.disk !== WIDGET_DEFAULTS.disk) sp.set("disk", config.disk ? "1" : "0"); if (config.cleanable !== WIDGET_DEFAULTS.cleanable) sp.set("clean", config.cleanable ? "1" : "0"); if (config.workerLabels !== WIDGET_DEFAULTS.workerLabels) sp.set("wnames", config.workerLabels ? "1" : "0"); if (config.controls !== WIDGET_DEFAULTS.controls) sp.set("controls", config.controls ? "1" : "0"); if (config.backfill !== WIDGET_DEFAULTS.backfill) sp.set("backfill", config.backfill ? "1" : "0"); if (config.actionable !== WIDGET_DEFAULTS.actionable) sp.set("act", config.actionable ? "1" : "0"); if (config.cleanChannels !== WIDGET_DEFAULTS.cleanChannels) sp.set("cleanlist", config.cleanChannels ? "1" : "0"); if (config.settings !== WIDGET_DEFAULTS.settings) sp.set("gear", config.settings ? "1" : "0"); if (config.sync !== WIDGET_DEFAULTS.sync) sp.set("sync", config.sync ? "1" : "0"); if (config.lastSync !== WIDGET_DEFAULTS.lastSync) sp.set("lastsync", config.lastSync ? "1" : "0"); if (config.scheduler !== WIDGET_DEFAULTS.scheduler) sp.set("sched", config.scheduler ? "1" : "0"); if (config.syncTimeAbsolute !== WIDGET_DEFAULTS.syncTimeAbsolute) sp.set("abstime", config.syncTimeAbsolute ? "1" : "0"); if (config.syncConfirm !== WIDGET_DEFAULTS.syncConfirm) sp.set("syncask", config.syncConfirm ? "1" : "0"); if (config.stackNarrow !== WIDGET_DEFAULTS.stackNarrow) sp.set("stack", config.stackNarrow ? "1" : "0"); // Last, and omitted entirely when the arrangement is the one the visibility // flags already imply — so a config that only toggles sections still produces // the same short link it did before layouts existed. `l` OR `g`, never both: // a layout that plain columns can express keeps emitting the `l=` a // pre-rows link would have carried. const layout = serializeLayout(config.layout, config); if (layout.l !== undefined) sp.set("l", layout.l); else if (layout.g !== undefined) sp.set("g", layout.g); return sp.toString(); }