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); +});