Files
trx-rs/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js
T
sjgandClaude Opus 5 0d4c657b97
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 1s
CI / frontend (pull_request) Failing after 41s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 2s
CI / frontend (push) Failing after 36s
CI / reuse (push) Failing after 1s
[fix](trx-frontend-http): route feature bundles through the host contract
The bookmark fix addressed one instance of a defect the TypeScript
migration left across the feature entries.  app.js stopped being a
classic script, so its top-level declarations are no longer shared
globals, but the converted entries kept reading them as window
properties that nothing publishes.

Restore the broken behavior:

- ais, aprs, hf-aprs read serverLat, serverLon and haversineKm as
  undefined, so every positioned packet rendered an empty distance.
- ais, aprs, hf-aprs, cw, sat, vdes, wefax, wspr called an undefined
  postPath, so clear-history and decoder toggles threw.
- scheduler read authRole as undefined, so the lazy-load path never
  self-initialized and the Settings tab opened an inert scheduler.
- background-decode read authEnabled as undefined, so control gating
  fell back to role-only.
- vchan read fifteen application values and services as undefined:
  mode and bandwidth sync, the out-of-band hint, RX audio restart, and
  the frequency field all silently no-opped on a virtual channel.
- vchan wrapped window.refreshFreqDisplay, capturing an undefined
  original exactly as it did for setRigFrequency, so leaving a channel
  never restored the application's own frequency display.
- _audioChannelOverride was a const that nothing could assign, so RX
  audio always subscribed to the primary channel.
- ftx-family read fmtTime, a helper legacy ft8.js owned locally, so
  decode bar timestamps rendered empty.

Declare the contract once in plugins/host.ts and import it from the
feature entries, rather than restoring globals that
docs/frontend-architecture.md excludes.  trx.state gains jogUnit,
rxActive and audioChannelOverride, and makes lastModeName writable;
trx.core gains the tuning, RDS, WFM, jog and RX audio services the
entries need.  vchan interception moves to an interceptFreqDisplay
service method that refreshFreqDisplay calls, matching the frequency,
mode and bandwidth interception it already registers.

Reading registry-built elements through a strict lookup is the same
defect as in bookmarks: renderTimelineNeedle guards its result, but
schedulerEl throws, so the now-initializing scheduler crashed on the
timeline needle group that its own SVG creates.

Feature tests move onto a shared host fixture, and entries that now
import a common module are bundled through bundleEntry like the other
shared-module entries.  Covers scheduler self-initialization and the
distance path that the bare window reads broke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:20:17 +02:00

667 lines
25 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 {
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 || hostState.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() {
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 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() {
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 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 {
const modeEl = document.getElementById("mode");
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
}
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();
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-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("");
})();