CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m24s
CI / frontend (pull_request) Successful in 5m12s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 8m8s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
447 lines
16 KiB
JavaScript
447 lines
16 KiB
JavaScript
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 = '<tr><td colspan="4" class="log-empty">Nothing worked yet.</td></tr>';
|
|
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 = '<tr><td colspan="10" class="log-empty">No contacts yet. Work someone and log them here, or bring a log in with Import ADIF.</td></tr>';
|
|
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();
|