import fs from "node:fs"; import { writeJsonAtomic } from "./jsonFile-server"; import type { Paths } from "./paths"; // Persisted monitor-widget presets: named arrangements the /widget/builder board // and the in-widget menu both load from, so a layout you liked stops being // something you have to keep a URL of. // // A PRESET STORES THE QUERY STRING, not a parsed config. That is the whole // design decision here: // - a preset and a shared /widget link are then literally the same artifact, // so "save this link" and "save this preset" cannot mean different things; // - loading one re-runs parseWidgetConfig, which already normalizes, clamps // and drops unknown codes — so a preset written by an older release survives // a registry change for free, exactly as an old link does; // - nothing in this file has to know what a section or a row IS, so adding one // never touches the store. // // The store is a small JSON file living outside any in-memory registry, written // atomically (tmp + rename), with tolerant reads that coerce a missing/corrupt // file to an empty list rather than crashing. export type WidgetPreset = { id: string; name: string; // The query string buildWidgetQuery produced, WITHOUT a leading "?". Empty // string is legal and means the all-default widget. query: string; createdAt: number; }; type PresetsFile = { v: 1; presets: WidgetPreset[] }; // Names are what the operator picks a preset by, so they are trimmed and capped // but otherwise left alone (spaces and punctuation included). export const MAX_PRESET_NAME = 60; export function normalizePresetName(name: string): string { return name.trim().slice(0, MAX_PRESET_NAME); } function newPresetId(): string { return `wp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } export async function readWidgetPresets(paths: Paths): Promise { let raw: unknown; try { raw = JSON.parse(await fs.promises.readFile(paths.widgetPresetsFile, "utf8")); } catch { return []; } if (!raw || typeof raw !== "object") return []; const arr = (raw as Record).presets; if (!Array.isArray(arr)) return []; const out: WidgetPreset[] = []; for (const item of arr) { if (!item || typeof item !== "object") continue; const p = item as Record; if (typeof p.id !== "string" || !p.id) continue; if (typeof p.query !== "string") continue; const name = typeof p.name === "string" ? normalizePresetName(p.name) : ""; if (!name) continue; out.push({ id: p.id, name, // Tolerate a stored value that kept its "?" — it is still a link. query: p.query.replace(/^\?/, ""), createdAt: typeof p.createdAt === "number" ? p.createdAt : 0, }); } return out; } async function writePresets( paths: Paths, presets: WidgetPreset[], ): Promise { const out: PresetsFile = { v: 1, presets }; await writeJsonAtomic(paths.widgetPresetsFile, out, { mkdir: true }); } // Save a preset under `name`. Saving over an existing name OVERWRITES its query // rather than making a second entry with the same label — "Save" and "Save // as… (an existing name)" then mean the same thing, which is what an operator // expects of a named slot. export async function addWidgetPreset( paths: Paths, name: string, query: string, ): Promise { const clean = normalizePresetName(name); if (!clean) return null; const existing = await readWidgetPresets(paths); const q = query.replace(/^\?/, ""); const at = existing.findIndex((p) => p.name === clean); if (at !== -1) { const next = existing.slice(); next[at] = { ...next[at], query: q }; await writePresets(paths, next); return next[at]; } const preset: WidgetPreset = { id: newPresetId(), name: clean, query: q, createdAt: Date.now(), }; await writePresets(paths, [...existing, preset]); return preset; } export async function renameWidgetPreset( paths: Paths, id: string, name: string, ): Promise { const clean = normalizePresetName(name); if (!clean) return false; const existing = await readWidgetPresets(paths); let changed = false; const next = existing.map((p) => { if (p.id !== id) return p; changed = true; return { ...p, name: clean }; }); if (changed) await writePresets(paths, next); return changed; } export async function removeWidgetPreset( paths: Paths, id: string, ): Promise { const existing = await readWidgetPresets(paths); const next = existing.filter((p) => p.id !== id); if (next.length !== existing.length) await writePresets(paths, next); } // Move a preset one slot up (dir -1) or down (dir +1) by swapping it with its // neighbour. Order is the array order — the order both the builder row and the // in-widget menu render — so this is the only place reordering lives. No-ops at // the bounds or for an unknown id. export async function moveWidgetPreset( paths: Paths, id: string, dir: -1 | 1, ): Promise { const existing = await readWidgetPresets(paths); const i = existing.findIndex((p) => p.id === id); const j = i + dir; if (i < 0 || j < 0 || j >= existing.length) return; const next = existing.slice(); [next[i], next[j]] = [next[j], next[i]]; await writePresets(paths, next); } // Resolve a `?preset=` value: by id first, then by name (case-insensitively), so // a hand-written link can say what it means. Returns null for an unknown one, // which the caller renders as "just the rest of the params". export function findWidgetPreset( presets: WidgetPreset[], ref: string, ): WidgetPreset | null { const needle = ref.trim(); if (!needle) return null; return ( presets.find((p) => p.id === needle) ?? presets.find((p) => p.name.toLowerCase() === needle.toLowerCase()) ?? null ); }