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: