diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
index 63f36ff8..67087b20 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js
@@ -802,11 +802,12 @@ function setTheme(theme) {
invalidateBookmarkColors();
}
function invalidateBookmarkColors() {
- if (typeof bmOverlayRevision === "undefined") return;
- bmOverlayRevision++;
+ const bookmarks = window.trx.modules.bookmarks;
+ if (!bookmarks) return;
+ bookmarks.invalidateColors();
void getComputedStyle(document.documentElement).getPropertyValue("--bg");
const colorMap = bmCategoryColorMap();
- const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : [];
+ const ref = bookmarks.overlayList;
document.querySelectorAll(".spectrum-bookmark-chip").forEach((chip) => {
const bm = ref.find((b) => b.id === chip.dataset.bmId);
if (!bm) return;
@@ -1450,7 +1451,7 @@ function drawSignalOverlay() {
const bwEdge = BW_OVERLAY_COLORS.edge;
const bwStroke = BW_OVERLAY_COLORS.stroke;
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) {
const colorMap = bmCategoryColorMap();
const grouped = /* @__PURE__ */ new Map();
@@ -4625,7 +4626,7 @@ function buildBookmarkTooltipText(bm) {
return text;
}
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) {
return null;
}
@@ -7189,7 +7190,7 @@ function bmThemePalette() {
];
}
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 palette = bmThemePalette();
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-fg", bmContrastFg(col));
span.addEventListener("click", () => {
- if (typeof bmApply === "function") bmApply(bm);
+ void window.trx.modules.bookmarks?.apply(bm);
});
return span;
}
function updateSideBookmarkStack(container, bookmarks, colorMap) {
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(",")}` : "";
if (!Array.isArray(bookmarks) || bookmarks.length === 0) {
if (container.dataset.bmKey) {
@@ -7246,7 +7247,7 @@ function updateBookmarkAxis(range) {
const leftSideEl = document.getElementById("spectrum-bookmark-side-left");
const rightSideEl = document.getElementById("spectrum-bookmark-side-right");
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 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);
@@ -7263,7 +7264,7 @@ function updateBookmarkAxis(range) {
}
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(",")}`;
if (axisEl.dataset.bmKey !== newKey) {
axisEl.dataset.bmKey = newKey;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js
index d38bd3d1..d33c6ad0 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js
@@ -1,647 +1,679 @@
"use strict";
-let bmScope = "general";
-function bmScopeParam(prefix, scope) {
- const sep = prefix ? "&" : "?";
- return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
-}
-var bmList = [];
-var bmRevision = 0;
-var bmOverlayList = [];
-var bmOverlayRevision = 0;
-let bmFilteredList = [];
-let bmEditId = null;
-let bmEditScope = null;
-let bmCurrentPage = 1;
-const BM_PAGE_SIZE = 25;
-const bmSelected = /* @__PURE__ */ new Set();
-function bmFmtFreq(hz) {
- if (!Number.isFinite(hz) || hz <= 0) return "--";
- if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + " GHz";
- if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + " MHz";
- if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + " kHz";
- return hz + " Hz";
-}
-function bmEsc(str) {
- const d = document.createElement("div");
- d.appendChild(document.createTextNode(String(str)));
- return d.innerHTML;
-}
-function bmCanControl() {
- return typeof authEnabled !== "undefined" && !authEnabled || typeof authRole !== "undefined" && authRole === "control";
-}
-function bmSyncAccess() {
- const canCtrl = bmCanControl();
- const addBtn = document.getElementById("bm-add-btn");
- const selectAllBtn = document.getElementById("bm-select-all-btn");
- if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
- if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
-}
-function bmListScope() {
- const rig = typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null;
- return rig || "general";
-}
-async function bmFetchOverlay() {
- const overlayScope = bmListScope();
- try {
- const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
- if (!resp.ok) throw new Error("HTTP " + resp.status);
- bmOverlayList = await resp.json();
- } catch (e) {
- console.error("Failed to fetch overlay bookmarks:", e);
- bmOverlayList = [];
+(() => {
+ // src/plugins/bookmarks.ts
+ var bridge = window;
+ function bmEl(id) {
+ const element = document.getElementById(id);
+ if (!element) throw new Error(`Missing bookmark element #${id}`);
+ return element;
}
- bmOverlayRevision++;
- if (typeof window.syncBookmarkMapLocators === "function") {
- window.syncBookmarkMapLocators(bmOverlayList);
+ function errorMessage(error) {
+ return error instanceof Error ? error.message : String(error);
}
- if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw();
-}
-async function bmFetch(categoryFilter) {
- let url = "/bookmarks";
- let hasQuery = false;
- if (categoryFilter && categoryFilter !== "") {
- url += "?category=" + encodeURIComponent(categoryFilter);
- hasQuery = true;
+ var bmScope = "general";
+ function bmScopeParam(prefix, scope) {
+ const sep = prefix ? "&" : "?";
+ return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
}
- url += bmScopeParam(hasQuery);
- const overlayPromise = bmFetchOverlay();
- try {
- const resp = await fetch(url);
- if (!resp.ok) throw new Error("HTTP " + resp.status);
- bmList = await resp.json();
- } catch (e) {
- console.error("Failed to fetch bookmarks:", e);
- bmList = [];
+ var bmList = [];
+ var bmOverlayList = [];
+ var bmOverlayRevision = 0;
+ var bmFilteredList = [];
+ var bmEditScope = null;
+ var bmCurrentPage = 1;
+ var BM_PAGE_SIZE = 25;
+ var bmSelected = /* @__PURE__ */ new Set();
+ function bmFmtFreq(hz) {
+ if (!Number.isFinite(hz) || hz <= 0) return "--";
+ if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + " GHz";
+ if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + " MHz";
+ if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + " kHz";
+ return `${hz} Hz`;
}
- bmRevision++;
- bmSelected.clear();
- bmUpdateSelectionUi();
- bmSyncAccess();
- bmApplyFilters();
- bmRefreshCategoryFilter(categoryFilter);
- await overlayPromise;
-}
-function bmApplyFilters() {
- const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase();
- const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase();
- let filtered = modeFilter ? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter) : bmList;
- filtered = text ? filtered.filter(
- (bm) => (bm.name || "").toLowerCase().includes(text) || (bm.locator || "").toLowerCase().includes(text) || (bm.category || "").toLowerCase().includes(text) || (bm.comment || "").toLowerCase().includes(text)
- ) : filtered;
- bmFilteredList = filtered;
- bmCurrentPage = 1;
- bmRender(filtered);
-}
-async function bmRefreshCategoryFilter(keepValue) {
- const sel = document.getElementById("bm-category-filter");
- const modeSel = document.getElementById("bm-mode-filter");
- if (!sel && !modeSel) return;
- try {
- const resp = await fetch("/bookmarks" + bmScopeParam(false));
- if (!resp.ok) return;
- const all = await resp.json();
- if (sel) {
- const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
- while (sel.options.length > 1) sel.remove(1);
- cats.forEach((cat) => {
- const opt = document.createElement("option");
- opt.value = cat;
- opt.textContent = cat;
- sel.add(opt);
- });
- if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
+ function bmEsc(str) {
+ const d = document.createElement("div");
+ d.appendChild(document.createTextNode(String(str)));
+ return d.innerHTML;
+ }
+ function bmCanControl() {
+ return typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled || typeof bridge.authRole !== "undefined" && bridge.authRole === "control";
+ }
+ function bmSyncAccess() {
+ const canCtrl = bmCanControl();
+ const addBtn = bmEl("bm-add-btn");
+ const selectAllBtn = bmEl("bm-select-all-btn");
+ if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
+ if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
+ }
+ function bmListScope() {
+ const rig = typeof bridge.lastActiveRigId !== "undefined" ? bridge.lastActiveRigId : null;
+ return rig || "general";
+ }
+ async function bmFetchOverlay() {
+ const overlayScope = bmListScope();
+ try {
+ const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ bmOverlayList = await resp.json();
+ } catch (e) {
+ console.error("Failed to fetch overlay bookmarks:", e);
+ bmOverlayList = [];
}
- if (modeSel) {
- const keepMode = modeSel.value;
- const modes = [...new Set(all.map((b) => String(b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
- while (modeSel.options.length > 1) modeSel.remove(1);
- modes.forEach((mode) => {
- const opt = document.createElement("option");
- opt.value = mode;
- opt.textContent = mode;
- modeSel.add(opt);
- });
- if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
+ bmOverlayRevision++;
+ if (typeof bridge.syncBookmarkMapLocators === "function") {
+ bridge.syncBookmarkMapLocators(bmOverlayList);
}
- } catch (_) {
+ if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw();
}
-}
-function bmRender(list) {
- const tbody = document.getElementById("bm-tbody");
- const emptyEl = document.getElementById("bm-empty");
- const paginatorEl = document.getElementById("bm-paginator");
- const pageSummaryEl = document.getElementById("bm-page-summary");
- const pageIndicatorEl = document.getElementById("bm-page-indicator");
- const prevBtn = document.getElementById("bm-page-prev");
- const nextBtn = document.getElementById("bm-page-next");
- if (!tbody) return;
- tbody.innerHTML = "";
- if (list.length === 0) {
- if (emptyEl) emptyEl.style.display = "";
- if (paginatorEl) paginatorEl.style.display = "none";
- return;
- }
- if (emptyEl) emptyEl.style.display = "none";
- const canControl = bmCanControl();
- const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE));
- const page = Math.min(Math.max(bmCurrentPage, 1), totalPages);
- bmCurrentPage = page;
- const startIndex = (page - 1) * BM_PAGE_SIZE;
- const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
- const pageItems = list.slice(startIndex, endIndex);
- const showScope = bmScope !== "general";
- pageItems.forEach((bm) => {
- const tr = document.createElement("tr");
- tr.dataset.bmId = bm.id;
- const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
- const locatorCell = bm.locator || "--";
- const catCell = bm.category || "Uncategorised";
- const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
- const commentCell = bm.comment || "";
- const checked = bmSelected.has(bm.id) ? " checked" : "";
- const scopeBadge = showScope && bm.scope === "general" ? ' G' : "";
- tr.innerHTML = `
| ${bmEsc(bm.name)}${scopeBadge} | ${bmFmtFreq(bm.freq_hz)} | ${bmEsc(bm.mode)} | ${bwCell} | ${bmEsc(locatorCell)} | ${bmEsc(catCell)} | ${bmEsc(decoderCell)} | ${bmEsc(commentCell)} | ` + (canControl ? `` : "") + ` | `;
- tbody.appendChild(tr);
- });
- bmSyncSelectAllCheckbox();
- if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
- if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
- if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`;
- if (prevBtn) prevBtn.disabled = page <= 1;
- if (nextBtn) nextBtn.disabled = page >= totalPages;
-}
-function bmChangePage(delta) {
- const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
- const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
- if (nextPage === bmCurrentPage) return;
- bmCurrentPage = nextPage;
- bmRender(bmFilteredList);
-}
-function bmReadDecoders() {
- return (window.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => document.getElementById("bm-dec-" + d.id)?.checked).map((d) => d.id);
-}
-function bmWriteDecoders(decoders) {
- const set = new Set(decoders || []);
- (window.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
- const el = document.getElementById("bm-dec-" + d.id);
- if (el) el.checked = set.has(d.id);
- });
-}
-function bmBuildDecoderCheckboxes() {
- const container = document.getElementById("bm-decoder-checkboxes");
- if (!container) return;
- container.innerHTML = "";
- (window.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
- const label = document.createElement("label");
- label.className = "bm-decoder-check";
- label.innerHTML = ' ' + d.label;
- container.appendChild(label);
- });
-}
-function bmOpenForm(bm) {
- const wrap = document.getElementById("bm-form-wrap");
- if (!wrap) return;
- bmEditId = bm ? bm.id : null;
- bmEditScope = bm ? bm.scope || bmScope : null;
- bmBuildDecoderCheckboxes();
- document.getElementById("bm-id").value = bm ? bm.id : "";
- document.getElementById("bm-name").value = bm ? bm.name : "";
- document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
- document.getElementById("bm-mode").value = bm ? bm.mode : "";
- document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
- document.getElementById("bm-locator").value = bm ? bm.locator || "" : "";
- document.getElementById("bm-category-input").value = bm ? bm.category || "" : "";
- document.getElementById("bm-comment").value = bm ? bm.comment || "" : "";
- bmWriteDecoders(bm ? bm.decoders : []);
- document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
- wrap.style.display = "flex";
- document.getElementById("bm-name").focus();
-}
-function bmCloseForm() {
- const wrap = document.getElementById("bm-form-wrap");
- if (wrap) wrap.style.display = "none";
- bmEditId = null;
-}
-function bmPrefillFromStatus() {
- if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) {
- document.getElementById("bm-freq").value = Math.round(lastFreqHz);
- }
- if (typeof lastModeName === "string" && lastModeName) {
- document.getElementById("bm-mode").value = lastModeName;
- }
- if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) {
- document.getElementById("bm-bw").value = Math.round(currentBandwidthHz);
- }
- const activeDecoders = (window.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => {
- const btn = document.getElementById(d.id + "-decode-toggle-btn");
- return btn && btn.dataset.enabled === "true";
- }).map((d) => d.id);
- bmWriteDecoders(activeDecoders);
-}
-async function bmSave(e) {
- e.preventDefault();
- const id = document.getElementById("bm-id").value;
- const name = document.getElementById("bm-name").value.trim();
- const freqStr = document.getElementById("bm-freq").value;
- const freq_hz = parseInt(freqStr, 10);
- const mode = document.getElementById("bm-mode").value.trim();
- const bwStr = document.getElementById("bm-bw").value;
- const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
- const locator = document.getElementById("bm-locator").value.trim().toUpperCase();
- const category = document.getElementById("bm-category-input").value.trim();
- const comment = document.getElementById("bm-comment").value.trim();
- const decoders = bmReadDecoders();
- const formError = document.getElementById("bm-form-error");
- if (formError) formError.textContent = "";
- if (!name || !Number.isFinite(freq_hz) || !mode) {
- if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
- const invalid = !name ? document.getElementById("bm-name") : !Number.isFinite(freq_hz) ? document.getElementById("bm-freq") : document.getElementById("bm-mode");
- invalid?.focus();
- return;
- }
- const body = {
- name,
- freq_hz,
- mode,
- bandwidth_hz,
- locator: locator || null,
- category,
- comment,
- decoders
- };
- try {
- let resp;
- if (id) {
- resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body)
- });
- } else {
- resp = await fetch("/bookmarks" + bmScopeParam(false), {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body)
- });
+ async function bmFetch(categoryFilter) {
+ let url = "/bookmarks";
+ let hasQuery = false;
+ if (categoryFilter && categoryFilter !== "") {
+ url += "?category=" + encodeURIComponent(categoryFilter);
+ hasQuery = true;
}
- if (!resp.ok) {
- const text = await resp.text();
- if (resp.status === 409) {
- throw new Error("A bookmark for that frequency already exists.");
- }
- throw new Error(text || "HTTP " + resp.status);
+ url += bmScopeParam(hasQuery);
+ const overlayPromise = bmFetchOverlay();
+ try {
+ const resp = await fetch(url);
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ bmList = await resp.json();
+ } catch (e) {
+ console.error("Failed to fetch bookmarks:", e);
+ bmList = [];
}
- bmCloseForm();
- await bmFetch(document.getElementById("bm-category-filter").value);
- } catch (err) {
- console.error("Failed to save bookmark:", err);
- if (formError) formError.textContent = "Failed to save bookmark: " + err.message;
- window.trxUi?.notify("Bookmark could not be saved", { kind: "error" });
- }
-}
-async function bmDelete(id) {
- if (!await window.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
- const bm = bmList.find((b) => b.id === id);
- const scope = bm ? bm.scope : void 0;
- try {
- const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
- method: "DELETE"
- });
- if (!resp.ok) throw new Error("HTTP " + resp.status);
- await bmFetch(document.getElementById("bm-category-filter").value);
- } catch (err) {
- console.error("Failed to delete bookmark:", err);
- window.trxUi?.notify("Failed to delete bookmark: " + err.message, { kind: "error" });
- }
-}
-async function bmApply(bm) {
- try {
- if (typeof modeEl !== "undefined" && modeEl) {
- modeEl.value = String(bm.mode || "").toUpperCase();
- }
- if (bm.bandwidth_hz) {
- if (typeof currentBandwidthHz !== "undefined") {
- currentBandwidthHz = bm.bandwidth_hz;
- }
- window.currentBandwidthHz = bm.bandwidth_hz;
- if (typeof syncBandwidthInput === "function") {
- syncBandwidthInput(bm.bandwidth_hz);
- }
- }
- if (typeof applyLocalTunedFrequency === "function") {
- if (typeof _freqOptimisticSeq !== "undefined") {
- ++_freqOptimisticSeq;
- _freqOptimisticHz = bm.freq_hz;
- }
- applyLocalTunedFrequency(bm.freq_hz, true);
- }
- if (typeof scheduleSpectrumDraw === "function" && typeof lastSpectrumData !== "undefined" && lastSpectrumData) {
- scheduleSpectrumDraw();
- }
- const tunePromise = (async () => {
- if (typeof vchanTakeSchedulerControl === "function") {
- await vchanTakeSchedulerControl();
- }
- const onVirtual = typeof vchanInterceptMode === "function" && await vchanInterceptMode(bm.mode);
- if (!onVirtual) {
- await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
- }
- if (bm.bandwidth_hz) {
- const bwHandledByVchan = typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(bm.bandwidth_hz);
- if (!bwHandledByVchan) {
- await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
- }
- }
- if (typeof setRigFrequency === "function") {
- await setRigFrequency(bm.freq_hz);
- } else {
- await postPath("/set_freq?hz=" + bm.freq_hz);
- }
- })();
- const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
- const modeUp = (bm.mode || "").toUpperCase();
- const allToggleDecoders = (window.decoderRegistry || []).filter(
- (d) => d.activation === "toggle"
- );
- const decoderPromise = allToggleDecoders.length ? (async () => {
- let statusUrl = "/status";
- if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) {
- statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId);
- }
- const statusResp = await fetch(statusUrl);
- if (!statusResp.ok) return;
- const st = await statusResp.json();
- const toggles = [];
- for (const d of allToggleDecoders) {
- const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
- const currentlyOn = !!st[statusKey];
- const compatible = Array.isArray(d.active_modes) && d.active_modes.includes(modeUp);
- let wanted;
- if (!compatible) {
- wanted = false;
- } else if (hasDecoders) {
- wanted = bm.decoders.includes(d.id);
- } else {
- wanted = currentlyOn;
- }
- if (wanted !== currentlyOn) {
- toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
- }
- }
- if (toggles.length) await Promise.all(toggles);
- })() : Promise.resolve();
- Promise.all([tunePromise, decoderPromise]).catch(
- (err) => console.error("Bookmark apply background error:", err)
- );
- } catch (err) {
- console.error("Failed to apply bookmark:", err);
- }
-}
-function bmUpdateSelectionUi() {
- const count = bmSelected.size;
- const canCtrl = bmCanControl();
- const visible = count > 0 && canCtrl;
- const btn = document.getElementById("bm-del-selected-btn");
- const countEl = document.getElementById("bm-del-selected-count");
- if (btn) btn.style.display = visible ? "" : "none";
- if (countEl) countEl.textContent = count;
- const moveWrap = document.getElementById("bm-move-selected-wrap");
- const moveCountEl = document.getElementById("bm-move-selected-count");
- if (moveWrap) moveWrap.style.display = visible ? "" : "none";
- if (moveCountEl) moveCountEl.textContent = count;
- if (visible) bmPopulateMoveTarget();
- const selectAllBtn = document.getElementById("bm-select-all-btn");
- if (selectAllBtn && bmCanControl()) {
- const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
- selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
- }
-}
-function bmPopulateMoveTarget() {
- const sel = document.getElementById("bm-move-target");
- if (!sel) return;
- const rigIds = typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds) ? lastRigIds : [];
- const displayNames = typeof lastRigDisplayNames !== "undefined" ? lastRigDisplayNames : {};
- const prev = sel.value;
- sel.innerHTML = "";
- if (bmScope !== "general") {
- const opt = document.createElement("option");
- opt.value = "general";
- opt.textContent = "General";
- sel.appendChild(opt);
- }
- rigIds.forEach((id) => {
- if (id === bmScope) return;
- const opt = document.createElement("option");
- opt.value = id;
- opt.textContent = displayNames[id] || id;
- sel.appendChild(opt);
- });
- if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
- sel.value = prev;
- }
-}
-async function bmMoveSelected() {
- const ids = Array.from(bmSelected);
- if (ids.length === 0) return;
- const target = document.getElementById("bm-move-target")?.value;
- if (!target) return;
- const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
- if (!await window.trxUi.confirm({
- title: "Move selected bookmarks?",
- message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
- confirmLabel: "Move",
- danger: false
- })) return;
- try {
- const byScope = {};
- for (const id of ids) {
- const bm = bmList.find((b) => b.id === id);
- const scope = bm?.scope || bmScope;
- if (scope === target) continue;
- (byScope[scope] ||= []).push(id);
- }
- await Promise.all(Object.entries(byScope).map(
- ([scope, scopeIds]) => fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ids: scopeIds, to: target })
- }).then((r) => {
- if (!r.ok) throw new Error("HTTP " + r.status);
- })
- ));
bmSelected.clear();
bmUpdateSelectionUi();
- await bmFetch(document.getElementById("bm-category-filter").value);
- } catch (err) {
- console.error("Failed to move bookmarks:", err);
- window.trxUi?.notify("Failed to move bookmarks: " + err.message, { kind: "error" });
- }
-}
-function bmSyncSelectAllCheckbox() {
- const selectAll = document.getElementById("bm-select-all");
- if (!selectAll) return;
- const checkboxes = document.querySelectorAll(".bm-row-sel");
- if (checkboxes.length === 0) {
- selectAll.checked = false;
- selectAll.indeterminate = false;
- return;
- }
- const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
- selectAll.checked = checkedCount === checkboxes.length;
- selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
-}
-async function bmDeleteSelected() {
- const ids = Array.from(bmSelected);
- if (ids.length === 0) return;
- if (!await window.trxUi.confirm({
- title: "Delete selected bookmarks?",
- message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
- confirmLabel: "Delete"
- })) return;
- try {
- const byScope = {};
- for (const id of ids) {
- const bm = bmList.find((b) => b.id === id);
- const scope = bm?.scope || bmScope;
- (byScope[scope] ||= []).push(id);
- }
- await Promise.all(Object.entries(byScope).map(
- ([scope, scopeIds]) => fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ids: scopeIds })
- }).then((r) => {
- if (!r.ok) throw new Error("HTTP " + r.status);
- })
- ));
- bmSelected.clear();
- bmUpdateSelectionUi();
- await bmFetch(document.getElementById("bm-category-filter").value);
- } catch (err) {
- console.error("Failed to delete bookmarks:", err);
- window.trxUi?.notify("Failed to delete bookmarks: " + err.message, { kind: "error" });
- }
-}
-function bmPopulateScopePicker() {
- const picker = document.getElementById("bm-scope-picker");
- if (!picker) return;
- const rigIds = typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds) ? lastRigIds : [];
- const displayNames = typeof lastRigDisplayNames !== "undefined" ? lastRigDisplayNames : {};
- const prev = picker.value;
- while (picker.options.length > 1) picker.remove(1);
- rigIds.forEach((id) => {
- const opt = document.createElement("option");
- opt.value = id;
- opt.textContent = displayNames[id] || id;
- picker.appendChild(opt);
- });
- if (prev && (prev === "general" || rigIds.includes(prev))) {
- picker.value = prev;
- } else {
- picker.value = "general";
- }
- bmScope = picker.value;
-}
-(function initBookmarks() {
- bmSyncAccess();
- bmBuildDecoderCheckboxes();
- if (typeof window.onDecoderRegistryReady === "function") {
- window.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
- }
- bmPopulateScopePicker();
- const scopePicker = document.getElementById("bm-scope-picker");
- if (scopePicker) {
- scopePicker.addEventListener("change", (e) => {
- bmScope = e.target.value;
- bmFetch(document.getElementById("bm-category-filter")?.value || "");
- });
- }
- document.querySelector(".tab-bar").addEventListener("click", (e) => {
- const btn = e.target.closest('.tab[data-tab="bookmarks"]');
- if (!btn) return;
- bmFetch(document.getElementById("bm-category-filter").value);
- });
- document.getElementById("bm-add-btn").addEventListener("click", () => {
- bmOpenForm(null);
- bmPrefillFromStatus();
- });
- document.getElementById("bm-category-filter").addEventListener("change", (e) => {
- bmFetch(e.target.value);
- });
- document.getElementById("bm-mode-filter").addEventListener("change", () => {
+ bmSyncAccess();
bmApplyFilters();
- });
- document.getElementById("bm-text-filter").addEventListener("input", () => {
- bmApplyFilters();
- });
- document.getElementById("bm-page-prev").addEventListener("click", () => {
- bmChangePage(-1);
- });
- document.getElementById("bm-page-next").addEventListener("click", () => {
- bmChangePage(1);
- });
- document.getElementById("bm-form").addEventListener("submit", bmSave);
- document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm);
- const formWrap = document.getElementById("bm-form-wrap");
- if (formWrap) {
- formWrap.addEventListener("click", (e) => {
- if (e.target === formWrap) bmCloseForm();
- });
+ void bmRefreshCategoryFilter(categoryFilter);
+ await overlayPromise;
}
- document.addEventListener("keydown", (e) => {
- if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") {
- bmCloseForm();
+ function bmApplyFilters() {
+ const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase();
+ const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase();
+ let filtered = modeFilter ? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter) : bmList;
+ filtered = text ? filtered.filter(
+ (bm) => (bm.name || "").toLowerCase().includes(text) || (bm.locator || "").toLowerCase().includes(text) || (bm.category || "").toLowerCase().includes(text) || (bm.comment || "").toLowerCase().includes(text)
+ ) : filtered;
+ bmFilteredList = filtered;
+ bmCurrentPage = 1;
+ bmRender(filtered);
+ }
+ async function bmRefreshCategoryFilter(keepValue) {
+ const sel = bmEl("bm-category-filter");
+ const modeSel = bmEl("bm-mode-filter");
+ if (!sel && !modeSel) return;
+ try {
+ const resp = await fetch("/bookmarks" + bmScopeParam(false));
+ if (!resp.ok) return;
+ const all = await resp.json();
+ if (sel) {
+ const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
+ while (sel.options.length > 1) sel.remove(1);
+ cats.forEach((cat) => {
+ const opt = document.createElement("option");
+ opt.value = cat;
+ opt.textContent = cat;
+ sel.add(opt);
+ });
+ if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
+ }
+ if (modeSel) {
+ const keepMode = modeSel.value;
+ const modes = [...new Set(all.map((b) => (b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
+ while (modeSel.options.length > 1) modeSel.remove(1);
+ modes.forEach((mode) => {
+ const opt = document.createElement("option");
+ opt.value = mode;
+ opt.textContent = mode;
+ modeSel.add(opt);
+ });
+ if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
+ }
+ } catch {
}
- });
- document.getElementById("bm-select-all").addEventListener("change", (e) => {
- const checked = e.target.checked;
- document.querySelectorAll(".bm-row-sel").forEach((cb) => {
- cb.checked = checked;
- if (checked) bmSelected.add(cb.dataset.bmId);
- else bmSelected.delete(cb.dataset.bmId);
- });
- bmUpdateSelectionUi();
- });
- document.getElementById("bm-select-all-btn").addEventListener("click", () => {
- const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
- if (allSelected) {
- bmSelected.clear();
- } else {
- bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
- }
- document.querySelectorAll(".bm-row-sel").forEach((cb) => {
- cb.checked = bmSelected.has(cb.dataset.bmId);
- });
- bmSyncSelectAllCheckbox();
- bmUpdateSelectionUi();
- });
- document.getElementById("bm-del-selected-btn").addEventListener("click", () => {
- bmDeleteSelected();
- });
- document.getElementById("bm-move-selected-btn").addEventListener("click", () => {
- bmMoveSelected();
- });
- document.getElementById("bm-tbody").addEventListener("click", async (e) => {
- const checkbox = e.target.closest(".bm-row-sel");
- if (checkbox) {
- if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId);
- else bmSelected.delete(checkbox.dataset.bmId);
- bmSyncSelectAllCheckbox();
- bmUpdateSelectionUi();
+ }
+ function bmRender(list) {
+ const tbody = bmEl("bm-tbody");
+ const emptyEl = bmEl("bm-empty");
+ const paginatorEl = bmEl("bm-paginator");
+ const pageSummaryEl = bmEl("bm-page-summary");
+ const pageIndicatorEl = bmEl("bm-page-indicator");
+ const prevBtn = bmEl("bm-page-prev");
+ const nextBtn = bmEl("bm-page-next");
+ if (!tbody) return;
+ tbody.innerHTML = "";
+ if (list.length === 0) {
+ if (emptyEl) emptyEl.style.display = "";
+ if (paginatorEl) paginatorEl.style.display = "none";
return;
}
- const tuneBtn = e.target.closest(".bm-tune-btn");
- const editBtn = e.target.closest(".bm-edit-btn");
- const delBtn = e.target.closest(".bm-del-btn");
- if (tuneBtn) {
- const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
- if (bm) await bmApply(bm);
- } else if (editBtn) {
- const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
- if (bm) bmOpenForm(bm);
- } else if (delBtn) {
- await bmDelete(delBtn.dataset.bmId);
+ if (emptyEl) emptyEl.style.display = "none";
+ const canControl = bmCanControl();
+ const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE));
+ const page = Math.min(Math.max(bmCurrentPage, 1), totalPages);
+ bmCurrentPage = page;
+ const startIndex = (page - 1) * BM_PAGE_SIZE;
+ const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
+ const pageItems = list.slice(startIndex, endIndex);
+ const showScope = bmScope !== "general";
+ pageItems.forEach((bm) => {
+ const tr = document.createElement("tr");
+ tr.dataset.bmId = bm.id;
+ const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
+ const locatorCell = bm.locator || "--";
+ const catCell = bm.category || "Uncategorised";
+ const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
+ const commentCell = bm.comment || "";
+ const checked = bmSelected.has(bm.id) ? " checked" : "";
+ const scopeBadge = showScope && bm.scope === "general" ? ' G' : "";
+ tr.innerHTML = ` | ${bmEsc(bm.name)}${scopeBadge} | ${bmFmtFreq(bm.freq_hz)} | ${bmEsc(bm.mode)} | ${bwCell} | ${bmEsc(locatorCell)} | ${bmEsc(catCell)} | ${bmEsc(decoderCell)} | ${bmEsc(commentCell)} | ` + (canControl ? `` : "") + ` | `;
+ tbody.appendChild(tr);
+ });
+ bmSyncSelectAllCheckbox();
+ if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
+ if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
+ if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`;
+ if (prevBtn) prevBtn.disabled = page <= 1;
+ if (nextBtn) nextBtn.disabled = page >= totalPages;
+ }
+ function bmChangePage(delta) {
+ const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
+ const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
+ if (nextPage === bmCurrentPage) return;
+ bmCurrentPage = nextPage;
+ bmRender(bmFilteredList);
+ }
+ function bmReadDecoders() {
+ return (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => bmEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
+ }
+ function bmWriteDecoders(decoders) {
+ const set = new Set(decoders || []);
+ (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
+ const el = bmEl("bm-dec-" + d.id);
+ if (el) el.checked = set.has(d.id);
+ });
+ }
+ function bmBuildDecoderCheckboxes() {
+ const container = bmEl("bm-decoder-checkboxes");
+ if (!container) return;
+ container.innerHTML = "";
+ (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
+ const label = document.createElement("label");
+ label.className = "bm-decoder-check";
+ label.innerHTML = ' ' + d.label;
+ container.appendChild(label);
+ });
+ }
+ function bmOpenForm(bm) {
+ const wrap = bmEl("bm-form-wrap");
+ if (!wrap) return;
+ bmEditScope = bm ? bm.scope || bmScope : null;
+ bmBuildDecoderCheckboxes();
+ bmEl("bm-id").value = bm ? bm.id : "";
+ bmEl("bm-name").value = bm ? bm.name : "";
+ bmEl("bm-freq").value = bm ? String(bm.freq_hz) : "";
+ bmEl("bm-mode").value = bm ? bm.mode : "";
+ bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : "";
+ bmEl("bm-locator").value = bm ? bm.locator || "" : "";
+ bmEl("bm-category-input").value = bm ? bm.category || "" : "";
+ bmEl("bm-comment").value = bm ? bm.comment || "" : "";
+ bmWriteDecoders(bm?.decoders ?? []);
+ bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
+ wrap.style.display = "flex";
+ bmEl("bm-name").focus();
+ }
+ function bmCloseForm() {
+ const wrap = bmEl("bm-form-wrap");
+ if (wrap) wrap.style.display = "none";
+ }
+ function bmPrefillFromStatus() {
+ if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) {
+ bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz));
}
- });
- bmFetch("");
+ if (typeof bridge.lastModeName === "string" && bridge.lastModeName) {
+ bmEl("bm-mode").value = bridge.lastModeName;
+ }
+ if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) {
+ bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz));
+ }
+ const activeDecoders = (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => {
+ const btn = bmEl(d.id + "-decode-toggle-btn");
+ return btn && btn.dataset.enabled === "true";
+ }).map((d) => d.id);
+ bmWriteDecoders(activeDecoders);
+ }
+ async function bmSave(e) {
+ e.preventDefault();
+ const id = bmEl("bm-id").value;
+ const name = bmEl("bm-name").value.trim();
+ const freqStr = bmEl("bm-freq").value;
+ const freq_hz = parseInt(freqStr, 10);
+ const mode = bmEl("bm-mode").value.trim();
+ const bwStr = bmEl("bm-bw").value;
+ const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
+ const locator = bmEl("bm-locator").value.trim().toUpperCase();
+ const category = bmEl("bm-category-input").value.trim();
+ const comment = bmEl("bm-comment").value.trim();
+ const decoders = bmReadDecoders();
+ const formError = bmEl("bm-form-error");
+ if (formError) formError.textContent = "";
+ if (!name || !Number.isFinite(freq_hz) || !mode) {
+ if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
+ const invalid = !name ? bmEl("bm-name") : !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode");
+ invalid?.focus();
+ return;
+ }
+ const body = {
+ name,
+ freq_hz,
+ mode,
+ bandwidth_hz,
+ locator: locator || null,
+ category,
+ comment,
+ decoders
+ };
+ try {
+ let resp;
+ if (id) {
+ resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body)
+ });
+ } else {
+ resp = await fetch("/bookmarks" + bmScopeParam(false), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body)
+ });
+ }
+ if (!resp.ok) {
+ const text = await resp.text();
+ if (resp.status === 409) {
+ throw new Error("A bookmark for that frequency already exists.");
+ }
+ throw new Error(text || `HTTP ${resp.status}`);
+ }
+ bmCloseForm();
+ await bmFetch(bmEl("bm-category-filter").value);
+ } catch (err) {
+ console.error("Failed to save bookmark:", err);
+ if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err);
+ bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" });
+ }
+ }
+ async function bmDelete(id) {
+ 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 scope = bm ? bm.scope : void 0;
+ try {
+ const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
+ method: "DELETE"
+ });
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ await bmFetch(bmEl("bm-category-filter").value);
+ } catch (err) {
+ console.error("Failed to delete bookmark:", err);
+ bridge.trxUi.notify?.("Failed to delete bookmark: " + errorMessage(err), { kind: "error" });
+ }
+ }
+ function bmApply(bm) {
+ try {
+ if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) {
+ bridge.modeEl.value = (bm.mode || "").toUpperCase();
+ }
+ if (bm.bandwidth_hz) {
+ if (typeof bridge.currentBandwidthHz !== "undefined") {
+ bridge.currentBandwidthHz = bm.bandwidth_hz;
+ }
+ bridge.currentBandwidthHz = bm.bandwidth_hz;
+ if (typeof bridge.syncBandwidthInput === "function") {
+ bridge.syncBandwidthInput(bm.bandwidth_hz);
+ }
+ }
+ if (typeof bridge.applyLocalTunedFrequency === "function") {
+ if (typeof bridge._freqOptimisticSeq !== "undefined") {
+ ++bridge._freqOptimisticSeq;
+ bridge._freqOptimisticHz = bm.freq_hz;
+ }
+ bridge.applyLocalTunedFrequency(bm.freq_hz, true);
+ }
+ if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
+ bridge.scheduleSpectrumDraw();
+ }
+ const tunePromise = (async () => {
+ if (typeof bridge.vchanTakeSchedulerControl === "function") {
+ await bridge.vchanTakeSchedulerControl();
+ }
+ const onVirtual = typeof bridge.vchanInterceptMode === "function" && await bridge.vchanInterceptMode(bm.mode);
+ if (!onVirtual) {
+ await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
+ }
+ if (bm.bandwidth_hz) {
+ const bwHandledByVchan = typeof bridge.vchanInterceptBandwidth === "function" && await bridge.vchanInterceptBandwidth(bm.bandwidth_hz);
+ if (!bwHandledByVchan) {
+ await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
+ }
+ }
+ if (typeof bridge.setRigFrequency === "function") {
+ await bridge.setRigFrequency(bm.freq_hz);
+ } else {
+ await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
+ }
+ })();
+ const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
+ const modeUp = (bm.mode || "").toUpperCase();
+ const allToggleDecoders = (bridge.decoderRegistry || []).filter(
+ (d) => d.activation === "toggle"
+ );
+ const decoderPromise = allToggleDecoders.length ? (async () => {
+ let statusUrl = "/status";
+ if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) {
+ statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId);
+ }
+ const statusResp = await fetch(statusUrl);
+ if (!statusResp.ok) return;
+ const st = await statusResp.json();
+ const toggles = [];
+ for (const d of allToggleDecoders) {
+ const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
+ const currentlyOn = !!st[statusKey];
+ const compatible = Array.isArray(d.active_modes) && d.active_modes.includes(modeUp);
+ let wanted;
+ if (!compatible) {
+ wanted = false;
+ } else if (hasDecoders) {
+ wanted = bm.decoders?.includes(d.id) ?? false;
+ } else {
+ wanted = currentlyOn;
+ }
+ if (wanted !== currentlyOn) {
+ toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
+ }
+ }
+ if (toggles.length) await Promise.all(toggles);
+ })() : Promise.resolve();
+ void Promise.all([tunePromise, decoderPromise]).catch((error) => {
+ console.error("Bookmark apply background error:", error);
+ });
+ } catch (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() {
+ const count = bmSelected.size;
+ const canCtrl = bmCanControl();
+ const visible = count > 0 && canCtrl;
+ const btn = bmEl("bm-del-selected-btn");
+ const countEl = bmEl("bm-del-selected-count");
+ if (btn) btn.style.display = visible ? "" : "none";
+ if (countEl) countEl.textContent = String(count);
+ const moveWrap = bmEl("bm-move-selected-wrap");
+ const moveCountEl = bmEl("bm-move-selected-count");
+ if (moveWrap) moveWrap.style.display = visible ? "" : "none";
+ if (moveCountEl) moveCountEl.textContent = String(count);
+ if (visible) bmPopulateMoveTarget();
+ const selectAllBtn = bmEl("bm-select-all-btn");
+ if (selectAllBtn && bmCanControl()) {
+ const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
+ selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
+ }
+ }
+ function bmPopulateMoveTarget() {
+ const sel = bmEl("bm-move-target");
+ if (!sel) return;
+ const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : [];
+ const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {};
+ const prev = sel.value;
+ sel.innerHTML = "";
+ if (bmScope !== "general") {
+ const opt = document.createElement("option");
+ opt.value = "general";
+ opt.textContent = "General";
+ sel.appendChild(opt);
+ }
+ rigIds.forEach((id) => {
+ if (id === bmScope) return;
+ const opt = document.createElement("option");
+ opt.value = id;
+ opt.textContent = displayNames[id] || id;
+ sel.appendChild(opt);
+ });
+ if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
+ sel.value = prev;
+ }
+ }
+ async function bmMoveSelected() {
+ const ids = Array.from(bmSelected);
+ if (ids.length === 0) return;
+ const target = bmEl("bm-move-target")?.value;
+ if (!target) return;
+ const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target;
+ if (!await bridge.trxUi.confirm({
+ title: "Move selected bookmarks?",
+ message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
+ confirmLabel: "Move",
+ danger: false
+ })) return;
+ try {
+ const byScope = {};
+ for (const id of ids) {
+ const bm = bmList.find((b) => b.id === id);
+ const scope = bm?.scope || bmScope;
+ if (scope === target) continue;
+ (byScope[scope] ||= []).push(id);
+ }
+ await Promise.all(Object.entries(byScope).map(
+ ([scope, scopeIds]) => fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ids: scopeIds, to: target })
+ }).then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ })
+ ));
+ bmSelected.clear();
+ bmUpdateSelectionUi();
+ await bmFetch(bmEl("bm-category-filter").value);
+ } catch (err) {
+ console.error("Failed to move bookmarks:", err);
+ bridge.trxUi.notify?.("Failed to move bookmarks: " + errorMessage(err), { kind: "error" });
+ }
+ }
+ function bmSyncSelectAllCheckbox() {
+ const selectAll = bmEl("bm-select-all");
+ if (!selectAll) return;
+ const checkboxes = document.querySelectorAll(".bm-row-sel");
+ if (checkboxes.length === 0) {
+ selectAll.checked = false;
+ selectAll.indeterminate = false;
+ return;
+ }
+ const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
+ selectAll.checked = checkedCount === checkboxes.length;
+ selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
+ }
+ async function bmDeleteSelected() {
+ const ids = Array.from(bmSelected);
+ if (ids.length === 0) return;
+ if (!await bridge.trxUi.confirm({
+ title: "Delete selected bookmarks?",
+ message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
+ confirmLabel: "Delete"
+ })) return;
+ try {
+ const byScope = {};
+ for (const id of ids) {
+ const bm = bmList.find((b) => b.id === id);
+ const scope = bm?.scope || bmScope;
+ (byScope[scope] ||= []).push(id);
+ }
+ await Promise.all(Object.entries(byScope).map(
+ ([scope, scopeIds]) => fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ids: scopeIds })
+ }).then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ })
+ ));
+ bmSelected.clear();
+ bmUpdateSelectionUi();
+ await bmFetch(bmEl("bm-category-filter").value);
+ } catch (err) {
+ console.error("Failed to delete bookmarks:", err);
+ bridge.trxUi.notify?.("Failed to delete bookmarks: " + errorMessage(err), { kind: "error" });
+ }
+ }
+ function bmPopulateScopePicker() {
+ const picker = bmEl("bm-scope-picker");
+ if (!picker) return;
+ const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : [];
+ const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {};
+ const prev = picker.value;
+ while (picker.options.length > 1) picker.remove(1);
+ rigIds.forEach((id) => {
+ const opt = document.createElement("option");
+ opt.value = id;
+ opt.textContent = displayNames[id] || id;
+ picker.appendChild(opt);
+ });
+ if (prev && (prev === "general" || rigIds.includes(prev))) {
+ picker.value = prev;
+ } else {
+ picker.value = "general";
+ }
+ bmScope = picker.value;
+ }
+ (function initBookmarks() {
+ bmSyncAccess();
+ bmBuildDecoderCheckboxes();
+ if (typeof bridge.onDecoderRegistryReady === "function") {
+ bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
+ }
+ bmPopulateScopePicker();
+ const scopePicker = bmEl("bm-scope-picker");
+ if (scopePicker) {
+ scopePicker.addEventListener("change", (e) => {
+ bmScope = e.currentTarget.value;
+ void bmFetch(bmEl("bm-category-filter").value || "");
+ });
+ }
+ document.querySelector(".tab-bar")?.addEventListener("click", (e) => {
+ const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null;
+ if (!btn) return;
+ void bmFetch(bmEl("bm-category-filter").value);
+ });
+ bmEl("bm-add-btn").addEventListener("click", () => {
+ bmOpenForm(null);
+ bmPrefillFromStatus();
+ });
+ bmEl("bm-category-filter").addEventListener("change", (e) => {
+ void bmFetch(e.currentTarget.value);
+ });
+ bmEl("bm-mode-filter").addEventListener("change", () => {
+ bmApplyFilters();
+ });
+ bmEl("bm-text-filter").addEventListener("input", () => {
+ bmApplyFilters();
+ });
+ bmEl("bm-page-prev").addEventListener("click", () => {
+ bmChangePage(-1);
+ });
+ bmEl("bm-page-next").addEventListener("click", () => {
+ bmChangePage(1);
+ });
+ bmEl("bm-form").addEventListener("submit", (event) => {
+ void bmSave(event);
+ });
+ bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
+ const formWrap = bmEl("bm-form-wrap");
+ if (formWrap) {
+ formWrap.addEventListener("click", (e) => {
+ if (e.target === formWrap) bmCloseForm();
+ });
+ }
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && bmEl("bm-form-wrap")?.style.display === "flex") {
+ bmCloseForm();
+ }
+ });
+ bmEl("bm-select-all").addEventListener("change", (e) => {
+ const checked = e.currentTarget.checked;
+ document.querySelectorAll(".bm-row-sel").forEach((cb) => {
+ cb.checked = checked;
+ const id = cb.dataset.bmId;
+ if (!id) return;
+ if (checked) bmSelected.add(id);
+ else bmSelected.delete(id);
+ });
+ bmUpdateSelectionUi();
+ });
+ bmEl("bm-select-all-btn").addEventListener("click", () => {
+ const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
+ if (allSelected) {
+ bmSelected.clear();
+ } else {
+ bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
+ }
+ document.querySelectorAll(".bm-row-sel").forEach((cb) => {
+ cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId);
+ });
+ bmSyncSelectAllCheckbox();
+ bmUpdateSelectionUi();
+ });
+ bmEl("bm-del-selected-btn").addEventListener("click", () => {
+ void bmDeleteSelected();
+ });
+ bmEl("bm-move-selected-btn").addEventListener("click", () => {
+ void bmMoveSelected();
+ });
+ bmEl("bm-tbody").addEventListener("click", (e) => {
+ void (async () => {
+ if (!(e.target instanceof Element)) return;
+ const checkbox = e.target.closest(".bm-row-sel");
+ if (checkbox) {
+ const id = checkbox.dataset.bmId;
+ if (!id) return;
+ if (checkbox.checked) bmSelected.add(id);
+ else bmSelected.delete(id);
+ bmSyncSelectAllCheckbox();
+ bmUpdateSelectionUi();
+ return;
+ }
+ const tuneBtn = e.target.closest(".bm-tune-btn");
+ const editBtn = e.target.closest(".bm-edit-btn");
+ const delBtn = e.target.closest(".bm-del-btn");
+ if (tuneBtn) {
+ const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
+ if (bm) bmApply(bm);
+ } else if (editBtn) {
+ const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
+ if (bm) bmOpenForm(bm);
+ } else if (delBtn) {
+ const id = delBtn.dataset.bmId;
+ if (id) await bmDelete(id);
+ }
+ })();
+ });
+ void bmFetch("");
+ })();
})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js
index 13bd3352..08fe1bbe 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js
@@ -10,7 +10,7 @@ const pluginGroups = {
};
const loaded = /* @__PURE__ */ new Set();
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) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs
index 3d7350c6..54ae2058 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs
@@ -23,7 +23,6 @@ await build({
"plugin-runtime": path.join(sourceDir, "plugin-runtime.ts"),
screenshot: path.join(sourceDir, "screenshot.ts"),
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
- bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
scheduler: path.join(sourceDir, "plugins", "scheduler.js"),
},
outdir: outputDir,
@@ -52,6 +51,7 @@ await build({
sat: path.join(sourceDir, "plugins", "sat.ts"),
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
+ bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
},
outdir: outputDir,
bundle: true,
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js
index 6da46018..16d5691e 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js
@@ -912,12 +912,13 @@ function setTheme(theme) {
// Recolour bookmark chips after any palette/theme change (setTheme or setStyle).
function invalidateBookmarkColors() {
- if (typeof bmOverlayRevision === "undefined") return;
- bmOverlayRevision++;
+ const bookmarks = window.trx.modules.bookmarks;
+ if (!bookmarks) return;
+ bookmarks.invalidateColors();
// Force the browser to recalculate styles so getComputedStyle reads new values.
void getComputedStyle(document.documentElement).getPropertyValue("--bg");
const colorMap = bmCategoryColorMap();
- const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : [];
+ const ref = bookmarks.overlayList;
document.querySelectorAll(".spectrum-bookmark-chip").forEach((chip) => {
const bm = ref.find((b) => b.id === chip.dataset.bmId);
if (!bm) return;
@@ -1482,7 +1483,7 @@ function drawSignalOverlay() {
const bwEdge = BW_OVERLAY_COLORS.edge;
const bwStroke = BW_OVERLAY_COLORS.stroke;
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) {
const colorMap = bmCategoryColorMap();
const grouped = new Map();
@@ -4973,7 +4974,7 @@ function buildBookmarkTooltipText(bm) {
}
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) {
return null;
}
@@ -7753,7 +7754,7 @@ function bmThemePalette() {
// Returns a map of category → hex colour, including "" for uncategorised.
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 palette = bmThemePalette();
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-fg", bmContrastFg(col));
span.addEventListener("click", () => {
- if (typeof bmApply === "function") bmApply(bm);
+ void window.trx.modules.bookmarks?.apply(bm);
});
return span;
}
function updateSideBookmarkStack(container, bookmarks, colorMap) {
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(",")}` : "";
if (!Array.isArray(bookmarks) || bookmarks.length === 0) {
if (container.dataset.bmKey) {
@@ -7832,7 +7833,7 @@ function updateBookmarkAxis(range) {
const rightSideEl = document.getElementById("spectrum-bookmark-side-right");
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 visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz);
const leftBookmarks = allBookmarks
@@ -7858,7 +7859,7 @@ function updateBookmarkAxis(range) {
// Only rebuild DOM when the set of visible bookmarks changes.
// 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(",")}`;
if (axisEl.dataset.bmKey !== newKey) {
axisEl.dataset.bmKey = newKey;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts
index 6a45ecc5..166655c9 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts
@@ -16,7 +16,7 @@ const pluginGroups: Readonly> = {
const loaded = new Set();
const loading = new Map>();
-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 {
return new Promise((resolve, reject) => {
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts
similarity index 56%
rename from src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.js
rename to src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts
index ee784e69..48a0ba34 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.ts
@@ -2,38 +2,114 @@
//
// 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;
+ invalidateColors(): void;
+ apply(bookmark: Bookmark): void;
+}
+
+interface BookmarkBridge extends Window {
+ authEnabled?: boolean;
+ authRole?: string | null;
+ lastActiveRigId?: string | null;
+ lastRigIds?: string[];
+ lastRigDisplayNames?: Record;
+ 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;
+ 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;
+ vchanInterceptMode?(mode: string): Promise;
+ vchanInterceptBandwidth?(bandwidthHz: number): Promise;
+ setRigFrequency?(frequencyHz: number): Promise;
+ postPath(path: string): Promise;
+ 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 ---
/** Current bookmark scope: "general" or a rig remote name. */
let bmScope = "general";
/** 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 ? "&" : "?";
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
}
-var bmList = [];
-var bmRevision = 0;
+let bmList: Bookmark[] = [];
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
-var bmOverlayList = [];
-var bmOverlayRevision = 0;
-let bmFilteredList = [];
-let bmEditId = null;
-let bmEditScope = null;
+let bmOverlayList: Bookmark[] = [];
+let bmOverlayRevision = 0;
+let bmFilteredList: Bookmark[] = [];
+let bmEditScope: string | null = null;
let bmCurrentPage = 1;
const BM_PAGE_SIZE = 25;
-const bmSelected = new Set();
+const bmSelected = new Set();
-function bmFmtFreq(hz) {
+function bmFmtFreq(hz: number): string {
if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
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");
d.appendChild(document.createTextNode(String(str)));
return d.innerHTML;
@@ -41,23 +117,23 @@ function bmEsc(str) {
function bmCanControl() {
return (
- (typeof authEnabled !== "undefined" && !authEnabled) ||
- (typeof authRole !== "undefined" && authRole === "control")
+ (typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled) ||
+ (typeof bridge.authRole !== "undefined" && bridge.authRole === "control")
);
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
function bmSyncAccess() {
const canCtrl = bmCanControl();
- const addBtn = document.getElementById("bm-add-btn");
- const selectAllBtn = document.getElementById("bm-select-all-btn");
+ const addBtn = bmEl("bm-add-btn");
+ const selectAllBtn = bmEl("bm-select-all-btn");
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
}
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
function bmListScope() {
- const rig = (typeof lastActiveRigId !== "undefined") ? lastActiveRigId : null;
+ const rig = (typeof bridge.lastActiveRigId !== "undefined") ? bridge.lastActiveRigId : null;
return rig || "general";
}
@@ -65,20 +141,20 @@ async function bmFetchOverlay() {
const overlayScope = bmListScope();
try {
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
- if (!resp.ok) throw new Error("HTTP " + resp.status);
- bmOverlayList = await resp.json();
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ bmOverlayList = await resp.json() as Bookmark[];
} catch (e) {
console.error("Failed to fetch overlay bookmarks:", e);
bmOverlayList = [];
}
bmOverlayRevision++;
- if (typeof window.syncBookmarkMapLocators === "function") {
- window.syncBookmarkMapLocators(bmOverlayList);
+ if (typeof bridge.syncBookmarkMapLocators === "function") {
+ 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 {
let url = "/bookmarks";
let hasQuery = false;
if (categoryFilter && categoryFilter !== "") {
@@ -89,26 +165,25 @@ async function bmFetch(categoryFilter) {
const overlayPromise = bmFetchOverlay();
try {
const resp = await fetch(url);
- if (!resp.ok) throw new Error("HTTP " + resp.status);
- bmList = await resp.json();
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ bmList = await resp.json() as Bookmark[];
} catch (e) {
console.error("Failed to fetch bookmarks:", e);
bmList = [];
}
- bmRevision++;
bmSelected.clear();
bmUpdateSelectionUi();
bmSyncAccess();
bmApplyFilters();
- bmRefreshCategoryFilter(categoryFilter);
+ void bmRefreshCategoryFilter(categoryFilter);
await overlayPromise;
}
function bmApplyFilters() {
- const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase();
- const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase();
+ const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase();
+ const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase();
let filtered = modeFilter
- ? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter)
+ ? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter)
: bmList;
filtered = text
? filtered.filter((bm) =>
@@ -123,14 +198,14 @@ function bmApplyFilters() {
bmRender(filtered);
}
-async function bmRefreshCategoryFilter(keepValue) {
- const sel = document.getElementById("bm-category-filter");
- const modeSel = document.getElementById("bm-mode-filter");
+async function bmRefreshCategoryFilter(keepValue: string): Promise {
+ const sel = bmEl("bm-category-filter");
+ const modeSel = bmEl("bm-mode-filter");
if (!sel && !modeSel) return;
try {
const resp = await fetch("/bookmarks" + bmScopeParam(false));
if (!resp.ok) return;
- const all = await resp.json();
+ const all = await resp.json() as Bookmark[];
if (sel) {
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
while (sel.options.length > 1) sel.remove(1);
@@ -144,7 +219,7 @@ async function bmRefreshCategoryFilter(keepValue) {
}
if (modeSel) {
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);
modes.forEach((mode) => {
const opt = document.createElement("option");
@@ -154,17 +229,17 @@ async function bmRefreshCategoryFilter(keepValue) {
});
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
}
- } catch (_) {}
+ } catch {}
}
-function bmRender(list) {
- const tbody = document.getElementById("bm-tbody");
- const emptyEl = document.getElementById("bm-empty");
- const paginatorEl = document.getElementById("bm-paginator");
- const pageSummaryEl = document.getElementById("bm-page-summary");
- const pageIndicatorEl = document.getElementById("bm-page-indicator");
- const prevBtn = document.getElementById("bm-page-prev");
- const nextBtn = document.getElementById("bm-page-next");
+function bmRender(list: Bookmark[]): void {
+ const tbody = bmEl("bm-tbody");
+ const emptyEl = bmEl("bm-empty");
+ const paginatorEl = bmEl("bm-paginator");
+ const pageSummaryEl = bmEl("bm-page-summary");
+ const pageIndicatorEl = bmEl("bm-page-indicator");
+ const prevBtn = bmEl("bm-page-prev");
+ const nextBtn = bmEl("bm-page-next");
if (!tbody) return;
tbody.innerHTML = "";
@@ -222,7 +297,7 @@ function bmRender(list) {
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 nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
if (nextPage === bmCurrentPage) return;
@@ -231,30 +306,30 @@ function bmChangePage(delta) {
}
// Read decoder checkboxes and return an array of selected decoder names.
-function bmReadDecoders() {
- return (window.decoderRegistry || [])
+function bmReadDecoders(): string[] {
+ return (bridge.decoderRegistry || [])
.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);
}
// Set decoder checkboxes to match the given array.
-function bmWriteDecoders(decoders) {
+function bmWriteDecoders(decoders: readonly string[]): void {
const set = new Set(decoders || []);
- (window.decoderRegistry || [])
+ (bridge.decoderRegistry || [])
.filter(d => d.bookmark_selectable)
.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);
});
}
// Build decoder checkboxes dynamically from the registry.
function bmBuildDecoderCheckboxes() {
- const container = document.getElementById("bm-decoder-checkboxes");
+ const container = bmEl("bm-decoder-checkboxes");
if (!container) return;
container.innerHTML = "";
- (window.decoderRegistry || [])
+ (bridge.decoderRegistry || [])
.filter(d => d.bookmark_selectable)
.forEach(d => {
const label = document.createElement("label");
@@ -264,79 +339,77 @@ function bmBuildDecoderCheckboxes() {
});
}
-function bmOpenForm(bm) {
- const wrap = document.getElementById("bm-form-wrap");
+function bmOpenForm(bm: Bookmark | null): void {
+ const wrap = bmEl("bm-form-wrap");
if (!wrap) return;
- bmEditId = bm ? bm.id : null;
bmEditScope = bm ? (bm.scope || bmScope) : null;
// Rebuild decoder checkboxes from registry (handles race where registry
// loaded after initial build).
bmBuildDecoderCheckboxes();
- document.getElementById("bm-id").value = bm ? bm.id : "";
- document.getElementById("bm-name").value = bm ? bm.name : "";
- document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
- document.getElementById("bm-mode").value = bm ? bm.mode : "";
- document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
- document.getElementById("bm-locator").value = bm ? (bm.locator || "") : "";
- document.getElementById("bm-category-input").value = bm ? (bm.category || "") : "";
- document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
- bmWriteDecoders(bm ? bm.decoders : []);
- document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
+ bmEl("bm-id").value = bm ? bm.id : "";
+ bmEl("bm-name").value = bm ? bm.name : "";
+ bmEl("bm-freq").value = bm ? String(bm.freq_hz) : "";
+ bmEl("bm-mode").value = bm ? bm.mode : "";
+ bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : "";
+ bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
+ bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
+ bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
+ bmWriteDecoders(bm?.decoders ?? []);
+ bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex";
- document.getElementById("bm-name").focus();
+ bmEl("bm-name").focus();
}
function bmCloseForm() {
- const wrap = document.getElementById("bm-form-wrap");
+ const wrap = bmEl("bm-form-wrap");
if (wrap) wrap.style.display = "none";
- bmEditId = null;
}
function bmPrefillFromStatus() {
// Use globals maintained by app.js (updated by SSE stream)
- if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) {
- document.getElementById("bm-freq").value = Math.round(lastFreqHz);
+ if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) {
+ bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz));
}
- if (typeof lastModeName === "string" && lastModeName) {
- document.getElementById("bm-mode").value = lastModeName;
+ if (typeof bridge.lastModeName === "string" && bridge.lastModeName) {
+ bmEl("bm-mode").value = bridge.lastModeName;
}
- if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) {
- document.getElementById("bm-bw").value = Math.round(currentBandwidthHz);
+ if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) {
+ bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz));
}
// 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 => {
- const btn = document.getElementById(d.id + "-decode-toggle-btn");
+ const btn = bmEl(d.id + "-decode-toggle-btn");
return btn && btn.dataset.enabled === "true";
})
.map(d => d.id);
bmWriteDecoders(activeDecoders);
}
-async function bmSave(e) {
+async function bmSave(e: Event): Promise {
e.preventDefault();
- const id = document.getElementById("bm-id").value;
- const name = document.getElementById("bm-name").value.trim();
- const freqStr = document.getElementById("bm-freq").value;
+ const id = bmEl("bm-id").value;
+ const name = bmEl("bm-name").value.trim();
+ const freqStr = bmEl("bm-freq").value;
const freq_hz = parseInt(freqStr, 10);
- const mode = document.getElementById("bm-mode").value.trim();
- const bwStr = document.getElementById("bm-bw").value;
+ const mode = bmEl("bm-mode").value.trim();
+ const bwStr = bmEl("bm-bw").value;
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
- const locator = document.getElementById("bm-locator").value.trim().toUpperCase();
- const category = document.getElementById("bm-category-input").value.trim();
- const comment = document.getElementById("bm-comment").value.trim();
+ const locator = bmEl("bm-locator").value.trim().toUpperCase();
+ const category = bmEl("bm-category-input").value.trim();
+ const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders();
- const formError = document.getElementById("bm-form-error");
+ const formError = bmEl("bm-form-error");
if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) {
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
- const invalid = !name ? document.getElementById("bm-name")
- : !Number.isFinite(freq_hz) ? document.getElementById("bm-freq") : document.getElementById("bm-mode");
+ const invalid = !name ? bmEl("bm-name")
+ : !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode");
invalid?.focus();
return;
}
@@ -372,90 +445,90 @@ async function bmSave(e) {
if (resp.status === 409) {
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();
- await bmFetch(document.getElementById("bm-category-filter").value);
+ await bmFetch(bmEl("bm-category-filter").value);
} catch (err) {
console.error("Failed to save bookmark:", err);
- if (formError) formError.textContent = "Failed to save bookmark: " + err.message;
- window.trxUi?.notify("Bookmark could not be saved", { kind: "error" });
+ if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err);
+ bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" });
}
}
-async function bmDelete(id) {
- if (!await window.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
+async function bmDelete(id: string): Promise {
+ 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 scope = bm ? bm.scope : undefined;
try {
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
method: "DELETE",
});
- if (!resp.ok) throw new Error("HTTP " + resp.status);
- await bmFetch(document.getElementById("bm-category-filter").value);
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ await bmFetch(bmEl("bm-category-filter").value);
} catch (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 {
// --- Optimistic UI updates (instant, before any network round-trips) ---
- if (typeof modeEl !== "undefined" && modeEl) {
- modeEl.value = String(bm.mode || "").toUpperCase();
+ if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) {
+ bridge.modeEl.value = (bm.mode || "").toUpperCase();
}
if (bm.bandwidth_hz) {
- if (typeof currentBandwidthHz !== "undefined") {
- currentBandwidthHz = bm.bandwidth_hz;
+ if (typeof bridge.currentBandwidthHz !== "undefined") {
+ bridge.currentBandwidthHz = bm.bandwidth_hz;
}
- window.currentBandwidthHz = bm.bandwidth_hz;
- if (typeof syncBandwidthInput === "function") {
- syncBandwidthInput(bm.bandwidth_hz);
+ bridge.currentBandwidthHz = bm.bandwidth_hz;
+ if (typeof bridge.syncBandwidthInput === "function") {
+ bridge.syncBandwidthInput(bm.bandwidth_hz);
}
}
- if (typeof applyLocalTunedFrequency === "function") {
+ if (typeof bridge.applyLocalTunedFrequency === "function") {
// Set optimistic guard before applying so SSE cannot snap back.
- if (typeof _freqOptimisticSeq !== "undefined") {
- ++_freqOptimisticSeq;
- _freqOptimisticHz = bm.freq_hz;
+ if (typeof bridge._freqOptimisticSeq !== "undefined") {
+ ++bridge._freqOptimisticSeq;
+ bridge._freqOptimisticHz = bm.freq_hz;
}
// 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) {
- scheduleSpectrumDraw();
+ if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
+ bridge.scheduleSpectrumDraw();
}
// Take scheduler control up front, then apply mode before bandwidth so a
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
const tunePromise = (async () => {
- if (typeof vchanTakeSchedulerControl === "function") {
- await vchanTakeSchedulerControl();
+ if (typeof bridge.vchanTakeSchedulerControl === "function") {
+ await bridge.vchanTakeSchedulerControl();
}
- const onVirtual = typeof vchanInterceptMode === "function"
- && await vchanInterceptMode(bm.mode);
+ const onVirtual = typeof bridge.vchanInterceptMode === "function"
+ && await bridge.vchanInterceptMode(bm.mode);
if (!onVirtual) {
- await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
+ await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
}
if (bm.bandwidth_hz) {
- const bwHandledByVchan = typeof vchanInterceptBandwidth === "function"
- && await vchanInterceptBandwidth(bm.bandwidth_hz);
+ const bwHandledByVchan = typeof bridge.vchanInterceptBandwidth === "function"
+ && await bridge.vchanInterceptBandwidth(bm.bandwidth_hz);
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.
- // 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.
- if (typeof setRigFrequency === "function") {
- await setRigFrequency(bm.freq_hz);
+ if (typeof bridge.setRigFrequency === "function") {
+ await bridge.setRigFrequency(bm.freq_hz);
} else {
- await postPath("/set_freq?hz=" + bm.freq_hz);
+ await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
}
})();
// Decoder toggles — fire-and-forget.
@@ -466,18 +539,18 @@ async function bmApply(bm) {
// alone.
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const modeUp = (bm.mode || "").toUpperCase();
- const allToggleDecoders = (window.decoderRegistry || []).filter(d =>
+ const allToggleDecoders = (bridge.decoderRegistry || []).filter(d =>
d.activation === "toggle"
);
const decoderPromise = allToggleDecoders.length ? (async () => {
let statusUrl = "/status";
- if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) {
- statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId);
+ if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) {
+ statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId);
}
const statusResp = await fetch(statusUrl);
if (!statusResp.ok) return;
- const st = await statusResp.json();
- const toggles = [];
+ const st = await statusResp.json() as Record;
+ const toggles: Promise[] = [];
for (const d of allToggleDecoders) {
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
const currentlyOn = !!st[statusKey];
@@ -488,41 +561,51 @@ async function bmApply(bm) {
// Always disable decoders that don't apply to the new mode.
wanted = false;
} else if (hasDecoders) {
- wanted = bm.decoders.includes(d.id);
+ wanted = bm.decoders?.includes(d.id) ?? false;
} else {
// Mode-compatible and no bookmark selection: leave as-is.
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);
})() : Promise.resolve();
// Don't await — let the network calls settle in the background.
// Errors are logged but don't block the UI.
- Promise.all([tunePromise, decoderPromise]).catch(
- (err) => console.error("Bookmark apply background error:", err)
- );
+ void Promise.all([tunePromise, decoderPromise]).catch((error: unknown) => {
+ console.error("Bookmark apply background error:", error);
+ });
} catch (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() {
const count = bmSelected.size;
const canCtrl = bmCanControl();
const visible = count > 0 && canCtrl;
- const btn = document.getElementById("bm-del-selected-btn");
- const countEl = document.getElementById("bm-del-selected-count");
+ const btn = bmEl("bm-del-selected-btn");
+ const countEl = bmEl("bm-del-selected-count");
if (btn) btn.style.display = visible ? "" : "none";
- if (countEl) countEl.textContent = count;
- const moveWrap = document.getElementById("bm-move-selected-wrap");
- const moveCountEl = document.getElementById("bm-move-selected-count");
+ if (countEl) countEl.textContent = String(count);
+ const moveWrap = bmEl("bm-move-selected-wrap");
+ const moveCountEl = bmEl("bm-move-selected-count");
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
- if (moveCountEl) moveCountEl.textContent = count;
+ if (moveCountEl) moveCountEl.textContent = String(count);
if (visible) bmPopulateMoveTarget();
- const selectAllBtn = document.getElementById("bm-select-all-btn");
+ const selectAllBtn = bmEl("bm-select-all-btn");
if (selectAllBtn && bmCanControl()) {
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
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. */
function bmPopulateMoveTarget() {
- const sel = document.getElementById("bm-move-target");
+ const sel = bmEl("bm-move-target");
if (!sel) return;
- const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
- const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
+ const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : [];
+ const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {};
const prev = sel.value;
sel.innerHTML = "";
if (bmScope !== "general") {
@@ -558,10 +641,10 @@ function bmPopulateMoveTarget() {
async function bmMoveSelected() {
const ids = Array.from(bmSelected);
if (ids.length === 0) return;
- const target = document.getElementById("bm-move-target")?.value;
+ const target = bmEl("bm-move-target")?.value;
if (!target) return;
- const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
- if (!await window.trxUi.confirm({
+ const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target;
+ if (!await bridge.trxUi.confirm({
title: "Move selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
confirmLabel: "Move",
@@ -569,7 +652,7 @@ async function bmMoveSelected() {
})) return;
try {
// Group selected IDs by their owning scope (skip if already in target).
- const byScope = {};
+ const byScope: Record = {};
for (const id of ids) {
const bm = bmList.find((b) => b.id === id);
const scope = bm?.scope || bmScope;
@@ -581,21 +664,21 @@ async function bmMoveSelected() {
method: "POST",
headers: { "Content-Type": "application/json" },
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();
bmUpdateSelectionUi();
- await bmFetch(document.getElementById("bm-category-filter").value);
+ await bmFetch(bmEl("bm-category-filter").value);
} catch (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() {
- const selectAll = document.getElementById("bm-select-all");
+ const selectAll = bmEl("bm-select-all");
if (!selectAll) return;
- const checkboxes = document.querySelectorAll(".bm-row-sel");
+ const checkboxes = document.querySelectorAll(".bm-row-sel");
if (checkboxes.length === 0) {
selectAll.checked = false;
selectAll.indeterminate = false;
@@ -609,14 +692,14 @@ function bmSyncSelectAllCheckbox() {
async function bmDeleteSelected() {
const ids = Array.from(bmSelected);
if (ids.length === 0) return;
- if (!await window.trxUi.confirm({
+ if (!await bridge.trxUi.confirm({
title: "Delete selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
confirmLabel: "Delete",
})) return;
try {
// Group selected IDs by their owning scope.
- const byScope = {};
+ const byScope: Record = {};
for (const id of ids) {
const bm = bmList.find((b) => b.id === id);
const scope = bm?.scope || bmScope;
@@ -627,23 +710,23 @@ async function bmDeleteSelected() {
method: "POST",
headers: { "Content-Type": "application/json" },
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();
bmUpdateSelectionUi();
- await bmFetch(document.getElementById("bm-category-filter").value);
+ await bmFetch(bmEl("bm-category-filter").value);
} catch (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. */
function bmPopulateScopePicker() {
- const picker = document.getElementById("bm-scope-picker");
+ const picker = bmEl("bm-scope-picker");
if (!picker) return;
- const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
- const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
+ const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : [];
+ const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {};
// Preserve current selection if still valid.
const prev = picker.value;
while (picker.options.length > 1) picker.remove(1);
@@ -670,63 +753,63 @@ function bmPopulateScopePicker() {
// Build decoder checkboxes from registry. The registry is fetched async
// so we rebuild once it arrives to ensure checkboxes are present.
bmBuildDecoderCheckboxes();
- if (typeof window.onDecoderRegistryReady === "function") {
- window.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
+ if (typeof bridge.onDecoderRegistryReady === "function") {
+ bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
}
// Scope picker
bmPopulateScopePicker();
- const scopePicker = document.getElementById("bm-scope-picker");
+ const scopePicker = bmEl("bm-scope-picker");
if (scopePicker) {
scopePicker.addEventListener("change", (e) => {
- bmScope = e.target.value;
- bmFetch(document.getElementById("bm-category-filter")?.value || "");
+ bmScope = (e.currentTarget as HTMLSelectElement).value;
+ void bmFetch(bmEl("bm-category-filter").value || "");
});
}
// Refresh list and sync access when the Bookmarks tab is activated
- document.querySelector(".tab-bar").addEventListener("click", (e) => {
- const btn = e.target.closest('.tab[data-tab="bookmarks"]');
+ document.querySelector(".tab-bar")?.addEventListener("click", (e) => {
+ const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null;
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
- document.getElementById("bm-add-btn").addEventListener("click", () => {
+ bmEl("bm-add-btn").addEventListener("click", () => {
bmOpenForm(null);
bmPrefillFromStatus();
});
// Category filter dropdown
- document.getElementById("bm-category-filter").addEventListener("change", (e) => {
- bmFetch(e.target.value);
+ bmEl("bm-category-filter").addEventListener("change", (e) => {
+ void bmFetch((e.currentTarget as HTMLSelectElement).value);
});
// Mode filter dropdown (client-side, no re-fetch)
- document.getElementById("bm-mode-filter").addEventListener("change", () => {
+ bmEl("bm-mode-filter").addEventListener("change", () => {
bmApplyFilters();
});
// Text search filter (client-side, no re-fetch)
- document.getElementById("bm-text-filter").addEventListener("input", () => {
+ bmEl("bm-text-filter").addEventListener("input", () => {
bmApplyFilters();
});
- document.getElementById("bm-page-prev").addEventListener("click", () => {
+ bmEl("bm-page-prev").addEventListener("click", () => {
bmChangePage(-1);
});
- document.getElementById("bm-page-next").addEventListener("click", () => {
+ bmEl("bm-page-next").addEventListener("click", () => {
bmChangePage(1);
});
// Form submit
- document.getElementById("bm-form").addEventListener("submit", bmSave);
+ bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); });
// 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) {
formWrap.addEventListener("click", (e) => {
if (e.target === formWrap) bmCloseForm();
@@ -734,24 +817,26 @@ function bmPopulateScopePicker() {
}
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();
}
});
// Select-all checkbox
- document.getElementById("bm-select-all").addEventListener("change", (e) => {
- const checked = e.target.checked;
- document.querySelectorAll(".bm-row-sel").forEach((cb) => {
+ bmEl("bm-select-all").addEventListener("change", (e) => {
+ const checked = (e.currentTarget as HTMLInputElement).checked;
+ document.querySelectorAll(".bm-row-sel").forEach((cb) => {
cb.checked = checked;
- if (checked) bmSelected.add(cb.dataset.bmId);
- else bmSelected.delete(cb.dataset.bmId);
+ const id = cb.dataset.bmId;
+ if (!id) return;
+ if (checked) bmSelected.add(id);
+ else bmSelected.delete(id);
});
bmUpdateSelectionUi();
});
// 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));
if (allSelected) {
bmSelected.clear();
@@ -759,49 +844,53 @@ function bmPopulateScopePicker() {
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
}
// Sync visible page checkboxes
- document.querySelectorAll(".bm-row-sel").forEach((cb) => {
- cb.checked = bmSelected.has(cb.dataset.bmId);
+ document.querySelectorAll(".bm-row-sel").forEach((cb) => {
+ cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId);
});
bmSyncSelectAllCheckbox();
bmUpdateSelectionUi();
});
// Delete Selected button
- document.getElementById("bm-del-selected-btn").addEventListener("click", () => {
- bmDeleteSelected();
+ bmEl("bm-del-selected-btn").addEventListener("click", () => {
+ void bmDeleteSelected();
});
// Move Selected button
- document.getElementById("bm-move-selected-btn").addEventListener("click", () => {
- bmMoveSelected();
+ bmEl("bm-move-selected-btn").addEventListener("click", () => {
+ void bmMoveSelected();
});
// Table action buttons and row checkboxes (event delegation)
- document.getElementById("bm-tbody").addEventListener("click", async (e) => {
- const checkbox = e.target.closest(".bm-row-sel");
+ bmEl("bm-tbody").addEventListener("click", (e) => { void (async () => {
+ if (!(e.target instanceof Element)) return;
+ const checkbox = e.target.closest(".bm-row-sel");
if (checkbox) {
- if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId);
- else bmSelected.delete(checkbox.dataset.bmId);
+ const id = checkbox.dataset.bmId;
+ if (!id) return;
+ if (checkbox.checked) bmSelected.add(id);
+ else bmSelected.delete(id);
bmSyncSelectAllCheckbox();
bmUpdateSelectionUi();
return;
}
- const tuneBtn = e.target.closest(".bm-tune-btn");
- const editBtn = e.target.closest(".bm-edit-btn");
- const delBtn = e.target.closest(".bm-del-btn");
+ const tuneBtn = e.target.closest(".bm-tune-btn");
+ const editBtn = e.target.closest(".bm-edit-btn");
+ const delBtn = e.target.closest(".bm-del-btn");
if (tuneBtn) {
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
- if (bm) await bmApply(bm);
+ if (bm) bmApply(bm);
} else if (editBtn) {
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
if (bm) bmOpenForm(bm);
} 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.
- bmFetch("");
+ void bmFetch("");
})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs
new file mode 100644
index 00000000..8ec76c1e
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bookmarks.test.mjs
@@ -0,0 +1,73 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// 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);
+});