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>
688 lines
26 KiB
JavaScript
688 lines
26 KiB
JavaScript
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("");
|
||
})();
|