import { hostCore, hostState } from "./chunk-KL66PICH.js"; // src/plugins/logbook.ts var bridge = window; var el = (id) => document.getElementById(id); var form = el("log-entry-form"); var callInput = el("log-call"); var freqInput = el("log-freq"); var modeInput = el("log-mode"); var rstSentInput = el("log-rst-sent"); var rstRcvdInput = el("log-rst-rcvd"); var gridInput = el("log-grid"); var nameInput = el("log-name"); var commentInput = el("log-comment"); var operatorInput = el("log-operator"); var rowsBody = el("log-rows"); var summaryEl = el("log-summary"); var workedEl = el("log-worked-before"); var clockEl = el("log-clock"); var stationCallEl = el("log-station-callsign"); var stationRigEl = el("log-station-rig-name"); var stationGridEl = el("log-station-grid"); var filterCall = el("log-filter-call"); var filterBand = el("log-filter-band"); var filterMode = el("log-filter-mode"); var importBtn = el("log-import-btn"); var importFile = el("log-import-file"); var exportLink = el("log-export-btn"); var clearBtn = el("log-clear-btn"); var saveBtn = el("log-save-btn"); var contestIdInput = el("log-contest-id"); var stxInput = el("log-stx"); var srxInput = el("log-srx"); var statisticsRows = el("log-statistics-rows"); var cabrilloContest = el("log-cbr-contest"); var cabrilloCallsign = el("log-cbr-callsign"); var cabrilloOperator = el("log-cbr-operator"); var cabrilloPower = el("log-cbr-power"); var cabrilloScore = el("log-cbr-score"); var cabrilloExport = el("log-cbr-export"); var entryStartedAt = null; var entryRigId = null; var entryRigName = null; var entryGrid = null; var qsos = []; var workedRequest = 0; function canWriteLogbook() { return !hostState.authEnabled || hostState.authRoles.includes("administrator") || hostState.authRoles.includes("write"); } function notify(message, kind) { if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0); else hostCore.showHint(message, 2e3); } async function getJson(path) { const response = await fetch(path); if (!response.ok) throw new Error(`HTTP ${String(response.status)}`); return await response.json(); } function formatFreq(hz) { if (!Number.isFinite(hz) || hz <= 0) return ""; return String(Number((hz / 1e6).toFixed(6))); } function parseFreq(text) { const value = Number(text.trim().replace(/\s+/g, "").replace(",", ".")); if (!Number.isFinite(value) || value <= 0) return null; return value < 1e5 ? Math.round(value * 1e6) : Math.round(value); } function numberOrNull(text) { const trimmed = (text ?? "").trim(); if (!trimmed || !/^\d+$/.test(trimmed)) return null; const value = Number(trimmed); return Number.isSafeInteger(value) ? value : null; } function utcDate(iso) { const date = new Date(iso); return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10); } function utcTime(iso) { const date = new Date(iso); return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19); } async function openEntry(seed = {}) { const params = new URLSearchParams(); if (hostState.lastActiveRigId) params.set("remote", hostState.lastActiveRigId); if (seed.decoder) params.set("decoder", seed.decoder); if (seed.call) params.set("call", seed.call); if (seed.gridsquare) params.set("gridsquare", seed.gridsquare); try { const prefill = await getJson(`/api/logbook/prefill?${params.toString()}`); applyPrefill(prefill); } catch (error) { console.error("logbook prefill failed", error); } } function applyPrefill(prefill) { entryStartedAt = prefill.started_at; entryRigId = prefill.rig_id; entryRigName = prefill.my_rig; if (freqInput) freqInput.value = formatFreq(prefill.freq_hz); if (modeInput) modeInput.value = prefill.submode ?? prefill.mode; if (callInput && prefill.call) callInput.value = prefill.call; if (gridInput && prefill.gridsquare) gridInput.value = prefill.gridsquare; if (stationRigEl) stationRigEl.textContent = prefill.my_rig ?? "no rig"; showClock(prefill); updateWorkedBefore(); } function showClock(prefill) { if (!clockEl) return; const time = utcTime(prefill.started_at); const drift = Math.abs(Date.now() - prefill.epoch_ms); clockEl.textContent = drift > 1e3 ? `${time}Z · your clock is ${(drift / 1e3).toFixed(0)}s out` : `${time}Z`; clockEl.classList.toggle("is-adrift", drift > 1e3); } function resetEntry() { for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) { if (input) input.value = ""; } if (workedEl) workedEl.textContent = ""; void openEntry(); } async function submitEntry(event) { event.preventDefault(); const call = callInput?.value.trim() ?? ""; if (!call) { notify("A contact needs a callsign", "error"); callInput?.focus(); return; } const freqHz = parseFreq(freqInput?.value ?? ""); if (freqHz == null) { notify("A contact needs a frequency", "error"); freqInput?.focus(); return; } const typedMode = (modeInput?.value ?? "").trim().toUpperCase(); const isSideband = typedMode === "USB" || typedMode === "LSB"; const body = { // The server stamps the time; this is the one it gave when the entry // opened, so a contact logged five minutes later keeps the time it started. started_at: entryStartedAt, call, freq_hz: freqHz, mode: isSideband ? "SSB" : typedMode, submode: isSideband ? typedMode : null, rst_sent: rstSentInput?.value ?? null, rst_rcvd: rstRcvdInput?.value ?? null, gridsquare: gridInput?.value ?? null, name: nameInput?.value ?? null, comment: commentInput?.value ?? null, contest_id: contestIdInput?.value ?? null, // The exchange is a number when it is a serial and a word when it is a // zone or a section; both are kept, and the log writes whichever it has. stx: numberOrNull(stxInput?.value), stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null, srx: numberOrNull(srxInput?.value), srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null, station_callsign: stationCallEl?.textContent?.trim() ?? null, operator: operatorInput?.value ?? null, my_gridsquare: entryGrid, my_rig: entryRigName, rig_id: entryRigId }; if (saveBtn) saveBtn.disabled = true; try { const response = await fetch("/api/logbook", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (!response.ok) { const detail = await response.json().catch(() => ({})); throw new Error(detail.error ?? `HTTP ${String(response.status)}`); } notify(`${call} logged`); const sent = numberOrNull(stxInput?.value); resetEntry(); if (stxInput && sent != null) stxInput.value = String(sent + 1); await refreshLog(); } catch (error) { notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error"); } finally { if (saveBtn) saveBtn.disabled = false; } } function updateWorkedBefore() { if (!workedEl) return; const call = callInput?.value.trim() ?? ""; if (call.length < 3) { workedEl.textContent = ""; return; } const request = ++workedRequest; void getJson( `/api/logbook/worked/${encodeURIComponent(call)}` ).then((answer) => { if (request !== workedRequest || !workedEl) return; if (answer.worked.length === 0) { workedEl.textContent = "Not worked before"; workedEl.classList.remove("is-worked"); return; } const where = answer.worked.map((entry) => `${entry.band} ${entry.mode}`).join(", "); workedEl.textContent = `Worked before: ${where}`; workedEl.classList.add("is-worked"); }).catch(() => { }); } function currentQuery() { const params = new URLSearchParams(); const call = filterCall?.value.trim(); if (call) params.set("call", call); if (filterBand?.value) params.set("band", filterBand.value); if (filterMode?.value) params.set("mode", filterMode.value); return params.toString(); } async function refreshLog() { try { const query = currentQuery(); const answer = await getJson( `/api/logbook${query ? `?${query}` : ""}` ); qsos = answer.qsos; renderRows(); renderFilterOptions(); if (summaryEl) { summaryEl.textContent = query ? `${String(qsos.length)} of ${String(answer.total)} contacts` : `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`; } if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`; await refreshStatistics(); } catch (error) { console.error("logbook read failed", error); } } async function refreshStatistics() { if (!statisticsRows) return; try { const answer = await getJson("/api/logbook/statistics"); if (answer.bands.length === 0) { statisticsRows.innerHTML = 'Nothing worked yet.'; return; } const fragment = document.createDocumentFragment(); for (const band of answer.bands) { const row = document.createElement("tr"); for (const value of [band.band, band.contacts, band.stations, band.confirmed]) { const cell = document.createElement("td"); cell.textContent = String(value); row.appendChild(cell); } fragment.appendChild(row); } statisticsRows.replaceChildren(fragment); } catch (error) { console.error("logbook statistics failed", error); } } function syncCabrilloLink() { if (!cabrilloExport) return; const params = new URLSearchParams(); const contest = cabrilloContest?.value.trim(); if (contest) { params.set("contest", contest); } const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? ""); if (callsign) params.set("callsign", callsign); if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value); if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value); const score = numberOrNull(cabrilloScore?.value); if (score != null) params.set("claimed_score", String(score)); const operator = operatorInput?.value.trim(); if (operator) params.set("operators", operator); cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`; } function renderRows() { if (!rowsBody) return; if (qsos.length === 0) { rowsBody.innerHTML = 'No contacts yet. Work someone and log them here, or bring a log in with Import ADIF.'; return; } const fragment = document.createDocumentFragment(); for (const qso of qsos) { const row = document.createElement("tr"); row.dataset.qsoId = qso.id; const cells = [ utcDate(qso.started_at), utcTime(qso.started_at), qso.call, qso.band ?? "", qso.submode ?? qso.mode, qso.rst_sent ?? "", qso.rst_rcvd ?? "", qso.gridsquare ?? "", qso.my_rig ?? "", qso.confirmed ? "✓" : "" ]; for (const [index, value] of cells.entries()) { const cell = document.createElement("td"); cell.textContent = value ?? ""; if (index === 2) cell.className = "log-cell-call"; row.appendChild(cell); } const actions = document.createElement("td"); if (!canWriteLogbook()) { row.appendChild(actions); fragment.appendChild(row); continue; } const confirm = document.createElement("button"); confirm.type = "button"; confirm.className = "log-row-btn"; confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm"; confirm.title = qso.confirmed ? "Mark this contact as not confirmed" : "Mark this contact confirmed by QSL"; confirm.addEventListener("click", () => { void setConfirmed(qso, !qso.confirmed); }); actions.appendChild(confirm); const remove = document.createElement("button"); remove.type = "button"; remove.className = "log-row-btn"; remove.textContent = "Delete"; remove.setAttribute("aria-label", `Delete the contact with ${qso.call}`); remove.addEventListener("click", () => { void deleteQso(qso); }); actions.appendChild(remove); row.appendChild(actions); fragment.appendChild(row); } rowsBody.replaceChildren(fragment); } function renderFilterOptions() { for (const [select, values] of [ [filterBand, [...new Set(qsos.map((q) => q.band).filter((b) => !!b))]], [filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]] ]) { if (!select) continue; const chosen = select.value; const known = new Set([...select.options].map((option) => option.value)); for (const value of [...values].sort()) { if (known.has(value)) continue; select.add(new Option(value, value)); } select.value = chosen; } } async function setConfirmed(qso, confirmed) { try { const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...qso, // A card is a card: this is the paper one, and an electronic // confirmation the log already holds is left where it is. qsl_rcvd: confirmed ? "Y" : "N" }) }); if (!response.ok) throw new Error(`HTTP ${String(response.status)}`); await refreshLog(); } catch (error) { notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error"); } } async function deleteQso(qso) { const confirmed = await bridge.trxUi.confirm({ title: "Delete this contact?", message: `${qso.call} on ${qso.band ?? formatFreq(qso.freq_hz)} will be removed from the log.`, confirmLabel: "Delete" }); if (!confirmed) return; try { const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { method: "DELETE" }); if (!response.ok) throw new Error(`HTTP ${String(response.status)}`); await refreshLog(); } catch (error) { notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error"); } } async function importAdif(file) { try { const response = await fetch("/api/logbook/import", { method: "POST", body: await file.arrayBuffer() }); if (!response.ok) throw new Error(`HTTP ${String(response.status)}`); const outcome = await response.json(); const parts = [`${String(outcome.added)} added`]; if (outcome.duplicate > 0) parts.push(`${String(outcome.duplicate)} already held`); if (outcome.rejected.length > 0) parts.push(`${String(outcome.rejected.length)} not readable`); notify(parts.join(", ")); await refreshLog(); } catch (error) { notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error"); } } function renderStation() { const callsign = hostState.ownerCallsign ?? ""; if (stationCallEl) stationCallEl.textContent = callsign || "no callsign set"; if (operatorInput && !operatorInput.value) operatorInput.value = callsign; if (stationGridEl) { const grid = hostState.serverLat != null && hostState.serverLon != null ? hostCore.latLonToMaidenhead(hostState.serverLat, hostState.serverLon) : ""; entryGrid = grid || null; stationGridEl.textContent = grid; } } form?.addEventListener("submit", (event) => { void submitEntry(event); }); clearBtn?.addEventListener("click", resetEntry); callInput?.addEventListener("input", updateWorkedBefore); for (const control of [filterCall, filterBand, filterMode]) { control?.addEventListener("input", () => { void refreshLog(); }); control?.addEventListener("change", () => { void refreshLog(); }); } for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) { control?.addEventListener("input", syncCabrilloLink); control?.addEventListener("change", syncCabrilloLink); } importBtn?.addEventListener("click", () => { importFile?.click(); }); importFile?.addEventListener("change", () => { const file = importFile.files?.[0]; if (file) void importAdif(file); importFile.value = ""; }); if (canWriteLogbook()) { bridge.logContact = (seed) => { bridge.navigateToTab?.("logbook"); void openEntry(seed).then(() => callInput?.focus()); }; } else { if (form) form.style.display = "none"; if (importBtn) importBtn.style.display = "none"; } renderStation(); if (cabrilloCallsign && !cabrilloCallsign.value) { cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? ""; } syncCabrilloLink(); if (canWriteLogbook()) void openEntry(); void refreshLog();