refactor: convert bookmarks to typed module

This commit is contained in:
sjg
2026-08-01 13:13:52 +02:00
parent 9fef0ebc7b
commit c7994347e9
8 changed files with 1052 additions and 856 deletions
@@ -802,11 +802,12 @@ function setTheme(theme) {
invalidateBookmarkColors(); invalidateBookmarkColors();
} }
function invalidateBookmarkColors() { function invalidateBookmarkColors() {
if (typeof bmOverlayRevision === "undefined") return; const bookmarks = window.trx.modules.bookmarks;
bmOverlayRevision++; if (!bookmarks) return;
bookmarks.invalidateColors();
void getComputedStyle(document.documentElement).getPropertyValue("--bg"); void getComputedStyle(document.documentElement).getPropertyValue("--bg");
const colorMap = bmCategoryColorMap(); const colorMap = bmCategoryColorMap();
const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : []; const ref = bookmarks.overlayList;
document.querySelectorAll(".spectrum-bookmark-chip").forEach((chip) => { document.querySelectorAll(".spectrum-bookmark-chip").forEach((chip) => {
const bm = ref.find((b) => b.id === chip.dataset.bmId); const bm = ref.find((b) => b.id === chip.dataset.bmId);
if (!bm) return; if (!bm) return;
@@ -1450,7 +1451,7 @@ function drawSignalOverlay() {
const bwEdge = BW_OVERLAY_COLORS.edge; const bwEdge = BW_OVERLAY_COLORS.edge;
const bwStroke = BW_OVERLAY_COLORS.stroke; const bwStroke = BW_OVERLAY_COLORS.stroke;
const bwHard = BW_OVERLAY_COLORS.hard; const bwHard = BW_OVERLAY_COLORS.hard;
const bmRef = typeof bmOverlayList !== "undefined" ? bmOverlayList : null; const bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
if (Array.isArray(bmRef) && bmRef.length > 0) { if (Array.isArray(bmRef) && bmRef.length > 0) {
const colorMap = bmCategoryColorMap(); const colorMap = bmCategoryColorMap();
const grouped = /* @__PURE__ */ new Map(); const grouped = /* @__PURE__ */ new Map();
@@ -4625,7 +4626,7 @@ function buildBookmarkTooltipText(bm) {
return text; return text;
} }
function nearestBookmarkForHz(hz, widthPx, range) { function nearestBookmarkForHz(hz, widthPx, range) {
const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : null; const ref = window.trx.modules.bookmarks?.overlayList ?? null;
if (!Array.isArray(ref) || !Number.isFinite(hz) || !widthPx || !range || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) { if (!Array.isArray(ref) || !Number.isFinite(hz) || !widthPx || !range || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) {
return null; return null;
} }
@@ -7189,7 +7190,7 @@ function bmThemePalette() {
]; ];
} }
function bmCategoryColorMap() { function bmCategoryColorMap() {
const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : []; const ref = window.trx.modules.bookmarks?.overlayList ?? [];
const cats = [...new Set(ref.map((b) => b.category).filter(Boolean))].sort(); const cats = [...new Set(ref.map((b) => b.category).filter(Boolean))].sort();
const palette = bmThemePalette(); const palette = bmThemePalette();
const map = { "": palette[0] }; const map = { "": palette[0] };
@@ -7216,13 +7217,13 @@ function createBookmarkChip(bm, colorMap, options = {}) {
span.style.setProperty("--bm-cat-bg", col); span.style.setProperty("--bm-cat-bg", col);
span.style.setProperty("--bm-cat-fg", bmContrastFg(col)); span.style.setProperty("--bm-cat-fg", bmContrastFg(col));
span.addEventListener("click", () => { span.addEventListener("click", () => {
if (typeof bmApply === "function") bmApply(bm); void window.trx.modules.bookmarks?.apply(bm);
}); });
return span; return span;
} }
function updateSideBookmarkStack(container, bookmarks, colorMap) { function updateSideBookmarkStack(container, bookmarks, colorMap) {
if (!container) return; if (!container) return;
const rev = typeof bmOverlayRevision !== "undefined" ? bmOverlayRevision : 0; const rev = window.trx.modules.bookmarks?.overlayRevision ?? 0;
const nextKey = Array.isArray(bookmarks) ? `${rev}:${bookmarks.map((bm) => bm.id).join(",")}` : ""; const nextKey = Array.isArray(bookmarks) ? `${rev}:${bookmarks.map((bm) => bm.id).join(",")}` : "";
if (!Array.isArray(bookmarks) || bookmarks.length === 0) { if (!Array.isArray(bookmarks) || bookmarks.length === 0) {
if (container.dataset.bmKey) { if (container.dataset.bmKey) {
@@ -7246,7 +7247,7 @@ function updateBookmarkAxis(range) {
const leftSideEl = document.getElementById("spectrum-bookmark-side-left"); const leftSideEl = document.getElementById("spectrum-bookmark-side-left");
const rightSideEl = document.getElementById("spectrum-bookmark-side-right"); const rightSideEl = document.getElementById("spectrum-bookmark-side-right");
if (!axisEl) return; if (!axisEl) return;
const _bmRef = typeof bmOverlayList !== "undefined" ? bmOverlayList : null; const _bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
const allBookmarks = Array.isArray(_bmRef) ? _bmRef : []; const allBookmarks = Array.isArray(_bmRef) ? _bmRef : [];
const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz); const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz);
const leftBookmarks = allBookmarks.filter((bm) => bm.freq_hz < range.visLoHz).sort((a, b) => b.freq_hz - a.freq_hz).slice(0, 3); const leftBookmarks = allBookmarks.filter((bm) => bm.freq_hz < range.visLoHz).sort((a, b) => b.freq_hz - a.freq_hz).slice(0, 3);
@@ -7263,7 +7264,7 @@ function updateBookmarkAxis(range) {
} }
return; return;
} }
const rev = typeof bmOverlayRevision !== "undefined" ? bmOverlayRevision : 0; const rev = window.trx.modules.bookmarks?.overlayRevision ?? 0;
const newKey = `${rev}:${visBookmarks.map((b) => b.id).join(",")}`; const newKey = `${rev}:${visBookmarks.map((b) => b.id).join(",")}`;
if (axisEl.dataset.bmKey !== newKey) { if (axisEl.dataset.bmKey !== newKey) {
axisEl.dataset.bmKey = newKey; axisEl.dataset.bmKey = newKey;
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,7 @@ const pluginGroups = {
}; };
const loaded = /* @__PURE__ */ new Set(); const loaded = /* @__PURE__ */ new Set();
const loading = /* @__PURE__ */ new Map(); const loading = /* @__PURE__ */ new Map();
const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js"]); const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js", "/bookmarks.js"]);
function loadLegacyScript(path) { function loadLegacyScript(path) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const script = document.createElement("script"); const script = document.createElement("script");
@@ -23,7 +23,6 @@ await build({
"plugin-runtime": path.join(sourceDir, "plugin-runtime.ts"), "plugin-runtime": path.join(sourceDir, "plugin-runtime.ts"),
screenshot: path.join(sourceDir, "screenshot.ts"), screenshot: path.join(sourceDir, "screenshot.ts"),
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"), "webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
scheduler: path.join(sourceDir, "plugins", "scheduler.js"), scheduler: path.join(sourceDir, "plugins", "scheduler.js"),
}, },
outdir: outputDir, outdir: outputDir,
@@ -52,6 +51,7 @@ await build({
sat: path.join(sourceDir, "plugins", "sat.ts"), sat: path.join(sourceDir, "plugins", "sat.ts"),
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"), "sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
vchan: path.join(sourceDir, "plugins", "vchan.ts"), vchan: path.join(sourceDir, "plugins", "vchan.ts"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
}, },
outdir: outputDir, outdir: outputDir,
bundle: true, bundle: true,
@@ -912,12 +912,13 @@ function setTheme(theme) {
// Recolour bookmark chips after any palette/theme change (setTheme or setStyle). // Recolour bookmark chips after any palette/theme change (setTheme or setStyle).
function invalidateBookmarkColors() { function invalidateBookmarkColors() {
if (typeof bmOverlayRevision === "undefined") return; const bookmarks = window.trx.modules.bookmarks;
bmOverlayRevision++; if (!bookmarks) return;
bookmarks.invalidateColors();
// Force the browser to recalculate styles so getComputedStyle reads new values. // Force the browser to recalculate styles so getComputedStyle reads new values.
void getComputedStyle(document.documentElement).getPropertyValue("--bg"); void getComputedStyle(document.documentElement).getPropertyValue("--bg");
const colorMap = bmCategoryColorMap(); const colorMap = bmCategoryColorMap();
const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : []; const ref = bookmarks.overlayList;
document.querySelectorAll(".spectrum-bookmark-chip").forEach((chip) => { document.querySelectorAll(".spectrum-bookmark-chip").forEach((chip) => {
const bm = ref.find((b) => b.id === chip.dataset.bmId); const bm = ref.find((b) => b.id === chip.dataset.bmId);
if (!bm) return; if (!bm) return;
@@ -1482,7 +1483,7 @@ function drawSignalOverlay() {
const bwEdge = BW_OVERLAY_COLORS.edge; const bwEdge = BW_OVERLAY_COLORS.edge;
const bwStroke = BW_OVERLAY_COLORS.stroke; const bwStroke = BW_OVERLAY_COLORS.stroke;
const bwHard = BW_OVERLAY_COLORS.hard; const bwHard = BW_OVERLAY_COLORS.hard;
const bmRef = typeof bmOverlayList !== "undefined" ? bmOverlayList : null; const bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
if (Array.isArray(bmRef) && bmRef.length > 0) { if (Array.isArray(bmRef) && bmRef.length > 0) {
const colorMap = bmCategoryColorMap(); const colorMap = bmCategoryColorMap();
const grouped = new Map(); const grouped = new Map();
@@ -4973,7 +4974,7 @@ function buildBookmarkTooltipText(bm) {
} }
function nearestBookmarkForHz(hz, widthPx, range) { function nearestBookmarkForHz(hz, widthPx, range) {
const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : null; const ref = window.trx.modules.bookmarks?.overlayList ?? null;
if (!Array.isArray(ref) || !Number.isFinite(hz) || !widthPx || !range || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) { if (!Array.isArray(ref) || !Number.isFinite(hz) || !widthPx || !range || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) {
return null; return null;
} }
@@ -7753,7 +7754,7 @@ function bmThemePalette() {
// Returns a map of category → hex colour, including "" for uncategorised. // Returns a map of category → hex colour, including "" for uncategorised.
function bmCategoryColorMap() { function bmCategoryColorMap() {
const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : []; const ref = window.trx.modules.bookmarks?.overlayList ?? [];
const cats = [...new Set(ref.map((b) => b.category).filter(Boolean))].sort(); const cats = [...new Set(ref.map((b) => b.category).filter(Boolean))].sort();
const palette = bmThemePalette(); const palette = bmThemePalette();
const map = { "": palette[0] }; const map = { "": palette[0] };
@@ -7797,14 +7798,14 @@ function createBookmarkChip(bm, colorMap, options = {}) {
span.style.setProperty("--bm-cat-bg", col); span.style.setProperty("--bm-cat-bg", col);
span.style.setProperty("--bm-cat-fg", bmContrastFg(col)); span.style.setProperty("--bm-cat-fg", bmContrastFg(col));
span.addEventListener("click", () => { span.addEventListener("click", () => {
if (typeof bmApply === "function") bmApply(bm); void window.trx.modules.bookmarks?.apply(bm);
}); });
return span; return span;
} }
function updateSideBookmarkStack(container, bookmarks, colorMap) { function updateSideBookmarkStack(container, bookmarks, colorMap) {
if (!container) return; if (!container) return;
const rev = typeof bmOverlayRevision !== "undefined" ? bmOverlayRevision : 0; const rev = window.trx.modules.bookmarks?.overlayRevision ?? 0;
const nextKey = Array.isArray(bookmarks) ? `${rev}:${bookmarks.map((bm) => bm.id).join(",")}` : ""; const nextKey = Array.isArray(bookmarks) ? `${rev}:${bookmarks.map((bm) => bm.id).join(",")}` : "";
if (!Array.isArray(bookmarks) || bookmarks.length === 0) { if (!Array.isArray(bookmarks) || bookmarks.length === 0) {
if (container.dataset.bmKey) { if (container.dataset.bmKey) {
@@ -7832,7 +7833,7 @@ function updateBookmarkAxis(range) {
const rightSideEl = document.getElementById("spectrum-bookmark-side-right"); const rightSideEl = document.getElementById("spectrum-bookmark-side-right");
if (!axisEl) return; if (!axisEl) return;
const _bmRef = typeof bmOverlayList !== "undefined" ? bmOverlayList : null; const _bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
const allBookmarks = Array.isArray(_bmRef) ? _bmRef : []; const allBookmarks = Array.isArray(_bmRef) ? _bmRef : [];
const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz); const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz);
const leftBookmarks = allBookmarks const leftBookmarks = allBookmarks
@@ -7858,7 +7859,7 @@ function updateBookmarkAxis(range) {
// Only rebuild DOM when the set of visible bookmarks changes. // Only rebuild DOM when the set of visible bookmarks changes.
// Positions are always updated to handle pan/zoom smoothly. // Positions are always updated to handle pan/zoom smoothly.
const rev = typeof bmOverlayRevision !== "undefined" ? bmOverlayRevision : 0; const rev = window.trx.modules.bookmarks?.overlayRevision ?? 0;
const newKey = `${rev}:${visBookmarks.map((b) => b.id).join(",")}`; const newKey = `${rev}:${visBookmarks.map((b) => b.id).join(",")}`;
if (axisEl.dataset.bmKey !== newKey) { if (axisEl.dataset.bmKey !== newKey) {
axisEl.dataset.bmKey = newKey; axisEl.dataset.bmKey = newKey;
@@ -16,7 +16,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
const loaded = new Set<string>(); const loaded = new Set<string>();
const loading = new Map<string, Promise<void>>(); const loading = new Map<string, Promise<void>>();
const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js"]); const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js", "/bookmarks.js"]);
function loadLegacyScript(path: string): Promise<void> { function loadLegacyScript(path: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -2,38 +2,114 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
export {};
/* DOM IDs in the server-owned page are required by this feature; bmEl throws
* during initialization if that contract is broken. */
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
interface Bookmark {
id: string;
name: string;
freq_hz: number;
mode: string;
bandwidth_hz?: number | null;
locator?: string | null;
category?: string | null;
comment?: string | null;
decoders?: string[];
scope?: string;
}
interface DecoderDescriptor {
id: string;
label: string;
activation?: string;
active_modes?: string[];
bookmark_selectable?: boolean;
}
interface BookmarkService {
readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number;
refreshOverlay(): Promise<void>;
invalidateColors(): void;
apply(bookmark: Bookmark): void;
}
interface BookmarkBridge extends Window {
authEnabled?: boolean;
authRole?: string | null;
lastActiveRigId?: string | null;
lastRigIds?: string[];
lastRigDisplayNames?: Record<string, string>;
lastFreqHz?: number;
lastModeName?: string;
lastSpectrumData?: unknown;
currentBandwidthHz?: number;
modeEl?: HTMLSelectElement | null;
decoderRegistry?: DecoderDescriptor[];
trx?: { modules?: { bookmarks?: BookmarkService } };
trxUi: {
confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
notify?(message: string, options: { kind: "error" }): void;
};
syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void;
scheduleSpectrumDraw?(): void;
syncBandwidthInput?(bandwidthHz: number): void;
applyLocalTunedFrequency?(frequencyHz: number, force?: boolean): void;
vchanTakeSchedulerControl?(): Promise<void>;
vchanInterceptMode?(mode: string): Promise<boolean>;
vchanInterceptBandwidth?(bandwidthHz: number): Promise<boolean>;
setRigFrequency?(frequencyHz: number): Promise<unknown>;
postPath(path: string): Promise<unknown>;
onDecoderRegistryReady?(callback: () => void): void;
_freqOptimisticSeq?: number;
_freqOptimisticHz?: number;
}
type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement;
const bridge = window as unknown as BookmarkBridge;
function bmEl(id: string): BookmarkElement {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing bookmark element #${id}`);
return element as BookmarkElement;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
// --- Bookmarks Tab --- // --- Bookmarks Tab ---
/** Current bookmark scope: "general" or a rig remote name. */ /** Current bookmark scope: "general" or a rig remote name. */
let bmScope = "general"; let bmScope = "general";
/** Build the ?scope= query string for a given or current bookmark scope. */ /** Build the ?scope= query string for a given or current bookmark scope. */
function bmScopeParam(prefix, scope) { function bmScopeParam(prefix: boolean, scope?: string | null): string {
const sep = prefix ? "&" : "?"; const sep = prefix ? "&" : "?";
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope); return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
} }
var bmList = []; let bmList: Bookmark[] = [];
var bmRevision = 0;
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */ /** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
var bmOverlayList = []; let bmOverlayList: Bookmark[] = [];
var bmOverlayRevision = 0; let bmOverlayRevision = 0;
let bmFilteredList = []; let bmFilteredList: Bookmark[] = [];
let bmEditId = null; let bmEditScope: string | null = null;
let bmEditScope = null;
let bmCurrentPage = 1; let bmCurrentPage = 1;
const BM_PAGE_SIZE = 25; const BM_PAGE_SIZE = 25;
const bmSelected = new Set(); const bmSelected = new Set<string>();
function bmFmtFreq(hz) { function bmFmtFreq(hz: number): string {
if (!Number.isFinite(hz) || hz <= 0) return "--"; if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz"; if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz"; if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz"; if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
return hz + "\u202fHz"; return `${hz}\u202fHz`;
} }
function bmEsc(str) { function bmEsc(str: unknown): string {
const d = document.createElement("div"); const d = document.createElement("div");
d.appendChild(document.createTextNode(String(str))); d.appendChild(document.createTextNode(String(str)));
return d.innerHTML; return d.innerHTML;
@@ -41,23 +117,23 @@ function bmEsc(str) {
function bmCanControl() { function bmCanControl() {
return ( return (
(typeof authEnabled !== "undefined" && !authEnabled) || (typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled) ||
(typeof authRole !== "undefined" && authRole === "control") (typeof bridge.authRole !== "undefined" && bridge.authRole === "control")
); );
} }
// Show/hide the Add Bookmark / Select All buttons based on the current auth role. // Show/hide the Add Bookmark / Select All buttons based on the current auth role.
function bmSyncAccess() { function bmSyncAccess() {
const canCtrl = bmCanControl(); const canCtrl = bmCanControl();
const addBtn = document.getElementById("bm-add-btn"); const addBtn = bmEl("bm-add-btn");
const selectAllBtn = document.getElementById("bm-select-all-btn"); const selectAllBtn = bmEl("bm-select-all-btn");
if (addBtn) addBtn.style.display = canCtrl ? "" : "none"; if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none"; if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
} }
/** The listing scope: always the active rig (to merge general + rig bookmarks). */ /** The listing scope: always the active rig (to merge general + rig bookmarks). */
function bmListScope() { function bmListScope() {
const rig = (typeof lastActiveRigId !== "undefined") ? lastActiveRigId : null; const rig = (typeof bridge.lastActiveRigId !== "undefined") ? bridge.lastActiveRigId : null;
return rig || "general"; return rig || "general";
} }
@@ -65,20 +141,20 @@ async function bmFetchOverlay() {
const overlayScope = bmListScope(); const overlayScope = bmListScope();
try { try {
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope)); const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
if (!resp.ok) throw new Error("HTTP " + resp.status); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
bmOverlayList = await resp.json(); bmOverlayList = await resp.json() as Bookmark[];
} catch (e) { } catch (e) {
console.error("Failed to fetch overlay bookmarks:", e); console.error("Failed to fetch overlay bookmarks:", e);
bmOverlayList = []; bmOverlayList = [];
} }
bmOverlayRevision++; bmOverlayRevision++;
if (typeof window.syncBookmarkMapLocators === "function") { if (typeof bridge.syncBookmarkMapLocators === "function") {
window.syncBookmarkMapLocators(bmOverlayList); bridge.syncBookmarkMapLocators(bmOverlayList);
} }
if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw(); if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw();
} }
async function bmFetch(categoryFilter) { async function bmFetch(categoryFilter: string): Promise<void> {
let url = "/bookmarks"; let url = "/bookmarks";
let hasQuery = false; let hasQuery = false;
if (categoryFilter && categoryFilter !== "") { if (categoryFilter && categoryFilter !== "") {
@@ -89,26 +165,25 @@ async function bmFetch(categoryFilter) {
const overlayPromise = bmFetchOverlay(); const overlayPromise = bmFetchOverlay();
try { try {
const resp = await fetch(url); const resp = await fetch(url);
if (!resp.ok) throw new Error("HTTP " + resp.status); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
bmList = await resp.json(); bmList = await resp.json() as Bookmark[];
} catch (e) { } catch (e) {
console.error("Failed to fetch bookmarks:", e); console.error("Failed to fetch bookmarks:", e);
bmList = []; bmList = [];
} }
bmRevision++;
bmSelected.clear(); bmSelected.clear();
bmUpdateSelectionUi(); bmUpdateSelectionUi();
bmSyncAccess(); bmSyncAccess();
bmApplyFilters(); bmApplyFilters();
bmRefreshCategoryFilter(categoryFilter); void bmRefreshCategoryFilter(categoryFilter);
await overlayPromise; await overlayPromise;
} }
function bmApplyFilters() { function bmApplyFilters() {
const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase(); const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase();
const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase(); const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase();
let filtered = modeFilter let filtered = modeFilter
? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter) ? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter)
: bmList; : bmList;
filtered = text filtered = text
? filtered.filter((bm) => ? filtered.filter((bm) =>
@@ -123,14 +198,14 @@ function bmApplyFilters() {
bmRender(filtered); bmRender(filtered);
} }
async function bmRefreshCategoryFilter(keepValue) { async function bmRefreshCategoryFilter(keepValue: string): Promise<void> {
const sel = document.getElementById("bm-category-filter"); const sel = bmEl("bm-category-filter");
const modeSel = document.getElementById("bm-mode-filter"); const modeSel = bmEl("bm-mode-filter");
if (!sel && !modeSel) return; if (!sel && !modeSel) return;
try { try {
const resp = await fetch("/bookmarks" + bmScopeParam(false)); const resp = await fetch("/bookmarks" + bmScopeParam(false));
if (!resp.ok) return; if (!resp.ok) return;
const all = await resp.json(); const all = await resp.json() as Bookmark[];
if (sel) { if (sel) {
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort(); const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
while (sel.options.length > 1) sel.remove(1); while (sel.options.length > 1) sel.remove(1);
@@ -144,7 +219,7 @@ async function bmRefreshCategoryFilter(keepValue) {
} }
if (modeSel) { if (modeSel) {
const keepMode = modeSel.value; const keepMode = modeSel.value;
const modes = [...new Set(all.map((b) => String(b.mode || "").trim().toUpperCase()).filter(Boolean))].sort(); const modes = [...new Set(all.map((b) => (b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
while (modeSel.options.length > 1) modeSel.remove(1); while (modeSel.options.length > 1) modeSel.remove(1);
modes.forEach((mode) => { modes.forEach((mode) => {
const opt = document.createElement("option"); const opt = document.createElement("option");
@@ -154,17 +229,17 @@ async function bmRefreshCategoryFilter(keepValue) {
}); });
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode; if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
} }
} catch (_) {} } catch {}
} }
function bmRender(list) { function bmRender(list: Bookmark[]): void {
const tbody = document.getElementById("bm-tbody"); const tbody = bmEl("bm-tbody");
const emptyEl = document.getElementById("bm-empty"); const emptyEl = bmEl("bm-empty");
const paginatorEl = document.getElementById("bm-paginator"); const paginatorEl = bmEl("bm-paginator");
const pageSummaryEl = document.getElementById("bm-page-summary"); const pageSummaryEl = bmEl("bm-page-summary");
const pageIndicatorEl = document.getElementById("bm-page-indicator"); const pageIndicatorEl = bmEl("bm-page-indicator");
const prevBtn = document.getElementById("bm-page-prev"); const prevBtn = bmEl("bm-page-prev");
const nextBtn = document.getElementById("bm-page-next"); const nextBtn = bmEl("bm-page-next");
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = ""; tbody.innerHTML = "";
@@ -222,7 +297,7 @@ function bmRender(list) {
if (nextBtn) nextBtn.disabled = page >= totalPages; if (nextBtn) nextBtn.disabled = page >= totalPages;
} }
function bmChangePage(delta) { function bmChangePage(delta: number): void {
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages); const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
if (nextPage === bmCurrentPage) return; if (nextPage === bmCurrentPage) return;
@@ -231,30 +306,30 @@ function bmChangePage(delta) {
} }
// Read decoder checkboxes and return an array of selected decoder names. // Read decoder checkboxes and return an array of selected decoder names.
function bmReadDecoders() { function bmReadDecoders(): string[] {
return (window.decoderRegistry || []) return (bridge.decoderRegistry || [])
.filter(d => d.bookmark_selectable) .filter(d => d.bookmark_selectable)
.filter(d => document.getElementById("bm-dec-" + d.id)?.checked) .filter(d => bmEl("bm-dec-" + d.id)?.checked)
.map(d => d.id); .map(d => d.id);
} }
// Set decoder checkboxes to match the given array. // Set decoder checkboxes to match the given array.
function bmWriteDecoders(decoders) { function bmWriteDecoders(decoders: readonly string[]): void {
const set = new Set(decoders || []); const set = new Set(decoders || []);
(window.decoderRegistry || []) (bridge.decoderRegistry || [])
.filter(d => d.bookmark_selectable) .filter(d => d.bookmark_selectable)
.forEach(d => { .forEach(d => {
const el = document.getElementById("bm-dec-" + d.id); const el = bmEl("bm-dec-" + d.id);
if (el) el.checked = set.has(d.id); if (el) el.checked = set.has(d.id);
}); });
} }
// Build decoder checkboxes dynamically from the registry. // Build decoder checkboxes dynamically from the registry.
function bmBuildDecoderCheckboxes() { function bmBuildDecoderCheckboxes() {
const container = document.getElementById("bm-decoder-checkboxes"); const container = bmEl("bm-decoder-checkboxes");
if (!container) return; if (!container) return;
container.innerHTML = ""; container.innerHTML = "";
(window.decoderRegistry || []) (bridge.decoderRegistry || [])
.filter(d => d.bookmark_selectable) .filter(d => d.bookmark_selectable)
.forEach(d => { .forEach(d => {
const label = document.createElement("label"); const label = document.createElement("label");
@@ -264,79 +339,77 @@ function bmBuildDecoderCheckboxes() {
}); });
} }
function bmOpenForm(bm) { function bmOpenForm(bm: Bookmark | null): void {
const wrap = document.getElementById("bm-form-wrap"); const wrap = bmEl("bm-form-wrap");
if (!wrap) return; if (!wrap) return;
bmEditId = bm ? bm.id : null;
bmEditScope = bm ? (bm.scope || bmScope) : null; bmEditScope = bm ? (bm.scope || bmScope) : null;
// Rebuild decoder checkboxes from registry (handles race where registry // Rebuild decoder checkboxes from registry (handles race where registry
// loaded after initial build). // loaded after initial build).
bmBuildDecoderCheckboxes(); bmBuildDecoderCheckboxes();
document.getElementById("bm-id").value = bm ? bm.id : ""; bmEl("bm-id").value = bm ? bm.id : "";
document.getElementById("bm-name").value = bm ? bm.name : ""; bmEl("bm-name").value = bm ? bm.name : "";
document.getElementById("bm-freq").value = bm ? bm.freq_hz : ""; bmEl("bm-freq").value = bm ? String(bm.freq_hz) : "";
document.getElementById("bm-mode").value = bm ? bm.mode : ""; bmEl("bm-mode").value = bm ? bm.mode : "";
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : ""; bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : "";
document.getElementById("bm-locator").value = bm ? (bm.locator || "") : ""; bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
document.getElementById("bm-category-input").value = bm ? (bm.category || "") : ""; bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : ""; bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
bmWriteDecoders(bm ? bm.decoders : []); bmWriteDecoders(bm?.decoders ?? []);
document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark"; bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex"; wrap.style.display = "flex";
document.getElementById("bm-name").focus(); bmEl("bm-name").focus();
} }
function bmCloseForm() { function bmCloseForm() {
const wrap = document.getElementById("bm-form-wrap"); const wrap = bmEl("bm-form-wrap");
if (wrap) wrap.style.display = "none"; if (wrap) wrap.style.display = "none";
bmEditId = null;
} }
function bmPrefillFromStatus() { function bmPrefillFromStatus() {
// Use globals maintained by app.js (updated by SSE stream) // Use globals maintained by app.js (updated by SSE stream)
if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) { if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) {
document.getElementById("bm-freq").value = Math.round(lastFreqHz); bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz));
} }
if (typeof lastModeName === "string" && lastModeName) { if (typeof bridge.lastModeName === "string" && bridge.lastModeName) {
document.getElementById("bm-mode").value = lastModeName; bmEl("bm-mode").value = bridge.lastModeName;
} }
if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) { if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) {
document.getElementById("bm-bw").value = Math.round(currentBandwidthHz); bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz));
} }
// Prefill decoder checkboxes from current toggle button state. // Prefill decoder checkboxes from current toggle button state.
const activeDecoders = (window.decoderRegistry || []) const activeDecoders = (bridge.decoderRegistry || [])
.filter(d => d.bookmark_selectable && d.activation === "toggle") .filter(d => d.bookmark_selectable && d.activation === "toggle")
.filter(d => { .filter(d => {
const btn = document.getElementById(d.id + "-decode-toggle-btn"); const btn = bmEl(d.id + "-decode-toggle-btn");
return btn && btn.dataset.enabled === "true"; return btn && btn.dataset.enabled === "true";
}) })
.map(d => d.id); .map(d => d.id);
bmWriteDecoders(activeDecoders); bmWriteDecoders(activeDecoders);
} }
async function bmSave(e) { async function bmSave(e: Event): Promise<void> {
e.preventDefault(); e.preventDefault();
const id = document.getElementById("bm-id").value; const id = bmEl("bm-id").value;
const name = document.getElementById("bm-name").value.trim(); const name = bmEl("bm-name").value.trim();
const freqStr = document.getElementById("bm-freq").value; const freqStr = bmEl("bm-freq").value;
const freq_hz = parseInt(freqStr, 10); const freq_hz = parseInt(freqStr, 10);
const mode = document.getElementById("bm-mode").value.trim(); const mode = bmEl("bm-mode").value.trim();
const bwStr = document.getElementById("bm-bw").value; const bwStr = bmEl("bm-bw").value;
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null; const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
const locator = document.getElementById("bm-locator").value.trim().toUpperCase(); const locator = bmEl("bm-locator").value.trim().toUpperCase();
const category = document.getElementById("bm-category-input").value.trim(); const category = bmEl("bm-category-input").value.trim();
const comment = document.getElementById("bm-comment").value.trim(); const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders(); const decoders = bmReadDecoders();
const formError = document.getElementById("bm-form-error"); const formError = bmEl("bm-form-error");
if (formError) formError.textContent = ""; if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) { if (!name || !Number.isFinite(freq_hz) || !mode) {
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode."; if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
const invalid = !name ? document.getElementById("bm-name") const invalid = !name ? bmEl("bm-name")
: !Number.isFinite(freq_hz) ? document.getElementById("bm-freq") : document.getElementById("bm-mode"); : !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode");
invalid?.focus(); invalid?.focus();
return; return;
} }
@@ -372,90 +445,90 @@ async function bmSave(e) {
if (resp.status === 409) { if (resp.status === 409) {
throw new Error("A bookmark for that frequency already exists."); throw new Error("A bookmark for that frequency already exists.");
} }
throw new Error(text || "HTTP " + resp.status); throw new Error(text || `HTTP ${resp.status}`);
} }
bmCloseForm(); bmCloseForm();
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(bmEl("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to save bookmark:", err); console.error("Failed to save bookmark:", err);
if (formError) formError.textContent = "Failed to save bookmark: " + err.message; if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err);
window.trxUi?.notify("Bookmark could not be saved", { kind: "error" }); bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" });
} }
} }
async function bmDelete(id) { async function bmDelete(id: string): Promise<void> {
if (!await window.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return; if (!await bridge.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
const bm = bmList.find((b) => b.id === id); const bm = bmList.find((b) => b.id === id);
const scope = bm ? bm.scope : undefined; const scope = bm ? bm.scope : undefined;
try { try {
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), { const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
method: "DELETE", method: "DELETE",
}); });
if (!resp.ok) throw new Error("HTTP " + resp.status); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(bmEl("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to delete bookmark:", err); console.error("Failed to delete bookmark:", err);
window.trxUi?.notify("Failed to delete bookmark: " + err.message, { kind: "error" }); bridge.trxUi.notify?.("Failed to delete bookmark: " + errorMessage(err), { kind: "error" });
} }
} }
async function bmApply(bm) { function bmApply(bm: Bookmark): void {
try { try {
// --- Optimistic UI updates (instant, before any network round-trips) --- // --- Optimistic UI updates (instant, before any network round-trips) ---
if (typeof modeEl !== "undefined" && modeEl) { if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) {
modeEl.value = String(bm.mode || "").toUpperCase(); bridge.modeEl.value = (bm.mode || "").toUpperCase();
} }
if (bm.bandwidth_hz) { if (bm.bandwidth_hz) {
if (typeof currentBandwidthHz !== "undefined") { if (typeof bridge.currentBandwidthHz !== "undefined") {
currentBandwidthHz = bm.bandwidth_hz; bridge.currentBandwidthHz = bm.bandwidth_hz;
} }
window.currentBandwidthHz = bm.bandwidth_hz; bridge.currentBandwidthHz = bm.bandwidth_hz;
if (typeof syncBandwidthInput === "function") { if (typeof bridge.syncBandwidthInput === "function") {
syncBandwidthInput(bm.bandwidth_hz); bridge.syncBandwidthInput(bm.bandwidth_hz);
} }
} }
if (typeof applyLocalTunedFrequency === "function") { if (typeof bridge.applyLocalTunedFrequency === "function") {
// Set optimistic guard before applying so SSE cannot snap back. // Set optimistic guard before applying so SSE cannot snap back.
if (typeof _freqOptimisticSeq !== "undefined") { if (typeof bridge._freqOptimisticSeq !== "undefined") {
++_freqOptimisticSeq; ++bridge._freqOptimisticSeq;
_freqOptimisticHz = bm.freq_hz; bridge._freqOptimisticHz = bm.freq_hz;
} }
// Force display so the BW overlay is repositioned even when freq is unchanged. // Force display so the BW overlay is repositioned even when freq is unchanged.
applyLocalTunedFrequency(bm.freq_hz, true); bridge.applyLocalTunedFrequency(bm.freq_hz, true);
} }
if (typeof scheduleSpectrumDraw === "function" && typeof lastSpectrumData !== "undefined" && lastSpectrumData) { if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
scheduleSpectrumDraw(); bridge.scheduleSpectrumDraw();
} }
// Take scheduler control up front, then apply mode before bandwidth so a // Take scheduler control up front, then apply mode before bandwidth so a
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz. // late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
const tunePromise = (async () => { const tunePromise = (async () => {
if (typeof vchanTakeSchedulerControl === "function") { if (typeof bridge.vchanTakeSchedulerControl === "function") {
await vchanTakeSchedulerControl(); await bridge.vchanTakeSchedulerControl();
} }
const onVirtual = typeof vchanInterceptMode === "function" const onVirtual = typeof bridge.vchanInterceptMode === "function"
&& await vchanInterceptMode(bm.mode); && await bridge.vchanInterceptMode(bm.mode);
if (!onVirtual) { if (!onVirtual) {
await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
} }
if (bm.bandwidth_hz) { if (bm.bandwidth_hz) {
const bwHandledByVchan = typeof vchanInterceptBandwidth === "function" const bwHandledByVchan = typeof bridge.vchanInterceptBandwidth === "function"
&& await vchanInterceptBandwidth(bm.bandwidth_hz); && await bridge.vchanInterceptBandwidth(bm.bandwidth_hz);
if (!bwHandledByVchan) { if (!bwHandledByVchan) {
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz); await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
} }
} }
// setRigFrequency is wrapped by vchan.js to redirect to the channel API // bridge.setRigFrequency is wrapped by vchan.js to redirect to the channel API
// when on a virtual channel, so this call works correctly in both cases. // when on a virtual channel, so this call works correctly in both cases.
// It also does its own optimistic update (applyLocalTunedFrequency) but // It also does its own optimistic update (bridge.applyLocalTunedFrequency) but
// that's a no-op since we already set the same value above. // that's a no-op since we already set the same value above.
if (typeof setRigFrequency === "function") { if (typeof bridge.setRigFrequency === "function") {
await setRigFrequency(bm.freq_hz); await bridge.setRigFrequency(bm.freq_hz);
} else { } else {
await postPath("/set_freq?hz=" + bm.freq_hz); await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
} }
})(); })();
// Decoder toggles — fire-and-forget. // Decoder toggles — fire-and-forget.
@@ -466,18 +539,18 @@ async function bmApply(bm) {
// alone. // alone.
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const modeUp = (bm.mode || "").toUpperCase(); const modeUp = (bm.mode || "").toUpperCase();
const allToggleDecoders = (window.decoderRegistry || []).filter(d => const allToggleDecoders = (bridge.decoderRegistry || []).filter(d =>
d.activation === "toggle" d.activation === "toggle"
); );
const decoderPromise = allToggleDecoders.length ? (async () => { const decoderPromise = allToggleDecoders.length ? (async () => {
let statusUrl = "/status"; let statusUrl = "/status";
if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) { if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) {
statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId); statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId);
} }
const statusResp = await fetch(statusUrl); const statusResp = await fetch(statusUrl);
if (!statusResp.ok) return; if (!statusResp.ok) return;
const st = await statusResp.json(); const st = await statusResp.json() as Record<string, unknown>;
const toggles = []; const toggles: Promise<unknown>[] = [];
for (const d of allToggleDecoders) { for (const d of allToggleDecoders) {
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled"; const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
const currentlyOn = !!st[statusKey]; const currentlyOn = !!st[statusKey];
@@ -488,41 +561,51 @@ async function bmApply(bm) {
// Always disable decoders that don't apply to the new mode. // Always disable decoders that don't apply to the new mode.
wanted = false; wanted = false;
} else if (hasDecoders) { } else if (hasDecoders) {
wanted = bm.decoders.includes(d.id); wanted = bm.decoders?.includes(d.id) ?? false;
} else { } else {
// Mode-compatible and no bookmark selection: leave as-is. // Mode-compatible and no bookmark selection: leave as-is.
wanted = currentlyOn; wanted = currentlyOn;
} }
if (wanted !== currentlyOn) { if (wanted !== currentlyOn) {
toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
} }
} }
if (toggles.length) await Promise.all(toggles); if (toggles.length) await Promise.all(toggles);
})() : Promise.resolve(); })() : Promise.resolve();
// Don't await — let the network calls settle in the background. // Don't await — let the network calls settle in the background.
// Errors are logged but don't block the UI. // Errors are logged but don't block the UI.
Promise.all([tunePromise, decoderPromise]).catch( void Promise.all([tunePromise, decoderPromise]).catch((error: unknown) => {
(err) => console.error("Bookmark apply background error:", err) console.error("Bookmark apply background error:", error);
); });
} catch (err) { } catch (err) {
console.error("Failed to apply bookmark:", err); console.error("Failed to apply bookmark:", err);
} }
} }
bridge.trx ??= {};
bridge.trx.modules ??= {};
bridge.trx.modules.bookmarks = {
get overlayList() { return bmOverlayList; },
get overlayRevision() { return bmOverlayRevision; },
refreshOverlay: bmFetchOverlay,
invalidateColors() { bmOverlayRevision += 1; },
apply: bmApply,
};
function bmUpdateSelectionUi() { function bmUpdateSelectionUi() {
const count = bmSelected.size; const count = bmSelected.size;
const canCtrl = bmCanControl(); const canCtrl = bmCanControl();
const visible = count > 0 && canCtrl; const visible = count > 0 && canCtrl;
const btn = document.getElementById("bm-del-selected-btn"); const btn = bmEl("bm-del-selected-btn");
const countEl = document.getElementById("bm-del-selected-count"); const countEl = bmEl("bm-del-selected-count");
if (btn) btn.style.display = visible ? "" : "none"; if (btn) btn.style.display = visible ? "" : "none";
if (countEl) countEl.textContent = count; if (countEl) countEl.textContent = String(count);
const moveWrap = document.getElementById("bm-move-selected-wrap"); const moveWrap = bmEl("bm-move-selected-wrap");
const moveCountEl = document.getElementById("bm-move-selected-count"); const moveCountEl = bmEl("bm-move-selected-count");
if (moveWrap) moveWrap.style.display = visible ? "" : "none"; if (moveWrap) moveWrap.style.display = visible ? "" : "none";
if (moveCountEl) moveCountEl.textContent = count; if (moveCountEl) moveCountEl.textContent = String(count);
if (visible) bmPopulateMoveTarget(); if (visible) bmPopulateMoveTarget();
const selectAllBtn = document.getElementById("bm-select-all-btn"); const selectAllBtn = bmEl("bm-select-all-btn");
if (selectAllBtn && bmCanControl()) { if (selectAllBtn && bmCanControl()) {
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id)); const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All"; selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
@@ -531,10 +614,10 @@ function bmUpdateSelectionUi() {
/** Populate the move-target dropdown with all scopes except the current one. */ /** Populate the move-target dropdown with all scopes except the current one. */
function bmPopulateMoveTarget() { function bmPopulateMoveTarget() {
const sel = document.getElementById("bm-move-target"); const sel = bmEl("bm-move-target");
if (!sel) return; if (!sel) return;
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : []; const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : [];
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {}; const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {};
const prev = sel.value; const prev = sel.value;
sel.innerHTML = ""; sel.innerHTML = "";
if (bmScope !== "general") { if (bmScope !== "general") {
@@ -558,10 +641,10 @@ function bmPopulateMoveTarget() {
async function bmMoveSelected() { async function bmMoveSelected() {
const ids = Array.from(bmSelected); const ids = Array.from(bmSelected);
if (ids.length === 0) return; if (ids.length === 0) return;
const target = document.getElementById("bm-move-target")?.value; const target = bmEl("bm-move-target")?.value;
if (!target) return; if (!target) return;
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target; const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target;
if (!await window.trxUi.confirm({ if (!await bridge.trxUi.confirm({
title: "Move selected bookmarks?", title: "Move selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`, message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
confirmLabel: "Move", confirmLabel: "Move",
@@ -569,7 +652,7 @@ async function bmMoveSelected() {
})) return; })) return;
try { try {
// Group selected IDs by their owning scope (skip if already in target). // Group selected IDs by their owning scope (skip if already in target).
const byScope = {}; const byScope: Record<string, string[]> = {};
for (const id of ids) { for (const id of ids) {
const bm = bmList.find((b) => b.id === id); const bm = bmList.find((b) => b.id === id);
const scope = bm?.scope || bmScope; const scope = bm?.scope || bmScope;
@@ -581,21 +664,21 @@ async function bmMoveSelected() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: scopeIds, to: target }), body: JSON.stringify({ ids: scopeIds, to: target }),
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); }) }).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
)); ));
bmSelected.clear(); bmSelected.clear();
bmUpdateSelectionUi(); bmUpdateSelectionUi();
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(bmEl("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to move bookmarks:", err); console.error("Failed to move bookmarks:", err);
window.trxUi?.notify("Failed to move bookmarks: " + err.message, { kind: "error" }); bridge.trxUi.notify?.("Failed to move bookmarks: " + errorMessage(err), { kind: "error" });
} }
} }
function bmSyncSelectAllCheckbox() { function bmSyncSelectAllCheckbox() {
const selectAll = document.getElementById("bm-select-all"); const selectAll = bmEl("bm-select-all");
if (!selectAll) return; if (!selectAll) return;
const checkboxes = document.querySelectorAll(".bm-row-sel"); const checkboxes = document.querySelectorAll<HTMLInputElement>(".bm-row-sel");
if (checkboxes.length === 0) { if (checkboxes.length === 0) {
selectAll.checked = false; selectAll.checked = false;
selectAll.indeterminate = false; selectAll.indeterminate = false;
@@ -609,14 +692,14 @@ function bmSyncSelectAllCheckbox() {
async function bmDeleteSelected() { async function bmDeleteSelected() {
const ids = Array.from(bmSelected); const ids = Array.from(bmSelected);
if (ids.length === 0) return; if (ids.length === 0) return;
if (!await window.trxUi.confirm({ if (!await bridge.trxUi.confirm({
title: "Delete selected bookmarks?", title: "Delete selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`, message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
confirmLabel: "Delete", confirmLabel: "Delete",
})) return; })) return;
try { try {
// Group selected IDs by their owning scope. // Group selected IDs by their owning scope.
const byScope = {}; const byScope: Record<string, string[]> = {};
for (const id of ids) { for (const id of ids) {
const bm = bmList.find((b) => b.id === id); const bm = bmList.find((b) => b.id === id);
const scope = bm?.scope || bmScope; const scope = bm?.scope || bmScope;
@@ -627,23 +710,23 @@ async function bmDeleteSelected() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: scopeIds }), body: JSON.stringify({ ids: scopeIds }),
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); }) }).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
)); ));
bmSelected.clear(); bmSelected.clear();
bmUpdateSelectionUi(); bmUpdateSelectionUi();
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(bmEl("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to delete bookmarks:", err); console.error("Failed to delete bookmarks:", err);
window.trxUi?.notify("Failed to delete bookmarks: " + err.message, { kind: "error" }); bridge.trxUi.notify?.("Failed to delete bookmarks: " + errorMessage(err), { kind: "error" });
} }
} }
/** Populate the scope picker with "General" + one option per rig. */ /** Populate the scope picker with "General" + one option per rig. */
function bmPopulateScopePicker() { function bmPopulateScopePicker() {
const picker = document.getElementById("bm-scope-picker"); const picker = bmEl("bm-scope-picker");
if (!picker) return; if (!picker) return;
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : []; const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : [];
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {}; const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {};
// Preserve current selection if still valid. // Preserve current selection if still valid.
const prev = picker.value; const prev = picker.value;
while (picker.options.length > 1) picker.remove(1); while (picker.options.length > 1) picker.remove(1);
@@ -670,63 +753,63 @@ function bmPopulateScopePicker() {
// Build decoder checkboxes from registry. The registry is fetched async // Build decoder checkboxes from registry. The registry is fetched async
// so we rebuild once it arrives to ensure checkboxes are present. // so we rebuild once it arrives to ensure checkboxes are present.
bmBuildDecoderCheckboxes(); bmBuildDecoderCheckboxes();
if (typeof window.onDecoderRegistryReady === "function") { if (typeof bridge.onDecoderRegistryReady === "function") {
window.onDecoderRegistryReady(bmBuildDecoderCheckboxes); bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
} }
// Scope picker // Scope picker
bmPopulateScopePicker(); bmPopulateScopePicker();
const scopePicker = document.getElementById("bm-scope-picker"); const scopePicker = bmEl("bm-scope-picker");
if (scopePicker) { if (scopePicker) {
scopePicker.addEventListener("change", (e) => { scopePicker.addEventListener("change", (e) => {
bmScope = e.target.value; bmScope = (e.currentTarget as HTMLSelectElement).value;
bmFetch(document.getElementById("bm-category-filter")?.value || ""); void bmFetch(bmEl("bm-category-filter").value || "");
}); });
} }
// Refresh list and sync access when the Bookmarks tab is activated // Refresh list and sync access when the Bookmarks tab is activated
document.querySelector(".tab-bar").addEventListener("click", (e) => { document.querySelector(".tab-bar")?.addEventListener("click", (e) => {
const btn = e.target.closest('.tab[data-tab="bookmarks"]'); const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null;
if (!btn) return; if (!btn) return;
bmFetch(document.getElementById("bm-category-filter").value); void bmFetch(bmEl("bm-category-filter").value);
}); });
// Add Bookmark button — open form and prefill from current rig state // Add Bookmark button — open form and prefill from current rig state
document.getElementById("bm-add-btn").addEventListener("click", () => { bmEl("bm-add-btn").addEventListener("click", () => {
bmOpenForm(null); bmOpenForm(null);
bmPrefillFromStatus(); bmPrefillFromStatus();
}); });
// Category filter dropdown // Category filter dropdown
document.getElementById("bm-category-filter").addEventListener("change", (e) => { bmEl("bm-category-filter").addEventListener("change", (e) => {
bmFetch(e.target.value); void bmFetch((e.currentTarget as HTMLSelectElement).value);
}); });
// Mode filter dropdown (client-side, no re-fetch) // Mode filter dropdown (client-side, no re-fetch)
document.getElementById("bm-mode-filter").addEventListener("change", () => { bmEl("bm-mode-filter").addEventListener("change", () => {
bmApplyFilters(); bmApplyFilters();
}); });
// Text search filter (client-side, no re-fetch) // Text search filter (client-side, no re-fetch)
document.getElementById("bm-text-filter").addEventListener("input", () => { bmEl("bm-text-filter").addEventListener("input", () => {
bmApplyFilters(); bmApplyFilters();
}); });
document.getElementById("bm-page-prev").addEventListener("click", () => { bmEl("bm-page-prev").addEventListener("click", () => {
bmChangePage(-1); bmChangePage(-1);
}); });
document.getElementById("bm-page-next").addEventListener("click", () => { bmEl("bm-page-next").addEventListener("click", () => {
bmChangePage(1); bmChangePage(1);
}); });
// Form submit // Form submit
document.getElementById("bm-form").addEventListener("submit", bmSave); bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); });
// Form cancel // Form cancel
document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm); bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
const formWrap = document.getElementById("bm-form-wrap"); const formWrap = bmEl("bm-form-wrap");
if (formWrap) { if (formWrap) {
formWrap.addEventListener("click", (e) => { formWrap.addEventListener("click", (e) => {
if (e.target === formWrap) bmCloseForm(); if (e.target === formWrap) bmCloseForm();
@@ -734,24 +817,26 @@ function bmPopulateScopePicker() {
} }
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") { if (e.key === "Escape" && bmEl("bm-form-wrap")?.style.display === "flex") {
bmCloseForm(); bmCloseForm();
} }
}); });
// Select-all checkbox // Select-all checkbox
document.getElementById("bm-select-all").addEventListener("change", (e) => { bmEl("bm-select-all").addEventListener("change", (e) => {
const checked = e.target.checked; const checked = (e.currentTarget as HTMLInputElement).checked;
document.querySelectorAll(".bm-row-sel").forEach((cb) => { document.querySelectorAll<HTMLInputElement>(".bm-row-sel").forEach((cb) => {
cb.checked = checked; cb.checked = checked;
if (checked) bmSelected.add(cb.dataset.bmId); const id = cb.dataset.bmId;
else bmSelected.delete(cb.dataset.bmId); if (!id) return;
if (checked) bmSelected.add(id);
else bmSelected.delete(id);
}); });
bmUpdateSelectionUi(); bmUpdateSelectionUi();
}); });
// Select All (across all pages) button // Select All (across all pages) button
document.getElementById("bm-select-all-btn").addEventListener("click", () => { bmEl("bm-select-all-btn").addEventListener("click", () => {
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id)); const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
if (allSelected) { if (allSelected) {
bmSelected.clear(); bmSelected.clear();
@@ -759,49 +844,53 @@ function bmPopulateScopePicker() {
bmFilteredList.forEach((bm) => bmSelected.add(bm.id)); bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
} }
// Sync visible page checkboxes // Sync visible page checkboxes
document.querySelectorAll(".bm-row-sel").forEach((cb) => { document.querySelectorAll<HTMLInputElement>(".bm-row-sel").forEach((cb) => {
cb.checked = bmSelected.has(cb.dataset.bmId); cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId);
}); });
bmSyncSelectAllCheckbox(); bmSyncSelectAllCheckbox();
bmUpdateSelectionUi(); bmUpdateSelectionUi();
}); });
// Delete Selected button // Delete Selected button
document.getElementById("bm-del-selected-btn").addEventListener("click", () => { bmEl("bm-del-selected-btn").addEventListener("click", () => {
bmDeleteSelected(); void bmDeleteSelected();
}); });
// Move Selected button // Move Selected button
document.getElementById("bm-move-selected-btn").addEventListener("click", () => { bmEl("bm-move-selected-btn").addEventListener("click", () => {
bmMoveSelected(); void bmMoveSelected();
}); });
// Table action buttons and row checkboxes (event delegation) // Table action buttons and row checkboxes (event delegation)
document.getElementById("bm-tbody").addEventListener("click", async (e) => { bmEl("bm-tbody").addEventListener("click", (e) => { void (async () => {
const checkbox = e.target.closest(".bm-row-sel"); if (!(e.target instanceof Element)) return;
const checkbox = e.target.closest<HTMLInputElement>(".bm-row-sel");
if (checkbox) { if (checkbox) {
if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId); const id = checkbox.dataset.bmId;
else bmSelected.delete(checkbox.dataset.bmId); if (!id) return;
if (checkbox.checked) bmSelected.add(id);
else bmSelected.delete(id);
bmSyncSelectAllCheckbox(); bmSyncSelectAllCheckbox();
bmUpdateSelectionUi(); bmUpdateSelectionUi();
return; return;
} }
const tuneBtn = e.target.closest(".bm-tune-btn"); const tuneBtn = e.target.closest<HTMLElement>(".bm-tune-btn");
const editBtn = e.target.closest(".bm-edit-btn"); const editBtn = e.target.closest<HTMLElement>(".bm-edit-btn");
const delBtn = e.target.closest(".bm-del-btn"); const delBtn = e.target.closest<HTMLElement>(".bm-del-btn");
if (tuneBtn) { if (tuneBtn) {
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId); const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
if (bm) await bmApply(bm); if (bm) bmApply(bm);
} else if (editBtn) { } else if (editBtn) {
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId); const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
if (bm) bmOpenForm(bm); if (bm) bmOpenForm(bm);
} else if (delBtn) { } else if (delBtn) {
await bmDelete(delBtn.dataset.bmId); const id = delBtn.dataset.bmId;
if (id) await bmDelete(id);
} }
}); })(); });
// Pre-load bookmarks so spectrum markers are visible immediately. // Pre-load bookmarks so spectrum markers are visible immediately.
bmFetch(""); void bmFetch("");
})(); })();
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
class ElementFixture {
constructor() {
this.value = "";
this.textContent = "";
this.innerHTML = "";
this.style = {};
this.dataset = {};
this.options = [];
this.selectedOptions = [];
this.children = [];
}
addEventListener() {}
appendChild(child) { this.children.push(child); return child; }
add(child) { this.options.push(child); }
remove(index) { this.options.splice(index, 1); }
focus() {}
querySelector() { return null; }
}
test("bookmarks register an explicit typed service for application consumers", async () => {
const elements = new Map();
const element = (id) => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
element("bm-scope-picker").value = "general";
element("bm-category-filter").options.push({ value: "" });
element("bm-mode-filter").options.push({ value: "" });
const bookmarks = [{ id: "one", name: "Local", freq_hz: 145_500_000, mode: "FM", scope: "general" }];
const window = {
trx: { modules: {} },
trxUi: { confirm: async () => true },
decoderRegistry: [],
};
const context = vm.createContext({
window,
document: {
getElementById: element,
querySelector: () => new ElementFixture(),
querySelectorAll: () => [],
createElement: () => new ElementFixture(),
createTextNode: (text) => ({ textContent: text }),
addEventListener() {},
},
fetch: async () => ({ ok: true, json: async () => bookmarks }),
CSS: { escape: (value) => value },
Element: ElementFixture,
Set,
Map,
Array,
Number,
String,
Promise,
console,
});
const source = await readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8");
new vm.Script(source).runInContext(context);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(window.trx.modules.bookmarks.overlayList.length, 1);
assert.equal(window.trx.modules.bookmarks.overlayList[0].id, "one");
assert.equal(typeof window.trx.modules.bookmarks.apply, "function");
assert.equal(globalThis.bmOverlayList, undefined);
});