feat: added Cabrillo export

This commit is contained in:
2026-07-04 11:39:33 +02:00
parent 18438d7737
commit 5bbcaab71a
9 changed files with 357 additions and 2 deletions
+78
View File
@@ -25,6 +25,7 @@ import (
"hamlog/internal/award" "hamlog/internal/award"
"hamlog/internal/awardref" "hamlog/internal/awardref"
"hamlog/internal/backup" "hamlog/internal/backup"
"hamlog/internal/cabrillo"
"hamlog/internal/cat" "hamlog/internal/cat"
"hamlog/internal/clublog" "hamlog/internal/clublog"
"hamlog/internal/cluster" "hamlog/internal/cluster"
@@ -3704,6 +3705,83 @@ func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64
return ex.ExportFileByIDs(a.ctx, path, ids) 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 --- // --- Lookup bindings ---
// LookupCallsign returns the cached or freshly-fetched info for a callsign. // LookupCallsign returns the cached or freshly-fetched info for a callsign.
+39
View File
@@ -7,6 +7,7 @@ import {
import { import {
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered, AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected, OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO, GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail, UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings, LookupCallsign, GetStationSettings, GetListsSettings,
@@ -1980,6 +1981,40 @@ export default function App() {
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''}${r.path}`); showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); } } 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]); } function askDelete(id: number) { setDeletingIds([id]); }
// Delete the whole multi-row selection (Edit menu / Delete key). // Delete the whole multi-row selection (Edit menu / Delete key).
function askDeleteSelected() { function askDeleteSelected() {
@@ -2216,6 +2251,7 @@ export default function App() {
{ name: 'file', label: 'File', items: [ { name: 'file', label: 'File', items: [
{ type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' }, { 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: 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: 'separator' },
{ type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 }, { type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
{ type: 'separator' }, { type: 'separator' },
@@ -2257,6 +2293,7 @@ export default function App() {
switch (action) { switch (action) {
case 'file.import': importAdif(); break; case 'file.import': importAdif(); break;
case 'file.export': exportAdif(); break; case 'file.export': exportAdif(); break;
case 'file.exportCabrillo': exportCabrillo(); break;
case 'file.deleteall': setShowDeleteAll(true); break; case 'file.deleteall': setShowDeleteAll(true); break;
case 'view.refresh': refresh(); break; case 'view.refresh': refresh(); break;
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break; case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
@@ -3746,6 +3783,8 @@ export default function App() {
onBulkEdit={openBulkEdit} onBulkEdit={openBulkEdit}
onExportSelected={exportSelectedADIF} onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF} onExportFiltered={exportFilteredADIF}
onExportCabrilloSelected={exportSelectedCabrillo}
onExportCabrilloFiltered={exportFilteredCabrillo}
onDelete={(ids) => setDeletingIds(ids)} onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
/> />
@@ -52,6 +52,18 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
const entries = value ? value.split(';').filter(Boolean) : []; 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(() => { useEffect(() => {
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()]) Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
.then(([d, m]) => { .then(([d, m]) => {
+21 -1
View File
@@ -15,6 +15,8 @@ type Props = {
onBulkEdit?: (ids: number[]) => void; onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void; onExportFiltered?: () => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void;
onDelete?: (ids: number[]) => 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 // 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, // list auto-refreshes and AG Grid fires internal scroll events on refresh,
// which used to dismiss the menu the instant it appeared.) // 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(() => { useEffect(() => {
if (!menu) return; if (!menu) return;
const close = () => onClose(); const close = () => onClose();
@@ -142,6 +144,24 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
<span>Export filtered view to ADIF (no limit)</span> <span>Export filtered view to ADIF (no limit)</span>
</button> </button>
)} )}
{onExportCabrilloSelected && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportCabrilloSelected(menu.ids); onClose(); }}
>
<FileDown className="size-4 text-amber-600" />
<span>Export selected to Cabrillo ({n})</span>
</button>
)}
{onExportCabrilloFiltered && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportCabrilloFiltered(); onClose(); }}
>
<FileDown className="size-4 text-amber-700" />
<span>Export filtered view to Cabrillo (no limit)</span>
</button>
)}
</> </>
)} )}
+5 -1
View File
@@ -59,6 +59,8 @@ type Props = {
onBulkEdit?: (ids: number[]) => void; onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void; onExportFiltered?: () => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void;
onDelete?: (ids: number[]) => void; onDelete?: (ids: number[]) => void;
// One column per defined award; the cell shows the reference this QSO counts // 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. // 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', '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<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -416,6 +418,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
onBulkEdit={onBulkEdit} onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected} onExportSelected={onExportSelected}
onExportFiltered={onExportFiltered} onExportFiltered={onExportFiltered}
onExportCabrilloSelected={onExportCabrilloSelected}
onExportCabrilloFiltered={onExportCabrilloFiltered}
onDelete={onDelete} onDelete={onDelete}
/> />
+8
View File
@@ -131,6 +131,12 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
export function ExportAwards():Promise<string>; export function ExportAwards():Promise<string>;
export function ExportCabrillo(arg1:string,arg2:string):Promise<main.CabrilloResult>;
export function ExportCabrilloFiltered(arg1:string,arg2:string,arg3:qso.QueryFilter):Promise<main.CabrilloResult>;
export function ExportCabrilloSelected(arg1:string,arg2:string,arg3:Array<number>):Promise<main.CabrilloResult>;
export function FilterFields():Promise<Array<string>>; export function FilterFields():Promise<Array<string>>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>; export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
@@ -535,6 +541,8 @@ export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>; export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveCabrilloFile():Promise<string>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>; export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>; export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
+16
View File
@@ -226,6 +226,18 @@ export function ExportAwards() {
return window['go']['main']['App']['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() { export function FilterFields() {
return window['go']['main']['App']['FilterFields'](); return window['go']['main']['App']['FilterFields']();
} }
@@ -1034,6 +1046,10 @@ export function SaveCATSettings(arg1) {
return window['go']['main']['App']['SaveCATSettings'](arg1); return window['go']['main']['App']['SaveCATSettings'](arg1);
} }
export function SaveCabrilloFile() {
return window['go']['main']['App']['SaveCabrilloFile']();
}
export function SaveClusterServer(arg1) { export function SaveClusterServer(arg1) {
return window['go']['main']['App']['SaveClusterServer'](arg1); return window['go']['main']['App']['SaveClusterServer'](arg1);
} }
+14
View File
@@ -1253,6 +1253,20 @@ export namespace main {
this.digital_default = source["digital_default"]; 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 { export class ChatMessage {
id: number; id: number;
operator: string; operator: string;
+164
View File
@@ -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: <freq> <mo> <date> <time> <mycall> <myrst> <myexch> <call> <rst> <exch>
fmt.Fprintf(w, "QSO: %5d %-2s %s %s %-13s %-3s %-6s %-13s %-3s %-6s\n",
khz, mode,
q.QSODate.UTC().Format("2006-01-02"), q.QSODate.UTC().Format("1504"),
myCall, myRST, myExch, call, hisRST, hisExch)
n++
}
fmt.Fprintln(w, "END-OF-LOG:")
return n, nil
}
// freqKHz returns the QSO frequency in kHz, falling back to a nominal frequency
// for the band when the exact frequency wasn't logged.
func freqKHz(q qso.QSO) int {
if q.FreqHz != nil && *q.FreqHz > 0 {
return int((*q.FreqHz + 500) / 1000)
}
return bandNominalKHz(q.Band)
}
func bandNominalKHz(band string) int {
switch strings.ToLower(strings.TrimSpace(band)) {
case "160m":
return 1800
case "80m":
return 3500
case "60m":
return 5350
case "40m":
return 7000
case "30m":
return 10100
case "20m":
return 14000
case "17m":
return 18068
case "15m":
return 21000
case "12m":
return 24890
case "10m":
return 28000
case "6m":
return 50000
case "2m":
return 144000
case "70cm":
return 432000
}
return 0
}
// cabrilloMode maps an ADIF mode to the two-letter Cabrillo mode code.
func cabrilloMode(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW":
return "CW"
case "RTTY":
return "RY"
case "FM":
return "FM"
case "SSB", "USB", "LSB", "AM":
return "PH"
case "":
return "PH"
default:
return "DG" // FT8/FT4/PSK/JT/DATA and other digital modes
}
}
func defaultRST(cabMode string) string {
if cabMode == "CW" || cabMode == "RY" || cabMode == "DG" {
return "599"
}
return "59"
}
// exchange renders the contest exchange: the string field if set, else the
// numeric serial, else empty.
func exchange(num *int, str string) string {
if s := strings.TrimSpace(str); s != "" {
return s
}
if num != nil && *num > 0 {
return fmt.Sprintf("%d", *num)
}
return ""
}