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
+153 -1
View File
@@ -2141,9 +2141,14 @@ func bandForHz(hz int64) string {
// the operator assigns by hand. Such awards are read-only in the per-QSO editor.
func isComputedAwardField(field string) bool {
switch field {
case "dxcc", "cqz", "ituz", "prefix", "callsign", "state", "cont", "country", "grid":
// Purely derived from the callsign / cty.dat — never assigned by hand.
case "dxcc", "cqz", "ituz", "prefix", "callsign", "cont", "country", "grid":
return true
}
// NB: "state" and "cnty" are deliberately NOT computed. They are QSO fields
// the operator often sets by hand (a QRZ lookup rarely fills the JA
// prefecture or VE province), and they drive predefined-list awards
// (WAS / RAC / WAJA / JCC). So they must be pickable in the per-QSO editor.
return false
}
@@ -2326,6 +2331,145 @@ func (a *App) HasBuiltinReferences(code string) bool {
return ok
}
// ── Award export / import ──────────────────────────────────────────────
//
// A self-contained JSON bundle of every award definition AND its reference
// list. This is the backup users need so a reinstall / PC change never loses
// the awards they built by hand. It is independent of the database file.
// AwardBundle is the on-disk format for an award export.
type AwardBundle struct {
Version int `json:"version"`
ExportedAt string `json:"exported_at"`
Awards []AwardBundleEntry `json:"awards"`
}
// AwardBundleEntry pairs one award definition with its full reference list.
type AwardBundleEntry struct {
Def award.Def `json:"def"`
References []awardref.Ref `json:"references"`
}
// AwardImportResult summarises an award import for the UI.
type AwardImportResult struct {
Awards int `json:"awards"` // definitions added or updated
References int `json:"references"` // references imported across all awards
}
// ExportAwards shows a Save dialog and writes every award definition plus its
// reference list to a JSON bundle. Returns the path written, or "" if the user
// cancelled.
func (a *App) ExportAwards() (string, error) {
if a.awardRefs == nil {
return "", fmt.Errorf("db not initialized")
}
defs := a.awardDefs()
bundle := AwardBundle{
Version: 1,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Awards: make([]AwardBundleEntry, 0, len(defs)),
}
for _, d := range defs {
refs, err := a.awardRefs.List(a.ctx, d.Code)
if err != nil {
return "", fmt.Errorf("list references for %s: %w", d.Code, err)
}
bundle.Awards = append(bundle.Awards, AwardBundleEntry{Def: d, References: refs})
}
data, err := json.MarshalIndent(bundle, "", " ")
if err != nil {
return "", err
}
path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "Export awards",
DefaultFilename: "OpsLog_awards_" + time.Now().UTC().Format("20060102_150405") + ".json",
Filters: []wruntime.FileFilter{
{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
if err != nil || path == "" {
return "", err
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return "", fmt.Errorf("write %s: %w", path, err)
}
return path, nil
}
// ImportAwards shows an Open dialog, reads an award bundle and merges it:
// definitions are upserted by code, and any entry that carries references
// replaces that award's list. Returns counts; the user cancelling yields a
// zero result and no error.
func (a *App) ImportAwards() (AwardImportResult, error) {
var res AwardImportResult
if a.awardRefs == nil || a.settings == nil {
return res, fmt.Errorf("db not initialized")
}
path, err := wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Import awards",
Filters: []wruntime.FileFilter{
{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
if err != nil || path == "" {
return res, err
}
data, err := os.ReadFile(path)
if err != nil {
return res, fmt.Errorf("read %s: %w", path, err)
}
var bundle AwardBundle
if err := json.Unmarshal(data, &bundle); err != nil {
return res, fmt.Errorf("parse award bundle: %w", err)
}
if len(bundle.Awards) == 0 {
return res, fmt.Errorf("no awards in file")
}
// Merge definitions: upsert by code (imported wins), keep the rest.
defs := a.awardDefs()
byCode := map[string]int{}
for i, d := range defs {
byCode[strings.ToUpper(d.Code)] = i
}
for _, e := range bundle.Awards {
code := strings.ToUpper(strings.TrimSpace(e.Def.Code))
if code == "" {
continue
}
if i, ok := byCode[code]; ok {
defs[i] = e.Def
} else {
byCode[code] = len(defs)
defs = append(defs, e.Def)
}
res.Awards++
}
if migrated, changed := award.Migrate(defs); changed {
defs = migrated
}
b, _ := json.Marshal(defs)
if err := a.settings.Set(a.ctx, keyAwardDefs, string(b)); err != nil {
return res, fmt.Errorf("save award defs: %w", err)
}
// Replace reference lists for entries that carry them (skip empty so we
// don't wipe built-in-seeded lists for a def exported without refs).
for _, e := range bundle.Awards {
if len(e.References) == 0 {
continue
}
n, err := a.ReplaceAwardReferences(e.Def.Code, e.References)
if err != nil {
return res, fmt.Errorf("import references for %s: %w", e.Def.Code, err)
}
res.References += n
}
return res, nil
}
// builtinRefsVersion is bumped whenever the built-in reference data changes
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
// derived lists. Bump this after correcting BuiltinRefs / the DXCC name table.
@@ -2683,6 +2827,14 @@ func (a *App) SaveADIFFile() (string, error) {
})
}
// ADIFFields returns the complete ADIF 3.1.7 QSO-field dictionary, for the
// generic "ADIF fields" editor (so any standard field can be viewed/edited)
// and for the export-mode help text.
func (a *App) ADIFFields() []adif.FieldDef { return adif.Fields }
// ADIFVersion returns the ADIF spec version OpsLog conforms to (e.g. "3.1.7").
func (a *App) ADIFVersion() string { return adif.ADIFVersion() }
// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
// Streams from DB so memory stays flat even with 100k+ records.
// includeAppFields=false → portable standard ADIF (for other loggers);