This commit is contained in:
2026-06-06 14:16:30 +02:00
parent f91f9ff3b8
commit 17f7a00bd7
19 changed files with 1278 additions and 91 deletions
+38 -5
View File
@@ -92,7 +92,7 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
ver := strings.TrimSpace(e.AppVersion)
now := time.Now().UTC().Format("20060102 150405")
fmt.Fprintf(bw, "# ADIF export by %s %s — %s UTC\n", app, ver, now)
fmt.Fprintf(bw, "<ADIF_VER:5>3.1.0 <PROGRAMID:%d>%s", len(app), app)
fmt.Fprintf(bw, "<ADIF_VER:%d>%s <PROGRAMID:%d>%s", len(adifVersion), adifVersion, len(app), app)
if ver != "" {
fmt.Fprintf(bw, " <PROGRAMVERSION:%d>%s", len(ver), ver)
}
@@ -248,13 +248,46 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
writeField(bw, "COMMENT", q.Comment)
writeField(bw, "NOTES", q.Notes)
// --- ADIF 3.1.7 additional promoted fields ---
writeField(bw, "SIG", q.SIG)
writeField(bw, "SIG_INFO", q.SIGInfo)
writeField(bw, "MY_SIG", q.MySIG)
writeField(bw, "MY_SIG_INFO", q.MySIGInfo)
writeField(bw, "WWFF_REF", q.WWFFRef)
writeField(bw, "MY_WWFF_REF", q.MyWWFFRef)
writeFloatPtr(bw, "DISTANCE", q.Distance, 1)
writeFloatPtr(bw, "RX_PWR", q.RXPower, 1)
writeFloatPtr(bw, "A_INDEX", q.AIndex, 0)
writeFloatPtr(bw, "K_INDEX", q.KIndex, 0)
writeFloatPtr(bw, "SFI", q.SFI, 0)
writeField(bw, "SKCC", q.SKCC)
writeField(bw, "FISTS", q.FISTS)
writeField(bw, "TEN_TEN", q.TenTen)
writeField(bw, "CONTACTED_OP", q.ContactedOp)
writeField(bw, "EQ_CALL", q.EqCall)
writeField(bw, "PFX", q.PFX)
writeField(bw, "MY_NAME", q.MyName)
writeField(bw, "CLASS", q.Class)
writeField(bw, "DARC_DOK", q.DarcDOK)
writeField(bw, "MY_DARC_DOK", q.MyDarcDOK)
writeField(bw, "REGION", q.Region)
writeField(bw, "SILENT_KEY", q.SilentKey)
writeField(bw, "SWL", q.SWL)
writeField(bw, "QSO_COMPLETE", q.QSOComplete)
writeField(bw, "QSO_RANDOM", q.QSORandom)
writeField(bw, "CREDIT_GRANTED", q.CreditGranted)
writeField(bw, "CREDIT_SUBMITTED", q.CreditSubmitted)
writeField(bw, "MY_ARRL_SECT", q.MyARRLSect)
writeField(bw, "MY_VUCC_GRIDS", q.MyVUCCGrids)
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
// In standard mode we drop application-specific tags (APP_*) so the file
// stays portable to other loggers; in full mode they're kept for a
// lossless OpsLog round-trip.
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
// application-specific tags AND any non-standard / vendor tag — keeping
// the file strictly portable to other loggers. Full mode keeps every
// extra for a lossless OpsLog round-trip.
for k, v := range q.Extras {
tag := strings.ToUpper(k)
if !includeApp && strings.HasPrefix(tag, "APP_") {
if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
continue
}
writeField(bw, tag, v)
+272
View File
@@ -0,0 +1,272 @@
package adif
import "strings"
// This file embeds the complete ADIF 3.1.7 QSO-field dictionary. It is the
// single source of truth for:
// - the generic "ADIF fields" editor in the UI (any field becomes editable),
// - the "standard ADIF" export mode (only spec fields are emitted),
// - import diagnostics (a tag absent here is non-standard / vendor-specific).
//
// Promoted fields (those with dedicated QSO columns) are flagged Promoted=true
// so the UI can hide them from the generic editor — they already have proper
// inputs in the main tabs.
// FieldKind is a coarse input type used by the generic editor to pick a widget.
type FieldKind string
const (
KindText FieldKind = "text" // String / IntlString / MultilineString
KindNumber FieldKind = "number" // Number / PositiveInteger
KindDate FieldKind = "date" // ADIF Date (YYYYMMDD)
KindTime FieldKind = "time" // ADIF Time (HHMMSS / HHMM)
KindBool FieldKind = "boolean" // Boolean (Y/N)
KindEnum FieldKind = "enum" // Enumeration
KindLoc FieldKind = "location" // Location (e.g. "N048 09.000")
)
// FieldDef describes one ADIF QSO field.
type FieldDef struct {
Name string `json:"name"` // canonical uppercase ADIF tag
Kind FieldKind `json:"kind"` // editor widget hint
Category string `json:"category"` // grouping for the UI
Promoted bool `json:"promoted"` // has a dedicated QSO column
Deprecated bool `json:"deprecated"` // import-only per the spec
Intl bool `json:"intl"` // *_INTL UTF-8 variant
}
// adifVersion is the ADIF spec version OpsLog targets for import/export.
const adifVersion = "3.1.7"
// ADIFVersion returns the ADIF spec version OpsLog conforms to.
func ADIFVersion() string { return adifVersion }
// Fields is the full ADIF 3.1.7 QSO-field set. Order is alphabetical within
// each category; categories order is roughly "most-used first" for the UI.
var Fields = []FieldDef{
// ── Core / contact ──────────────────────────────────────────────
{Name: "CALL", Kind: KindText, Category: "Core", Promoted: true},
{Name: "QSO_DATE", Kind: KindDate, Category: "Core", Promoted: true},
{Name: "TIME_ON", Kind: KindTime, Category: "Core", Promoted: true},
{Name: "QSO_DATE_OFF", Kind: KindDate, Category: "Core", Promoted: true},
{Name: "TIME_OFF", Kind: KindTime, Category: "Core", Promoted: true},
{Name: "BAND", Kind: KindEnum, Category: "Core", Promoted: true},
{Name: "BAND_RX", Kind: KindEnum, Category: "Core", Promoted: true},
{Name: "MODE", Kind: KindEnum, Category: "Core", Promoted: true},
{Name: "SUBMODE", Kind: KindEnum, Category: "Core", Promoted: true},
{Name: "FREQ", Kind: KindNumber, Category: "Core", Promoted: true},
{Name: "FREQ_RX", Kind: KindNumber, Category: "Core", Promoted: true},
{Name: "RST_SENT", Kind: KindText, Category: "Core", Promoted: true},
{Name: "RST_RCVD", Kind: KindText, Category: "Core", Promoted: true},
{Name: "QSO_COMPLETE", Kind: KindEnum, Category: "Core", Promoted: true},
{Name: "QSO_RANDOM", Kind: KindBool, Category: "Core", Promoted: true},
{Name: "SWL", Kind: KindBool, Category: "Core", Promoted: true},
// ── Contacted station ───────────────────────────────────────────
{Name: "NAME", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "NAME_INTL", Kind: KindText, Category: "Contacted", Intl: true},
{Name: "QTH", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "QTH_INTL", Kind: KindText, Category: "Contacted", Intl: true},
{Name: "ADDRESS", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "ADDRESS_INTL", Kind: KindText, Category: "Contacted", Intl: true},
{Name: "EMAIL", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "WEB", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "GRIDSQUARE", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "GRIDSQUARE_EXT", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "VUCC_GRIDS", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "COUNTRY", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "COUNTRY_INTL", Kind: KindText, Category: "Contacted", Intl: true},
{Name: "STATE", Kind: KindEnum, Category: "Contacted", Promoted: true},
{Name: "CNTY", Kind: KindEnum, Category: "Contacted", Promoted: true},
{Name: "CNTY_ALT", Kind: KindEnum, Category: "Contacted"},
{Name: "DXCC", Kind: KindEnum, Category: "Contacted", Promoted: true},
{Name: "CONT", Kind: KindEnum, Category: "Contacted", Promoted: true},
{Name: "CQZ", Kind: KindNumber, Category: "Contacted", Promoted: true},
{Name: "ITUZ", Kind: KindNumber, Category: "Contacted", Promoted: true},
{Name: "IOTA", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "IOTA_ISLAND_ID", Kind: KindText, Category: "Contacted"},
{Name: "REGION", Kind: KindEnum, Category: "Contacted", Promoted: true},
{Name: "PFX", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "AGE", Kind: KindNumber, Category: "Contacted", Promoted: true},
{Name: "LAT", Kind: KindLoc, Category: "Contacted", Promoted: true},
{Name: "LON", Kind: KindLoc, Category: "Contacted", Promoted: true},
{Name: "ALTITUDE", Kind: KindNumber, Category: "Contacted"},
{Name: "DISTANCE", Kind: KindNumber, Category: "Contacted", Promoted: true},
{Name: "RIG", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "RIG_INTL", Kind: KindText, Category: "Contacted", Intl: true},
{Name: "ANT", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "CONTACTED_OP", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "EQ_CALL", Kind: KindText, Category: "Contacted", Promoted: true},
{Name: "GUEST_OP", Kind: KindText, Category: "Contacted", Deprecated: true},
{Name: "OWNER_CALLSIGN", Kind: KindText, Category: "Contacted"},
{Name: "SILENT_KEY", Kind: KindBool, Category: "Contacted", Promoted: true},
{Name: "USACA_COUNTIES", Kind: KindText, Category: "Contacted"},
// ── Special activity (POTA/SOTA/WWFF/SIG) ───────────────────────
{Name: "SIG", Kind: KindText, Category: "Activity", Promoted: true},
{Name: "SIG_INFO", Kind: KindText, Category: "Activity", Promoted: true},
{Name: "SIG_INTL", Kind: KindText, Category: "Activity", Intl: true},
{Name: "SIG_INFO_INTL", Kind: KindText, Category: "Activity", Intl: true},
{Name: "POTA_REF", Kind: KindText, Category: "Activity", Promoted: true},
{Name: "SOTA_REF", Kind: KindText, Category: "Activity", Promoted: true},
{Name: "WWFF_REF", Kind: KindText, Category: "Activity", Promoted: true},
// ── Power / propagation / space wx ──────────────────────────────
{Name: "TX_PWR", Kind: KindNumber, Category: "Propagation", Promoted: true},
{Name: "RX_PWR", Kind: KindNumber, Category: "Propagation", Promoted: true},
{Name: "A_INDEX", Kind: KindNumber, Category: "Propagation", Promoted: true},
{Name: "K_INDEX", Kind: KindNumber, Category: "Propagation", Promoted: true},
{Name: "SFI", Kind: KindNumber, Category: "Propagation", Promoted: true},
{Name: "PROP_MODE", Kind: KindEnum, Category: "Propagation", Promoted: true},
{Name: "SAT_NAME", Kind: KindText, Category: "Propagation", Promoted: true},
{Name: "SAT_MODE", Kind: KindText, Category: "Propagation", Promoted: true},
{Name: "ANT_AZ", Kind: KindNumber, Category: "Propagation", Promoted: true},
{Name: "ANT_EL", Kind: KindNumber, Category: "Propagation", Promoted: true},
{Name: "ANT_PATH", Kind: KindEnum, Category: "Propagation", Promoted: true},
{Name: "FORCE_INIT", Kind: KindBool, Category: "Propagation"},
{Name: "MAX_BURSTS", Kind: KindNumber, Category: "Propagation"},
{Name: "MS_SHOWER", Kind: KindText, Category: "Propagation"},
{Name: "NR_BURSTS", Kind: KindNumber, Category: "Propagation"},
{Name: "NR_PINGS", Kind: KindNumber, Category: "Propagation"},
// ── QSL / confirmations ─────────────────────────────────────────
{Name: "QSL_SENT", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QSL_RCVD", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QSL_VIA", Kind: KindText, Category: "QSL", Promoted: true},
{Name: "QSL_SENT_VIA", Kind: KindEnum, Category: "QSL"},
{Name: "QSL_RCVD_VIA", Kind: KindEnum, Category: "QSL"},
{Name: "QSLMSG", Kind: KindText, Category: "QSL", Promoted: true},
{Name: "QSLMSG_INTL", Kind: KindText, Category: "QSL", Intl: true},
{Name: "QSLMSG_RCVD", Kind: KindText, Category: "QSL", Promoted: true},
{Name: "LOTW_QSL_SENT", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "LOTW_QSL_RCVD", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "LOTW_QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "LOTW_QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "EQSL_QSL_SENT", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "EQSL_QSL_RCVD", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "EQSL_QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "EQSL_QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "EQSL_AG", Kind: KindBool, Category: "QSL"},
{Name: "DCL_QSL_SENT", Kind: KindEnum, Category: "QSL"},
{Name: "DCL_QSL_RCVD", Kind: KindEnum, Category: "QSL"},
{Name: "DCL_QSLSDATE", Kind: KindDate, Category: "QSL"},
{Name: "DCL_QSLRDATE", Kind: KindDate, Category: "QSL"},
{Name: "CLUBLOG_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "CLUBLOG_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "HRDLOG_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "HRDLOG_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QRZCOM_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
{Name: "HAMQTH_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
// ── Awards / credits ────────────────────────────────────────────
{Name: "CREDIT_SUBMITTED", Kind: KindText, Category: "Awards", Promoted: true},
{Name: "CREDIT_GRANTED", Kind: KindText, Category: "Awards", Promoted: true},
{Name: "AWARD_SUBMITTED", Kind: KindText, Category: "Awards"},
{Name: "AWARD_GRANTED", Kind: KindText, Category: "Awards"},
// ── Contest ─────────────────────────────────────────────────────
{Name: "CONTEST_ID", Kind: KindText, Category: "Contest", Promoted: true},
{Name: "SRX", Kind: KindNumber, Category: "Contest", Promoted: true},
{Name: "STX", Kind: KindNumber, Category: "Contest", Promoted: true},
{Name: "SRX_STRING", Kind: KindText, Category: "Contest", Promoted: true},
{Name: "STX_STRING", Kind: KindText, Category: "Contest", Promoted: true},
{Name: "CHECK", Kind: KindText, Category: "Contest", Promoted: true},
{Name: "CLASS", Kind: KindText, Category: "Contest", Promoted: true},
{Name: "PRECEDENCE", Kind: KindText, Category: "Contest", Promoted: true},
{Name: "ARRL_SECT", Kind: KindEnum, Category: "Contest", Promoted: true},
// ── Club memberships ────────────────────────────────────────────
{Name: "SKCC", Kind: KindText, Category: "Clubs", Promoted: true},
{Name: "FISTS", Kind: KindNumber, Category: "Clubs", Promoted: true},
{Name: "FISTS_CC", Kind: KindNumber, Category: "Clubs"},
{Name: "TEN_TEN", Kind: KindNumber, Category: "Clubs", Promoted: true},
{Name: "UKSMG", Kind: KindNumber, Category: "Clubs"},
{Name: "DARC_DOK", Kind: KindText, Category: "Clubs", Promoted: true},
// ── Morse key (3.1.5+) ──────────────────────────────────────────
{Name: "MORSE_KEY_TYPE", Kind: KindEnum, Category: "Morse key"},
{Name: "MORSE_KEY_INFO", Kind: KindText, Category: "Morse key"},
// ── Misc / crypto ───────────────────────────────────────────────
{Name: "COMMENT", Kind: KindText, Category: "Misc", Promoted: true},
{Name: "COMMENT_INTL", Kind: KindText, Category: "Misc", Intl: true},
{Name: "NOTES", Kind: KindText, Category: "Misc", Promoted: true},
{Name: "NOTES_INTL", Kind: KindText, Category: "Misc", Intl: true},
{Name: "PUBLIC_KEY", Kind: KindText, Category: "Misc"},
{Name: "VE_PROV", Kind: KindText, Category: "Misc", Deprecated: true},
// ── My station / operator ───────────────────────────────────────
{Name: "STATION_CALLSIGN", Kind: KindText, Category: "My station", Promoted: true},
{Name: "OPERATOR", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_NAME", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_NAME_INTL", Kind: KindText, Category: "My station", Intl: true},
{Name: "MY_GRIDSQUARE", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_GRIDSQUARE_EXT", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_VUCC_GRIDS", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_COUNTRY", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_COUNTRY_INTL", Kind: KindText, Category: "My station", Intl: true},
{Name: "MY_STATE", Kind: KindEnum, Category: "My station", Promoted: true},
{Name: "MY_CNTY", Kind: KindEnum, Category: "My station", Promoted: true},
{Name: "MY_CNTY_ALT", Kind: KindEnum, Category: "My station"},
{Name: "MY_DXCC", Kind: KindEnum, Category: "My station", Promoted: true},
{Name: "MY_CQ_ZONE", Kind: KindNumber, Category: "My station", Promoted: true},
{Name: "MY_ITU_ZONE", Kind: KindNumber, Category: "My station", Promoted: true},
{Name: "MY_IOTA", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_IOTA_ISLAND_ID", Kind: KindText, Category: "My station"},
{Name: "MY_SOTA_REF", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_POTA_REF", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_WWFF_REF", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_SIG", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_SIG_INFO", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_SIG_INTL", Kind: KindText, Category: "My station", Intl: true},
{Name: "MY_SIG_INFO_INTL", Kind: KindText, Category: "My station", Intl: true},
{Name: "MY_LAT", Kind: KindLoc, Category: "My station", Promoted: true},
{Name: "MY_LON", Kind: KindLoc, Category: "My station", Promoted: true},
{Name: "MY_ALTITUDE", Kind: KindNumber, Category: "My station"},
{Name: "MY_STREET", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_STREET_INTL", Kind: KindText, Category: "My station", Intl: true},
{Name: "MY_CITY", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_CITY_INTL", Kind: KindText, Category: "My station", Intl: true},
{Name: "MY_POSTAL_CODE", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_POSTAL_CODE_INTL", Kind: KindText, Category: "My station", Intl: true},
{Name: "MY_RIG", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_RIG_INTL", Kind: KindText, Category: "My station", Intl: true},
{Name: "MY_ANTENNA", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_ANTENNA_INTL", Kind: KindText, Category: "My station", Intl: true},
{Name: "MY_ARRL_SECT", Kind: KindEnum, Category: "My station", Promoted: true},
{Name: "MY_USACA_COUNTIES", Kind: KindText, Category: "My station"},
{Name: "MY_DARC_DOK", Kind: KindText, Category: "My station", Promoted: true},
{Name: "MY_FISTS", Kind: KindNumber, Category: "My station"},
{Name: "MY_MORSE_KEY_TYPE", Kind: KindEnum, Category: "My station"},
{Name: "MY_MORSE_KEY_INFO", Kind: KindText, Category: "My station"},
}
// fieldIndex maps an uppercase ADIF tag to its definition for O(1) lookup.
var fieldIndex = func() map[string]FieldDef {
m := make(map[string]FieldDef, len(Fields))
for _, f := range Fields {
m[f.Name] = f
}
return m
}()
// IsStandardField reports whether tag (any case) is a defined ADIF 3.1.7
// field. APP_* and USERDEF tags are non-standard and return false.
func IsStandardField(tag string) bool {
_, ok := fieldIndex[strings.ToUpper(strings.TrimSpace(tag))]
return ok
}
// LookupField returns the definition for a tag (any case), ok=false if unknown.
func LookupField(tag string) (FieldDef, bool) {
f, ok := fieldIndex[strings.ToUpper(strings.TrimSpace(tag))]
return f, ok
}
+48
View File
@@ -284,6 +284,12 @@ var adifPromoted = stringSet(
"my_street", "my_city", "my_postal_code", "my_rig", "my_antenna",
// Misc
"tx_pwr", "comment", "notes",
// ADIF 3.1.7 additional promoted fields
"sig", "sig_info", "my_sig", "my_sig_info", "wwff_ref", "my_wwff_ref",
"distance", "rx_pwr", "a_index", "k_index", "sfi",
"skcc", "fists", "ten_ten", "contacted_op", "eq_call", "pfx", "my_name", "class",
"darc_dok", "my_darc_dok", "region", "silent_key", "swl", "qso_complete", "qso_random",
"credit_granted", "credit_submitted", "my_arrl_sect", "my_vucc_grids",
)
func stringSet(items ...string) map[string]struct{} {
@@ -482,6 +488,48 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.Comment = rec["comment"]
q.Notes = rec["notes"]
// ADIF 3.1.7 additional promoted fields
q.SIG = rec["sig"]
q.SIGInfo = rec["sig_info"]
q.MySIG = rec["my_sig"]
q.MySIGInfo = rec["my_sig_info"]
q.WWFFRef = strings.ToUpper(rec["wwff_ref"])
q.MyWWFFRef = strings.ToUpper(rec["my_wwff_ref"])
if v, ok := parseFloat(rec["distance"]); ok {
q.Distance = &v
}
if v, ok := parseFloat(rec["rx_pwr"]); ok {
q.RXPower = &v
}
if v, ok := parseFloat(rec["a_index"]); ok {
q.AIndex = &v
}
if v, ok := parseFloat(rec["k_index"]); ok {
q.KIndex = &v
}
if v, ok := parseFloat(rec["sfi"]); ok {
q.SFI = &v
}
q.SKCC = rec["skcc"]
q.FISTS = rec["fists"]
q.TenTen = rec["ten_ten"]
q.ContactedOp = strings.ToUpper(rec["contacted_op"])
q.EqCall = strings.ToUpper(rec["eq_call"])
q.PFX = strings.ToUpper(rec["pfx"])
q.MyName = rec["my_name"]
q.Class = rec["class"]
q.DarcDOK = rec["darc_dok"]
q.MyDarcDOK = rec["my_darc_dok"]
q.Region = rec["region"]
q.SilentKey = strings.ToUpper(rec["silent_key"])
q.SWL = strings.ToUpper(rec["swl"])
q.QSOComplete = rec["qso_complete"]
q.QSORandom = strings.ToUpper(rec["qso_random"])
q.CreditGranted = rec["credit_granted"]
q.CreditSubmitted = rec["credit_submitted"]
q.MyARRLSect = strings.ToUpper(rec["my_arrl_sect"])
q.MyVUCCGrids = strings.ToUpper(rec["my_vucc_grids"])
// Everything else lands in extras (uppercased ADIF names).
var extras map[string]string
for k, v := range rec {
+121
View File
@@ -0,0 +1,121 @@
package adif
import (
"bufio"
"bytes"
"strings"
"testing"
"time"
"hamlog/internal/qso"
)
// TestPromotedFieldsRoundTrip writes a QSO carrying the ADIF 3.1.7 promoted
// fields, parses it back, and checks they survive — guarding the export
// writeRecord ↔ import recordToQSO field-name mapping against typos.
func TestPromotedFieldsRoundTrip(t *testing.T) {
dist := 1234.5
rxp := 5.0
a := 12.0
in := qso.QSO{
Callsign: "EA8ABC", Band: "20m", Mode: "SSB",
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
SIG: "POTA", SIGInfo: "US-0001", MySIG: "WWFF", MySIGInfo: "ONFF-0001",
WWFFRef: "ONFF-0001", MyWWFFRef: "F-FFF-0001",
Distance: &dist, RXPower: &rxp, AIndex: &a,
SKCC: "12345S", FISTS: "999", TenTen: "55555",
ContactedOp: "EA8XYZ", EqCall: "EA8OLD", PFX: "EA8", MyName: "Greg",
Class: "1A", DarcDOK: "A01", MyDarcDOK: "B02", Region: "IV",
SilentKey: "N", SWL: "N", QSOComplete: "Y", QSORandom: "Y",
CreditGranted: "DXCC", CreditSubmitted: "WAS",
MyARRLSect: "EMA", MyVUCCGrids: "FN20,FN21",
}
var buf bytes.Buffer
bw := bufio.NewWriter(&buf)
bw.WriteString("<EOH>\n")
writeRecord(bw, in, true)
bw.Flush()
var rec Record
if err := Parse(strings.NewReader(buf.String()), func(r Record) error { rec = r; return nil }); err != nil {
t.Fatalf("parse: %v", err)
}
out, ok := recordToQSO(rec)
if !ok {
t.Fatal("recordToQSO returned !ok")
}
checks := map[string]struct{ got, want string }{
"SIG": {out.SIG, in.SIG},
"SIG_INFO": {out.SIGInfo, in.SIGInfo},
"MY_SIG": {out.MySIG, in.MySIG},
"MY_SIG_INFO": {out.MySIGInfo, in.MySIGInfo},
"WWFF_REF": {out.WWFFRef, in.WWFFRef},
"MY_WWFF_REF": {out.MyWWFFRef, in.MyWWFFRef},
"SKCC": {out.SKCC, in.SKCC},
"FISTS": {out.FISTS, in.FISTS},
"TEN_TEN": {out.TenTen, in.TenTen},
"CONTACTED_OP": {out.ContactedOp, in.ContactedOp},
"EQ_CALL": {out.EqCall, in.EqCall},
"PFX": {out.PFX, in.PFX},
"MY_NAME": {out.MyName, in.MyName},
"CLASS": {out.Class, in.Class},
"DARC_DOK": {out.DarcDOK, in.DarcDOK},
"MY_DARC_DOK": {out.MyDarcDOK, in.MyDarcDOK},
"REGION": {out.Region, in.Region},
"SILENT_KEY": {out.SilentKey, in.SilentKey},
"SWL": {out.SWL, in.SWL},
"QSO_COMPLETE": {out.QSOComplete, in.QSOComplete},
"QSO_RANDOM": {out.QSORandom, in.QSORandom},
"CREDIT_GRANTED": {out.CreditGranted, in.CreditGranted},
"CREDIT_SUBMITTED": {out.CreditSubmitted, in.CreditSubmitted},
"MY_ARRL_SECT": {out.MyARRLSect, in.MyARRLSect},
"MY_VUCC_GRIDS": {out.MyVUCCGrids, in.MyVUCCGrids},
}
for tag, c := range checks {
if c.got != c.want {
t.Errorf("%s round-trip = %q, want %q", tag, c.got, c.want)
}
}
if out.Distance == nil || *out.Distance != dist {
t.Errorf("DISTANCE round-trip = %v, want %v", out.Distance, dist)
}
if out.RXPower == nil || *out.RXPower != rxp {
t.Errorf("RX_PWR round-trip = %v, want %v", out.RXPower, rxp)
}
if out.AIndex == nil || *out.AIndex != a {
t.Errorf("A_INDEX round-trip = %v, want %v", out.AIndex, a)
}
}
// TestStandardExportDropsNonStandard verifies that standard mode strips
// vendor/APP tags while full mode keeps them.
func TestStandardExportDropsNonStandard(t *testing.T) {
q := qso.QSO{
Callsign: "F4BPO", Band: "20m", Mode: "CW",
Extras: map[string]string{
"APP_LOG4OM_FOO": "x",
"DARC_DOK": "A01", // standard → kept in both
"MY_VENDOR_TAG": "y", // non-standard → dropped in standard mode
},
}
standard := renderRecord(q, false)
if strings.Contains(standard, "APP_LOG4OM_FOO") || strings.Contains(standard, "MY_VENDOR_TAG") {
t.Errorf("standard export should drop non-standard tags:\n%s", standard)
}
full := renderRecord(q, true)
if !strings.Contains(full, "APP_LOG4OM_FOO") || !strings.Contains(full, "MY_VENDOR_TAG") {
t.Errorf("full export should keep all extras:\n%s", full)
}
}
func renderRecord(q qso.QSO, includeApp bool) string {
var buf bytes.Buffer
bw := bufio.NewWriter(&buf)
writeRecord(bw, q, includeApp)
bw.Flush()
return buf.String()
}
+32
View File
@@ -210,8 +210,13 @@ type refList struct {
byCode map[string]RefMeta // uppercased code → metadata
codes []string // codes in input order (for stable unworked listing)
withPattern []string // codes whose reference declares a regex (usually none)
names []nameCode // (uppercased name → code) for MatchBy="description"
}
// nameCode pairs a reference's uppercased description with its code, for
// description-based matching (e.g. WAJA finding a prefecture NAME in the QTH).
type nameCode struct{ name, code string }
// RefMeta is one reference's metadata for the engine: enough to enforce a
// predefined list, per-reference DXCC scoping, a per-reference pattern, and to
// label results.
@@ -243,6 +248,9 @@ func NewRefList(metas []RefMeta) refList {
m.Code = code
if _, dup := rl.byCode[code]; !dup {
rl.codes = append(rl.codes, code)
if nm := strings.ToUpper(strings.TrimSpace(m.Name)); nm != "" {
rl.names = append(rl.names, nameCode{name: nm, code: code})
}
}
rl.byCode[code] = m
}
@@ -376,6 +384,15 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
for _, b := range sortedBands(bandWorked) {
r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]})
}
// Never return nil slices: they marshal to JSON null, and the UI calls
// .filter/.length on them (an award with nothing worked yet — e.g. a
// freshly-created WWFF/WAJA — would otherwise white-screen the panel).
if r.Refs == nil {
r.Refs = []Ref{}
}
if r.Bands == nil {
r.Bands = []BandCount{}
}
out[i] = r
}
return out
@@ -428,12 +445,27 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
return nil
}
predefined := hasList && !d.Dynamic
byDesc := predefined && strings.EqualFold(strings.TrimSpace(d.MatchBy), "description")
var found []string
switch {
case re != nil:
// Award-level regex: capture group 1 (or whole match) for each hit.
found = regexTokens(re, raw)
case byDesc:
// Match references by their DESCRIPTION/name appearing in the field
// (e.g. WAJA finds the prefecture name inside the QTH). ExactMatch means
// the field equals the name; otherwise the name is a substring of it.
up := strings.ToUpper(raw)
for _, nc := range rl.names {
if d.ExactMatch {
if up == nc.name {
found = append(found, nc.code)
}
} else if strings.Contains(up, nc.name) {
found = append(found, nc.code)
}
}
case predefined && !d.ExactMatch:
// "Search reference inside the field": look up each token of the field in
// the list — O(tokens), not O(all references) — plus test the few
+32
View File
@@ -96,6 +96,38 @@ func TestComputeMultiRef(t *testing.T) {
}
}
// WAJA-style award: MatchBy="description", non-exact, scanning the QTH for a
// reference's NAME (the prefecture). Also guards against the nil-slice crash:
// an award with nothing worked must return empty (non-nil) Refs/Bands.
func TestComputeMatchByDescription(t *testing.T) {
def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true}
qsos := []qso.QSO{
{Callsign: "JA1ABC", Band: "20m", DXCC: ip(339), QTH: "Tokyo city", LOTWRcvd: "Y"},
{Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "Osaka"},
{Callsign: "JA9XYZ", Band: "20m", DXCC: ip(339), QTH: "nowhere special"}, // no prefecture name
}
refMetas := map[string][]RefMeta{"WAJA": {
{Code: "100", Name: "Tokyo", Valid: true},
{Code: "270", Name: "Osaka", Valid: true},
{Code: "010", Name: "Hokkaido", Valid: true},
}}
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
if r.Worked != 2 { // Tokyo + Osaka found by name inside QTH
t.Errorf("WAJA worked = %d, want 2 (%v)", r.Worked, refCodes(r))
}
if r.Total != 3 { // predefined denominator = list size
t.Errorf("WAJA total = %d, want 3", r.Total)
}
// Nil-slice guard: an award with zero worked refs must still return
// non-nil (empty) Refs/Bands so the JSON isn't null (UI white-screen).
empty := Compute([]Def{{Code: "WWFF", Type: TypeReference, Field: "wwff", Dynamic: true, Valid: true}}, nil, nil, nil)[0]
if empty.Refs == nil || empty.Bands == nil {
t.Errorf("empty award must have non-nil Refs/Bands, got Refs=%v Bands=%v", empty.Refs, empty.Bands)
}
}
func refCodes(r Result) []string {
out := make([]string, 0, len(r.Refs))
for _, rf := range r.Refs {
@@ -0,0 +1,55 @@
-- Promote ~30 more ADIF 3.1.7 fields to dedicated columns so they are
-- editable, queryable and exported as proper tags (rather than living only
-- in extras_json). The long tail of rarely-used fields still rides in
-- extras_json and is reachable via the generic "ADIF fields" editor.
-- SQLite ADD COLUMN is metadata-only — fast even on large logbooks.
-- --- Special-activity group (POTA/SOTA/WWFF/SIG) ---
ALTER TABLE qso ADD COLUMN sig TEXT; -- e.g. "POTA", "WWFF"
ALTER TABLE qso ADD COLUMN sig_info TEXT; -- the reference for SIG
ALTER TABLE qso ADD COLUMN my_sig TEXT;
ALTER TABLE qso ADD COLUMN my_sig_info TEXT;
ALTER TABLE qso ADD COLUMN wwff_ref TEXT; -- contacted WWFF reference
ALTER TABLE qso ADD COLUMN my_wwff_ref TEXT; -- my WWFF activation
-- --- Distance / power / space weather ---
ALTER TABLE qso ADD COLUMN distance REAL; -- km
ALTER TABLE qso ADD COLUMN rx_pwr REAL; -- contacted station power (W)
ALTER TABLE qso ADD COLUMN a_index REAL;
ALTER TABLE qso ADD COLUMN k_index REAL;
ALTER TABLE qso ADD COLUMN sfi REAL; -- solar flux index
-- --- Club memberships ---
ALTER TABLE qso ADD COLUMN skcc TEXT; -- can carry suffix letters
ALTER TABLE qso ADD COLUMN fists TEXT;
ALTER TABLE qso ADD COLUMN ten_ten TEXT;
-- --- Contacted / station identity ---
ALTER TABLE qso ADD COLUMN contacted_op TEXT; -- the actual operator worked
ALTER TABLE qso ADD COLUMN eq_call TEXT; -- former / alternate callsign
ALTER TABLE qso ADD COLUMN pfx TEXT; -- WPX prefix
ALTER TABLE qso ADD COLUMN my_name TEXT;
ALTER TABLE qso ADD COLUMN class TEXT; -- Field Day class
-- --- German DOK / region ---
ALTER TABLE qso ADD COLUMN darc_dok TEXT;
ALTER TABLE qso ADD COLUMN my_darc_dok TEXT;
ALTER TABLE qso ADD COLUMN region TEXT;
-- --- Flags ---
ALTER TABLE qso ADD COLUMN silent_key TEXT; -- Y/N
ALTER TABLE qso ADD COLUMN swl TEXT; -- Y/N (SWL report)
ALTER TABLE qso ADD COLUMN qso_complete TEXT; -- Y/N/NIL/?
ALTER TABLE qso ADD COLUMN qso_random TEXT; -- Y/N
-- --- Award credits ---
ALTER TABLE qso ADD COLUMN credit_granted TEXT;
ALTER TABLE qso ADD COLUMN credit_submitted TEXT;
-- --- My station extras ---
ALTER TABLE qso ADD COLUMN my_arrl_sect TEXT;
ALTER TABLE qso ADD COLUMN my_vucc_grids TEXT;
CREATE INDEX IF NOT EXISTS idx_qso_sig ON qso(sig);
CREATE INDEX IF NOT EXISTS idx_qso_wwff_ref ON qso(wwff_ref);
CREATE INDEX IF NOT EXISTS idx_qso_skcc ON qso(skcc);
+110 -4
View File
@@ -156,8 +156,42 @@ type QSO struct {
Comment string `json:"comment,omitempty"`
Notes string `json:"notes,omitempty"`
// --- ADIF 3.1.7 additional promoted fields ---
// Kept in one block so columnList / args() / scanQSO stay trivially in
// sync (they are appended at the end, before extras_json).
SIG string `json:"sig,omitempty"`
SIGInfo string `json:"sig_info,omitempty"`
MySIG string `json:"my_sig,omitempty"`
MySIGInfo string `json:"my_sig_info,omitempty"`
WWFFRef string `json:"wwff_ref,omitempty"`
MyWWFFRef string `json:"my_wwff_ref,omitempty"`
Distance *float64 `json:"distance,omitempty"`
RXPower *float64 `json:"rx_pwr,omitempty"`
AIndex *float64 `json:"a_index,omitempty"`
KIndex *float64 `json:"k_index,omitempty"`
SFI *float64 `json:"sfi,omitempty"`
SKCC string `json:"skcc,omitempty"`
FISTS string `json:"fists,omitempty"`
TenTen string `json:"ten_ten,omitempty"`
ContactedOp string `json:"contacted_op,omitempty"`
EqCall string `json:"eq_call,omitempty"`
PFX string `json:"pfx,omitempty"`
MyName string `json:"my_name,omitempty"`
Class string `json:"class,omitempty"`
DarcDOK string `json:"darc_dok,omitempty"`
MyDarcDOK string `json:"my_darc_dok,omitempty"`
Region string `json:"region,omitempty"`
SilentKey string `json:"silent_key,omitempty"`
SWL string `json:"swl,omitempty"`
QSOComplete string `json:"qso_complete,omitempty"`
QSORandom string `json:"qso_random,omitempty"`
CreditGranted string `json:"credit_granted,omitempty"`
CreditSubmitted string `json:"credit_submitted,omitempty"`
MyARRLSect string `json:"my_arrl_sect,omitempty"`
MyVUCCGrids string `json:"my_vucc_grids,omitempty"`
// Extras holds ADIF fields not promoted to columns. Keys are uppercase
// ADIF field names (e.g. "DARC_DOK"); values are the raw string content.
// ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
Extras map[string]string `json:"extras,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -205,7 +239,13 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota,
my_sota_ref, my_pota_ref, my_dxcc, my_cq_zone, my_itu_zone, my_lat, my_lon,
my_street, my_city, my_postal_code, my_rig, my_antenna,
tx_pwr, comment, notes, extras_json`
tx_pwr, comment, notes,
sig, sig_info, my_sig, my_sig_info, wwff_ref, my_wwff_ref,
distance, rx_pwr, a_index, k_index, sfi,
skcc, fists, ten_ten, contacted_op, eq_call, pfx, my_name, class,
darc_dok, my_darc_dok, region, silent_key, swl, qso_complete, qso_random,
credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
extras_json`
const selectCols = `id, ` + columnList + `, created_at, updated_at`
@@ -258,7 +298,13 @@ func (q *QSO) args() []any {
q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA,
q.MySOTARef, q.MyPOTARef, q.MyDXCC, q.MyCQZone, q.MyITUZone, q.MyLat, q.MyLon,
q.MyStreet, q.MyCity, q.MyPostalCode, q.MyRig, q.MyAntenna,
q.TXPower, q.Comment, q.Notes, extras,
q.TXPower, q.Comment, q.Notes,
q.SIG, q.SIGInfo, q.MySIG, q.MySIGInfo, q.WWFFRef, q.MyWWFFRef,
q.Distance, q.RXPower, q.AIndex, q.KIndex, q.SFI,
q.SKCC, q.FISTS, q.TenTen, q.ContactedOp, q.EqCall, q.PFX, q.MyName, q.Class,
q.DarcDOK, q.MyDarcDOK, q.Region, q.SilentKey, q.SWL, q.QSOComplete, q.QSORandom,
q.CreditGranted, q.CreditSubmitted, q.MyARRLSect, q.MyVUCCGrids,
extras,
}
}
@@ -1472,6 +1518,15 @@ func scanQSO(s scanner) (QSO, error) {
myRig, myAntenna sql.NullString
txp sql.NullFloat64
comment, notes sql.NullString
sig, sigInfo, mySig, mySigInfo sql.NullString
wwffRef, myWWFFRef sql.NullString
distance, rxPwr, aIndex, kIndex, sfi sql.NullFloat64
skcc, fists, tenTen sql.NullString
contactedOp, eqCall, pfx, myName sql.NullString
class, darcDOK, myDarcDOK, region sql.NullString
silentKey, swl, qsoComplete, qsoRandom sql.NullString
creditGranted, creditSubmitted sql.NullString
myARRLSect, myVUCCGrids sql.NullString
extrasJSON sql.NullString
createdStr, updatedStr string
)
@@ -1494,7 +1549,13 @@ func scanQSO(s scanner) (QSO, error) {
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
&mySOTA, &myPOTA, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon,
&myStreet, &myCity, &myPostal, &myRig, &myAntenna,
&txp, &comment, &notes, &extrasJSON, &createdStr, &updatedStr,
&txp, &comment, &notes,
&sig, &sigInfo, &mySig, &mySigInfo, &wwffRef, &myWWFFRef,
&distance, &rxPwr, &aIndex, &kIndex, &sfi,
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
&darcDOK, &myDarcDOK, &region, &silentKey, &swl, &qsoComplete, &qsoRandom,
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
&extrasJSON, &createdStr, &updatedStr,
); err != nil {
return QSO{}, fmt.Errorf("scan qso: %w", err)
}
@@ -1645,6 +1706,51 @@ func scanQSO(s scanner) (QSO, error) {
}
q.Comment = comment.String
q.Notes = notes.String
q.SIG = sig.String
q.SIGInfo = sigInfo.String
q.MySIG = mySig.String
q.MySIGInfo = mySigInfo.String
q.WWFFRef = wwffRef.String
q.MyWWFFRef = myWWFFRef.String
if distance.Valid {
v := distance.Float64
q.Distance = &v
}
if rxPwr.Valid {
v := rxPwr.Float64
q.RXPower = &v
}
if aIndex.Valid {
v := aIndex.Float64
q.AIndex = &v
}
if kIndex.Valid {
v := kIndex.Float64
q.KIndex = &v
}
if sfi.Valid {
v := sfi.Float64
q.SFI = &v
}
q.SKCC = skcc.String
q.FISTS = fists.String
q.TenTen = tenTen.String
q.ContactedOp = contactedOp.String
q.EqCall = eqCall.String
q.PFX = pfx.String
q.MyName = myName.String
q.Class = class.String
q.DarcDOK = darcDOK.String
q.MyDarcDOK = myDarcDOK.String
q.Region = region.String
q.SilentKey = silentKey.String
q.SWL = swl.String
q.QSOComplete = qsoComplete.String
q.QSORandom = qsoRandom.String
q.CreditGranted = creditGranted.String
q.CreditSubmitted = creditSubmitted.String
q.MyARRLSect = myARRLSect.String
q.MyVUCCGrids = myVUCCGrids.String
q.Extras = decodeExtras(extrasJSON.String)
return q, nil
}