Files
OpsLog/internal/cabrillo/cabrillo.go
T
2026-07-04 11:39:33 +02:00

165 lines
4.3 KiB
Go

// 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 ""
}