From 5bbcaab71a4525b72da087423e811a204d95fd33 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 4 Jul 2026 11:39:33 +0200 Subject: [PATCH] feat: added Cabrillo export --- app.go | 78 +++++++++ frontend/src/App.tsx | 39 +++++ frontend/src/components/AwardRefSelector.tsx | 12 ++ frontend/src/components/QSOContextMenu.tsx | 22 ++- frontend/src/components/RecentQSOsGrid.tsx | 6 +- frontend/wailsjs/go/main/App.d.ts | 8 + frontend/wailsjs/go/main/App.js | 16 ++ frontend/wailsjs/go/models.ts | 14 ++ internal/cabrillo/cabrillo.go | 164 +++++++++++++++++++ 9 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 internal/cabrillo/cabrillo.go diff --git a/app.go b/app.go index f1aaed1..f5847d4 100644 --- a/app.go +++ b/app.go @@ -25,6 +25,7 @@ import ( "hamlog/internal/award" "hamlog/internal/awardref" "hamlog/internal/backup" + "hamlog/internal/cabrillo" "hamlog/internal/cat" "hamlog/internal/clublog" "hamlog/internal/cluster" @@ -3704,6 +3705,83 @@ func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64 return ex.ExportFileByIDs(a.ctx, path, ids) } +// CabrilloResult reports how many QSOs a Cabrillo export wrote and where. +type CabrilloResult struct { + Count int `json:"count"` + Path string `json:"path"` +} + +// cabrilloHeader fills the Cabrillo header from the active profile (callsign, +// operator, grid) plus the contest name the user supplied. +func (a *App) cabrilloHeader(contest string) cabrillo.Header { + h := cabrillo.Header{Contest: contest} + if a.profiles != nil { + if p, err := a.profiles.Active(a.ctx); err == nil { + h.Callsign = p.Callsign + h.Operators = p.Callsign + h.GridLocator = p.MyGrid + h.Name = p.OpName + } + } + return h +} + +// writeCabrillo collects the QSOs via the given iterator and writes a Cabrillo +// document to path. +func (a *App) writeCabrillo(path, contest string, iterate func(func(qso.QSO) error) error) (CabrilloResult, error) { + if a.qso == nil { + return CabrilloResult{}, fmt.Errorf("db not initialized") + } + if path == "" { + return CabrilloResult{}, fmt.Errorf("empty path") + } + var qsos []qso.QSO + if err := iterate(func(q qso.QSO) error { qsos = append(qsos, q); return nil }); err != nil { + return CabrilloResult{}, err + } + f, err := os.Create(path) + if err != nil { + return CabrilloResult{}, err + } + defer f.Close() + n, err := cabrillo.Generate(f, a.cabrilloHeader(contest), qsos) + if err != nil { + return CabrilloResult{}, err + } + return CabrilloResult{Count: n, Path: path}, nil +} + +// ExportCabrillo writes every QSO to a Cabrillo file. +func (a *App) ExportCabrillo(path, contest string) (CabrilloResult, error) { + return a.writeCabrillo(path, contest, func(fn func(qso.QSO) error) error { return a.qso.IterateAll(a.ctx, fn) }) +} + +// ExportCabrilloFiltered writes the QSOs matching the current filter. +func (a *App) ExportCabrilloFiltered(path, contest string, f qso.QueryFilter) (CabrilloResult, error) { + return a.writeCabrillo(path, contest, func(fn func(qso.QSO) error) error { return a.qso.IterateFiltered(a.ctx, f, fn) }) +} + +// ExportCabrilloSelected writes only the highlighted QSOs. +func (a *App) ExportCabrilloSelected(path, contest string, ids []int64) (CabrilloResult, error) { + if len(ids) == 0 { + return CabrilloResult{}, fmt.Errorf("no QSOs selected") + } + return a.writeCabrillo(path, contest, func(fn func(qso.QSO) error) error { return a.qso.IterateByIDs(a.ctx, ids, fn) }) +} + +// SaveCabrilloFile opens a save dialog for a Cabrillo (.cbr / .log) file. +func (a *App) SaveCabrilloFile() (string, error) { + suggested := "OpsLog_" + time.Now().UTC().Format("20060102_150405") + ".cbr" + return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ + Title: "Export Cabrillo", + DefaultFilename: suggested, + Filters: []wruntime.FileFilter{ + {DisplayName: "Cabrillo files (*.cbr, *.log)", Pattern: "*.cbr;*.log"}, + {DisplayName: "All files (*.*)", Pattern: "*.*"}, + }, + }) +} + // --- Lookup bindings --- // LookupCallsign returns the cached or freshly-fetched info for a callsign. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1104598..d2e0947 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { import { AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered, OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected, + SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected, GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO, UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, LookupCallsign, GetStationSettings, GetListsSettings, @@ -1980,6 +1981,40 @@ export default function App() { showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`); } catch (e: any) { setError(String(e?.message ?? e)); } } + // Cabrillo (contest log) export — prompts for the contest name, then writes a + // .cbr file. All / filtered / selected variants mirror the ADIF ones. + async function exportCabrillo() { + const contest = window.prompt('Contest name for Cabrillo (e.g. CQ-WW-SSB):', ''); + if (contest === null) return; + try { + const path = await SaveCabrilloFile(); + if (!path) return; + const r = await ExportCabrillo(path, contest); + showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`); + } catch (e: any) { setError(String(e?.message ?? e)); } + } + async function exportFilteredCabrillo() { + const contest = window.prompt('Contest name for Cabrillo (e.g. CQ-WW-SSB):', ''); + if (contest === null) return; + try { + const path = await SaveCabrilloFile(); + if (!path) return; + const f = buildActiveFilter(); + const r = await ExportCabrilloFiltered(path, contest, { ...f, limit: 0, offset: 0 } as any); + showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`); + } catch (e: any) { setError(String(e?.message ?? e)); } + } + async function exportSelectedCabrillo(ids: number[]) { + if (ids.length === 0) return; + const contest = window.prompt('Contest name for Cabrillo (e.g. CQ-WW-SSB):', ''); + if (contest === null) return; + try { + const path = await SaveCabrilloFile(); + if (!path) return; + const r = await ExportCabrilloSelected(path, contest, ids as any); + showToast(`Cabrillo: ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`); + } catch (e: any) { setError(String(e?.message ?? e)); } + } function askDelete(id: number) { setDeletingIds([id]); } // Delete the whole multi-row selection (Edit menu / Delete key). function askDeleteSelected() { @@ -2216,6 +2251,7 @@ export default function App() { { name: 'file', label: 'File', items: [ { type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' }, { type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 }, + { type: 'item', label: 'Export Cabrillo…', action: 'file.exportCabrillo', disabled: total === 0 }, { type: 'separator' }, { type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 }, { type: 'separator' }, @@ -2257,6 +2293,7 @@ export default function App() { switch (action) { case 'file.import': importAdif(); break; case 'file.export': exportAdif(); break; + case 'file.exportCabrillo': exportCabrillo(); break; case 'file.deleteall': setShowDeleteAll(true); break; case 'view.refresh': refresh(); break; case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break; @@ -3746,6 +3783,8 @@ export default function App() { onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportFiltered={exportFilteredADIF} + onExportCabrilloSelected={exportSelectedCabrillo} + onExportCabrilloFiltered={exportFilteredCabrillo} onDelete={(ids) => setDeletingIds(ids)} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} /> diff --git a/frontend/src/components/AwardRefSelector.tsx b/frontend/src/components/AwardRefSelector.tsx index 8f41314..17d974e 100644 --- a/frontend/src/components/AwardRefSelector.tsx +++ b/frontend/src/components/AwardRefSelector.tsx @@ -52,6 +52,18 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla const entries = value ? value.split(';').filter(Boolean) : []; + // When the reference set is cleared (a QSO was logged or the call was reset), + // also blank the picker's own state — the search box and the highlighted / + // selected reference — so the Awards tab starts fresh for the next contact. + useEffect(() => { + if (!value) { + setSelectedEntry(null); + setSelectedRef(null); + setQ(''); + setSearchResults([]); + } + }, [value]); + useEffect(() => { Promise.all([GetAwardDefs(), GetAwardReferenceMeta()]) .then(([d, m]) => { diff --git a/frontend/src/components/QSOContextMenu.tsx b/frontend/src/components/QSOContextMenu.tsx index 477db40..1f88d84 100644 --- a/frontend/src/components/QSOContextMenu.tsx +++ b/frontend/src/components/QSOContextMenu.tsx @@ -15,6 +15,8 @@ type Props = { onBulkEdit?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void; onExportFiltered?: () => void; + onExportCabrilloSelected?: (ids: number[]) => void; + onExportCabrilloFiltered?: () => void; onDelete?: (ids: number[]) => void; }; @@ -32,7 +34,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [ // or picks a command. (We deliberately do NOT close on scroll/resize: the QSO // list auto-refreshes and AG Grid fires internal scroll events on refresh, // which used to dismiss the menu the instant it appeared.) -export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete }: Props) { +export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) { useEffect(() => { if (!menu) return; const close = () => onClose(); @@ -142,6 +144,24 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ Export filtered view to ADIF (no limit) )} + {onExportCabrilloSelected && ( + + )} + {onExportCabrilloFiltered && ( + + )} )} diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index cf7605d..4e688d3 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -59,6 +59,8 @@ type Props = { onBulkEdit?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void; onExportFiltered?: () => void; + onExportCabrilloSelected?: (ids: number[]) => void; + onExportCabrilloFiltered?: () => void; onDelete?: (ids: number[]) => void; // One column per defined award; the cell shows the reference this QSO counts // for (from row.award_refs[CODE], attached by the parent). Hidden by default. @@ -228,7 +230,7 @@ export const GROUP_ORDER = [ 'Contest', 'Propagation', 'My station', 'Misc', ]; -export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) { +export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) { const gridRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); const [menu, setMenu] = useState(null); @@ -416,6 +418,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo onBulkEdit={onBulkEdit} onExportSelected={onExportSelected} onExportFiltered={onExportFiltered} + onExportCabrilloSelected={onExportCabrilloSelected} + onExportCabrilloFiltered={onExportCabrilloFiltered} onDelete={onDelete} /> diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 9e4bf1c..5f00f58 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -131,6 +131,12 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array): export function ExportAwards():Promise; +export function ExportCabrillo(arg1:string,arg2:string):Promise; + +export function ExportCabrilloFiltered(arg1:string,arg2:string,arg3:qso.QueryFilter):Promise; + +export function ExportCabrilloSelected(arg1:string,arg2:string,arg3:Array):Promise; + export function FilterFields():Promise>; export function FindQSOsForUpload(arg1:string,arg2:string):Promise>; @@ -535,6 +541,8 @@ export function SaveBackupSettings(arg1:main.BackupSettings):Promise; export function SaveCATSettings(arg1:main.CATSettings):Promise; +export function SaveCabrilloFile():Promise; + export function SaveClusterServer(arg1:cluster.ServerConfig):Promise; export function SaveEmailSettings(arg1:main.EmailSettings):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 2ea9b0c..9533836 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -226,6 +226,18 @@ export function ExportAwards() { return window['go']['main']['App']['ExportAwards'](); } +export function ExportCabrillo(arg1, arg2) { + return window['go']['main']['App']['ExportCabrillo'](arg1, arg2); +} + +export function ExportCabrilloFiltered(arg1, arg2, arg3) { + return window['go']['main']['App']['ExportCabrilloFiltered'](arg1, arg2, arg3); +} + +export function ExportCabrilloSelected(arg1, arg2, arg3) { + return window['go']['main']['App']['ExportCabrilloSelected'](arg1, arg2, arg3); +} + export function FilterFields() { return window['go']['main']['App']['FilterFields'](); } @@ -1034,6 +1046,10 @@ export function SaveCATSettings(arg1) { return window['go']['main']['App']['SaveCATSettings'](arg1); } +export function SaveCabrilloFile() { + return window['go']['main']['App']['SaveCabrilloFile'](); +} + export function SaveClusterServer(arg1) { return window['go']['main']['App']['SaveClusterServer'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 6fa9677..73718f6 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1253,6 +1253,20 @@ export namespace main { this.digital_default = source["digital_default"]; } } + export class CabrilloResult { + count: number; + path: string; + + static createFrom(source: any = {}) { + return new CabrilloResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.count = source["count"]; + this.path = source["path"]; + } + } export class ChatMessage { id: number; operator: string; diff --git a/internal/cabrillo/cabrillo.go b/internal/cabrillo/cabrillo.go new file mode 100644 index 0000000..524857c --- /dev/null +++ b/internal/cabrillo/cabrillo.go @@ -0,0 +1,164 @@ +// Package cabrillo writes a Cabrillo v3 contest log from OpsLog QSOs — the +// format most contest sponsors require for log submission (a header of +// CATEGORY-* / CONTEST / CALLSIGN tags followed by one "QSO:" line each). +package cabrillo + +import ( + "fmt" + "io" + "strings" + + "hamlog/internal/qso" +) + +// Header holds the Cabrillo header fields. Only Callsign and Contest really +// matter; the rest carry sensible defaults the operator can edit in the file +// before submitting (categories vary per contest). +type Header struct { + Callsign string + Contest string + Operators string + CatOperator string // SINGLE-OP | MULTI-OP | CHECKLOG + CatBand string // ALL | 20M | … + CatMode string // CW | SSB | MIXED | … + CatPower string // HIGH | LOW | QRP + GridLocator string + Name string + Email string + Club string +} + +func def(v, d string) string { + if strings.TrimSpace(v) == "" { + return d + } + return v +} + +// Generate writes the Cabrillo document for qsos and returns the number of QSO +// lines written (rows without a usable frequency/callsign are skipped). +func Generate(w io.Writer, h Header, qsos []qso.QSO) (int, error) { + myCall := strings.ToUpper(strings.TrimSpace(h.Callsign)) + fmt.Fprintln(w, "START-OF-LOG: 3.0") + fmt.Fprintln(w, "CREATED-BY: OpsLog") + fmt.Fprintf(w, "CONTEST: %s\n", strings.ToUpper(def(h.Contest, "UNKNOWN"))) + fmt.Fprintf(w, "CALLSIGN: %s\n", myCall) + fmt.Fprintf(w, "CATEGORY-OPERATOR: %s\n", strings.ToUpper(def(h.CatOperator, "SINGLE-OP"))) + fmt.Fprintf(w, "CATEGORY-BAND: %s\n", strings.ToUpper(def(h.CatBand, "ALL"))) + fmt.Fprintf(w, "CATEGORY-MODE: %s\n", strings.ToUpper(def(h.CatMode, "MIXED"))) + fmt.Fprintf(w, "CATEGORY-POWER: %s\n", strings.ToUpper(def(h.CatPower, "HIGH"))) + fmt.Fprintln(w, "CLAIMED-SCORE: 0") + fmt.Fprintf(w, "OPERATORS: %s\n", strings.ToUpper(def(h.Operators, myCall))) + if h.GridLocator != "" { + fmt.Fprintf(w, "GRID-LOCATOR: %s\n", strings.ToUpper(h.GridLocator)) + } + if h.Name != "" { + fmt.Fprintf(w, "NAME: %s\n", h.Name) + } + if h.Club != "" { + fmt.Fprintf(w, "CLUB: %s\n", h.Club) + } + if h.Email != "" { + fmt.Fprintf(w, "EMAIL: %s\n", h.Email) + } + + n := 0 + for _, q := range qsos { + call := strings.ToUpper(strings.TrimSpace(q.Callsign)) + if call == "" { + continue + } + khz := freqKHz(q) + mode := cabrilloMode(q.Mode) + myRST := def(q.RSTSent, defaultRST(mode)) + hisRST := def(q.RSTRcvd, defaultRST(mode)) + myExch := exchange(q.STX, q.STXString) + hisExch := exchange(q.SRX, q.SRXString) + // QSO: