Files
trx-rs/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js
T
sjgandClaude Opus 4.8 df7483fe30
CI / lint (push) Failing after 4s
CI / test (push) Successful in 10m37s
CI / frontend (push) Successful in 5m41s
CI / reuse (push) Successful in 5s
[feat](trx-rs): configurable DIG sideband for SDR (auto/USB/LSB)
DIG has no inherent sideband; on the SDR backend it was always demodulated
as USB. Make it resolve to USB or LSB via a policy that defaults to the
amateur SSB/data convention (USB at/above 10 MHz, LSB below) and can be
overridden globally from the advanced radio controls or per-bookmark.

Design: the logical DIG mode is kept in RigState (display, decoder gating)
while the SDR pipeline is handed a concrete USB/LSB demodulator resolved from
(policy, dial frequency). Resolution happens at the boundary — the rig for
the primary channel and the virtual-channel manager for vchans — so the hot
DSP/demod path is untouched. The resolved sideband is only re-pushed when it
actually changes (e.g. tuning DIG/Auto across 10 MHz), keeping ordinary
tuning glitch-free.

Core/protocol:
- New `DigSidebandPolicy { Auto, Usb, Lsb }` with `resolve(freq)` and an
  `effective_demod_mode()` helper (trx-core), re-exported at the crate root.
- `RigCommand::SetSdrDigSideband`, `RigSdr::set_sdr_dig_sideband`, and a
  `RigFilterState.sdr_dig_sideband` field for state sync; wired through the
  ClientCommand mapping.

Config: `[rig.sdr] dig_sideband = "auto"` (regenerated trx-rs.toml.example).

SDR backend: the vchan manager owns the shared policy (atomic); the rig
applies it to the primary channel and, on `set_sdr_dig_sideband`, re-resolves
all DIG virtual channels.

Frontend: a mode-gated "DIG sideband" selector in the SDR advanced controls
(POST /set_sdr_dig_sideband), reflecting server state; bookmarks gain an
optional `dig_sideband` field (form selector shown only for DIG) that, on
apply, sets the global policy before switching to DIG. The scheduler honours
it for automated bookmark activation too.

Tests: policy resolution / effective-mode / u8+parse round-trips (trx-core);
a vchan integration test asserting a DIG channel resolves to LSB below
10 MHz, flips with the policy, and still lists as DIG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 00:26:45 +02:00

688 lines
26 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// 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;
}
function bmOptionalEl(id) {
return document.getElementById(id);
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
var bmScope = "general";
function bmScopeParam(prefix, scope) {
const sep = prefix ? "&" : "?";
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
}
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`;
}
function bmEsc(str) {
const d = document.createElement("div");
d.appendChild(document.createTextNode(String(str)));
return d.innerHTML;
}
function bmCanControl() {
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
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() {
return hostState.lastActiveRigId || "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 = [];
}
bmOverlayRevision++;
if (typeof bridge.syncBookmarkMapLocators === "function") {
bridge.syncBookmarkMapLocators(bmOverlayList);
}
hostCore.scheduleSpectrumDraw();
}
async function bmFetch(categoryFilter) {
let url = "/bookmarks";
let hasQuery = false;
if (categoryFilter && categoryFilter !== "") {
url += "?category=" + encodeURIComponent(categoryFilter);
hasQuery = true;
}
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 = [];
}
bmSelected.clear();
bmUpdateSelectionUi();
bmSyncAccess();
bmApplyFilters();
void bmRefreshCategoryFilter(categoryFilter);
await overlayPromise;
}
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 {
}
}
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;
}
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" ? ' <span class="bm-scope-badge">G</span>' : "";
tr.innerHTML = `<td class="bm-col-sel"><input type="checkbox" class="bm-row-sel" data-bm-id="${bmEsc(bm.id)}"${checked} aria-label="Select ${bmEsc(bm.name)}" /></td><td class="bm-col-name">${bmEsc(bm.name)}${scopeBadge}</td><td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td><td class="bm-col-mode">${bmEsc(bm.mode)}</td><td class="bm-col-bw">${bwCell}</td><td class="bm-col-loc">${bmEsc(locatorCell)}</td><td class="bm-col-cat">${bmEsc(catCell)}</td><td class="bm-col-dec">${bmEsc(decoderCell)}</td><td class="bm-col-cmt">${bmEsc(commentCell)}</td><td class="bm-col-act"><button class="bm-tune-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Tune</button>` + (canControl ? `<button class="bm-edit-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Edit</button><button class="bm-del-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Delete</button>` : "") + `</td>`;
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 hostState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
}
function bmWriteDecoders(decoders) {
const set = new Set(decoders || []);
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
const el = bmOptionalEl("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 = "";
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
const label = document.createElement("label");
label.className = "bm-decoder-check";
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
container.appendChild(label);
});
}
function bmSyncDigSidebandVisibility() {
const label = bmOptionalEl("bm-dig-sideband-label");
if (!label) return;
const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
label.style.display = mode === "DIG" ? "" : "none";
}
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 || "" : "";
bmEl("bm-dig-sideband").value = bm ? bm.dig_sideband || "" : "";
bmWriteDecoders(bm?.decoders ?? []);
bmSyncDigSidebandVisibility();
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() {
const freqHz = hostState.lastFreqHz;
if (freqHz != null && Number.isFinite(freqHz)) {
bmEl("bm-freq").value = String(Math.round(freqHz));
}
if (hostState.lastModeName) {
bmEl("bm-mode").value = hostState.lastModeName;
}
if (hostState.currentBandwidthHz > 0) {
bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
}
const activeDecoders = hostState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-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 dig_sideband = mode.toUpperCase() === "DIG" ? bmEl("bm-dig-sideband").value || null : null;
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,
dig_sideband
};
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 {
const modeEl = document.getElementById("mode");
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
}
if (bm.bandwidth_hz) {
hostState.currentBandwidthHz = bm.bandwidth_hz;
hostCore.syncBandwidthInput(bm.bandwidth_hz);
}
hostCore.armOptimisticFrequency(bm.freq_hz);
hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
if (hostState.lastSpectrumData) {
hostCore.scheduleSpectrumDraw();
}
const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl();
if ((bm.mode || "").toUpperCase() === "DIG" && bm.dig_sideband) {
const p = bm.dig_sideband.toLowerCase();
if (p === "auto" || p === "usb" || p === "lsb") {
await hostCore.postPath("/set_sdr_dig_sideband?policy=" + encodeURIComponent(p));
}
}
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
}
if (bm.bandwidth_hz) {
const bwHandledByVchan = await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
if (!bwHandledByVchan) {
await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
}
}
hostCore.setRigFrequency(bm.freq_hz);
})();
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const modeUp = (bm.mode || "").toUpperCase();
const allToggleDecoders = hostState.decoderRegistry.filter(
(d) => d.activation === "toggle"
);
const decoderPromise = allToggleDecoders.length ? (async () => {
let statusUrl = "/status";
const rigId = hostState.lastActiveRigId;
if (rigId) {
statusUrl += "?remote=" + encodeURIComponent(rigId);
}
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(hostCore.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.modules.bookmarks = {
get overlayList() {
return bmOverlayList;
},
get overlayRevision() {
return bmOverlayRevision;
},
refreshOverlay: bmFetchOverlay,
invalidateColors() {
bmOverlayRevision += 1;
},
apply: bmApply,
formatFrequency: bmFmtFreq,
fetch: bmFetch,
populateScopePicker: bmPopulateScopePicker
};
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 = hostState.lastRigIds;
const displayNames = hostState.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 = hostState.lastRigIds;
const displayNames = hostState.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();
hostCore.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-mode").addEventListener("input", bmSyncDigSidebandVisibility);
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("");
})();