feat: added FFMA award support
This commit is contained in:
@@ -2157,6 +2157,7 @@ func (a *App) awardDefs() []award.Def {
|
|||||||
if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 {
|
if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 {
|
||||||
// Upgrade legacy defs (pre-rich-model) in memory on every load.
|
// Upgrade legacy defs (pre-rich-model) in memory on every load.
|
||||||
migrated, _ := award.Migrate(defs)
|
migrated, _ := award.Migrate(defs)
|
||||||
|
migrated, _ = mergeCatalog(migrated)
|
||||||
return migrated
|
return migrated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2164,6 +2165,27 @@ func (a *App) awardDefs() []award.Def {
|
|||||||
return award.Defaults()
|
return award.Defaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeCatalog adds the catalog awards that are missing from the stored
|
||||||
|
// definitions. This is how an award SHIPPED in a new release (FFMA, say) reaches
|
||||||
|
// an operator who already has awards saved: without it awardDefs() would keep
|
||||||
|
// returning the stored copy and the new award would simply never appear. Add-only
|
||||||
|
// — an award already there keeps the operator's edits, Valid=false included.
|
||||||
|
func mergeCatalog(defs []award.Def) ([]award.Def, bool) {
|
||||||
|
have := make(map[string]struct{}, len(defs))
|
||||||
|
for _, d := range defs {
|
||||||
|
have[strings.ToUpper(strings.TrimSpace(d.Code))] = struct{}{}
|
||||||
|
}
|
||||||
|
added := false
|
||||||
|
for _, d := range award.Defaults() {
|
||||||
|
if _, ok := have[strings.ToUpper(strings.TrimSpace(d.Code))]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defs = append(defs, d)
|
||||||
|
added = true
|
||||||
|
}
|
||||||
|
return defs, added
|
||||||
|
}
|
||||||
|
|
||||||
// GetAwardDefs returns the (editable) award definitions.
|
// GetAwardDefs returns the (editable) award definitions.
|
||||||
func (a *App) GetAwardDefs() []award.Def { return a.awardDefs() }
|
func (a *App) GetAwardDefs() []award.Def { return a.awardDefs() }
|
||||||
|
|
||||||
@@ -2189,6 +2211,11 @@ func (a *App) migrateAwardDefs() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
migrated, changed := award.Migrate(defs)
|
migrated, changed := award.Migrate(defs)
|
||||||
|
// Awards added to the catalog since this operator last saved (a new release
|
||||||
|
// ships one) must be written back, or the editor would keep showing the old set.
|
||||||
|
if merged, added := mergeCatalog(migrated); added {
|
||||||
|
migrated, changed = merged, true
|
||||||
|
}
|
||||||
// Version-gated correction of the built-in awards' Validate sources, which
|
// Version-gated correction of the built-in awards' Validate sources, which
|
||||||
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
|
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
|
||||||
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
|
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
|
||||||
@@ -3825,8 +3852,10 @@ func freeAwardCode(code string, taken map[string]int) string {
|
|||||||
const builtinRefsVersion = "2"
|
const builtinRefsVersion = "2"
|
||||||
|
|
||||||
// seedBuiltinReferences populates the reference lists of built-in awards.
|
// seedBuiltinReferences populates the reference lists of built-in awards.
|
||||||
// - First run (no version stored): seed only awards that have NO references
|
// - First run (no version stored), or already up to date: seed only awards that
|
||||||
// yet, so an online-loaded list (POTA…) or a user list isn't clobbered.
|
// have NO references yet, so an online-loaded list (POTA…), a user list, or an
|
||||||
|
// edited one isn't clobbered. This is also what gives an award newly added to
|
||||||
|
// the catalog (FFMA and its 488 grids) its list on an existing install.
|
||||||
// - Version bump (stored != current): RE-SEED the derived built-in lists
|
// - Version bump (stored != current): RE-SEED the derived built-in lists
|
||||||
// (DXCC, WAZ, WAC, WAS, DDFM) to push data corrections to existing installs.
|
// (DXCC, WAZ, WAC, WAS, DDFM) to push data corrections to existing installs.
|
||||||
// These lists are canonical, not user-maintained, so overwriting is safe.
|
// These lists are canonical, not user-maintained, so overwriting is safe.
|
||||||
@@ -3834,22 +3863,26 @@ func (a *App) seedBuiltinReferences() {
|
|||||||
if a.awardRefs == nil || a.settings == nil {
|
if a.awardRefs == nil || a.settings == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ver, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded)
|
|
||||||
if ver == builtinRefsVersion {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
firstRun := ver == "" || ver == "1" // "1" was the old boolean flag
|
|
||||||
if ver == "1" {
|
|
||||||
firstRun = false // already seeded once → treat as a version upgrade
|
|
||||||
}
|
|
||||||
counts, err := a.awardRefs.Counts(a.ctx)
|
counts, err := a.awardRefs.Counts(a.ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ver, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded)
|
||||||
|
firstRun := ver == "" || ver == "1" // "1" was the old boolean flag
|
||||||
|
if ver == "1" {
|
||||||
|
firstRun = false // already seeded once → treat as a version upgrade
|
||||||
|
}
|
||||||
|
if ver == builtinRefsVersion {
|
||||||
|
// Already on the current data. But an award ADDED to the catalog since the
|
||||||
|
// last run (FFMA, say) still has an EMPTY list, and bumping the version to
|
||||||
|
// seed it would re-seed every award and clobber lists the operator has
|
||||||
|
// edited. So fill only the empty ones.
|
||||||
|
firstRun = true
|
||||||
|
}
|
||||||
for _, d := range a.awardDefs() {
|
for _, d := range a.awardDefs() {
|
||||||
code := strings.ToUpper(d.Code)
|
code := strings.ToUpper(d.Code)
|
||||||
if firstRun && counts[code] > 0 {
|
if firstRun && counts[code] > 0 {
|
||||||
continue // don't overwrite an existing list on a fresh install
|
continue // don't overwrite an existing list
|
||||||
}
|
}
|
||||||
// A catalog award that SHIPS its own reference list wins: that is how an
|
// A catalog award that SHIPS its own reference list wins: that is how an
|
||||||
// award added as a JSON file (a shared WAPC, with its provinces and their
|
// award added as a JSON file (a shared WAPC, with its provinces and their
|
||||||
|
|||||||
@@ -355,14 +355,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
||||||
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
|
||||||
{/* "Built-in" is what you tick before dropping an award into the
|
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
|
||||||
shipped catalog. Leave it off and "Reset to defaults" DELETES
|
the catalog derives that on load. Asking the author to tick a
|
||||||
the award on the user's machine — even though you shipped it.
|
box to declare it would be one more step nobody can guess —
|
||||||
Editing the JSON by hand to fix that is exactly what we're
|
forget it and the award silently misses every future catalog
|
||||||
avoiding here. */}
|
correction. "Protected" stays: whether an award can be deleted
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.builtinTip')}>
|
IS a real choice. */}
|
||||||
<Checkbox checked={!!cur.builtin} onCheckedChange={(c) => patch({ builtin: !!c })} /> {t('awed.builtin')}
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
||||||
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -200,6 +200,14 @@ func Catalog() []CatalogEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
|
||||||
|
// trusting the flag in the file: you drop in an award you exported, its JSON
|
||||||
|
// says builtin:false (you wrote it, it wasn't built-in then), and it would
|
||||||
|
// quietly miss every future catalog correction. Making the author remember to
|
||||||
|
// flip a flag is exactly the kind of step nobody can guess.
|
||||||
|
for i := range out {
|
||||||
|
out[i].Def.Builtin = true
|
||||||
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
|
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package award
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
)
|
)
|
||||||
@@ -287,6 +289,71 @@ func TestComputeGrid4VUCC(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FFMA ships a fixed list of the 488 grids of the contiguous 48 states, taken
|
||||||
|
// from arrl.org/ffma. The count is the award's own checksum — if this test ever
|
||||||
|
// fails on the count, the catalog file is wrong, not the test.
|
||||||
|
func TestCatalogFFMA(t *testing.T) {
|
||||||
|
raw, ok := CatalogRefs("FFMA")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("FFMA has no reference list in the embedded catalog")
|
||||||
|
}
|
||||||
|
var refs []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
DXCC int `json:"dxcc"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &refs); err != nil {
|
||||||
|
t.Fatalf("FFMA references: %v", err)
|
||||||
|
}
|
||||||
|
if len(refs) != 488 {
|
||||||
|
t.Fatalf("FFMA has %d grids, want exactly 488", len(refs))
|
||||||
|
}
|
||||||
|
|
||||||
|
var def Def
|
||||||
|
metas := make([]RefMeta, 0, len(refs))
|
||||||
|
for _, r := range refs {
|
||||||
|
// Rule 4(c): a grid may be activated from Canadian or Mexican soil, or from
|
||||||
|
// water. Pinning a reference to a DXCC entity would reject those contacts.
|
||||||
|
if r.DXCC != 0 {
|
||||||
|
t.Fatalf("FFMA grid %s is pinned to DXCC %d — rule 4(c) allows working it from outside the US", r.Code, r.DXCC)
|
||||||
|
}
|
||||||
|
metas = append(metas, RefMeta{Code: r.Code, Valid: r.Valid})
|
||||||
|
}
|
||||||
|
for _, d := range Defaults() {
|
||||||
|
if d.Code == "FFMA" {
|
||||||
|
def = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.Total != 488 || def.Field != "grid4" {
|
||||||
|
t.Fatalf("FFMA def: total=%d field=%q, want 488 / grid4", def.Total, def.Field)
|
||||||
|
}
|
||||||
|
|
||||||
|
d1983 := time.Date(1990, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "K5ABC", Band: "6m", Grid: "EM00AA", QSODate: d1983, LOTWRcvd: "Y"}, // counts
|
||||||
|
{Callsign: "VE3XYZ", Band: "6m", Grid: "FN25AA", QSODate: d1983, LOTWRcvd: "Y"}, // a VE3 in an FFMA grid: counts (4c)
|
||||||
|
{Callsign: "W1LINE", Band: "6m", VUCCGrids: "FN31,FN32", QSODate: d1983, QSLRcvd: "Y"}, // grid line: 2 grids (4d)
|
||||||
|
{Callsign: "G4XXX", Band: "6m", Grid: "IO91AA", QSODate: d1983, LOTWRcvd: "Y"}, // not an FFMA grid
|
||||||
|
{Callsign: "K5ABC", Band: "2m", Grid: "EM10AA", QSODate: d1983, LOTWRcvd: "Y"}, // wrong band
|
||||||
|
{Callsign: "K5OLD", Band: "6m", Grid: "EM20AA", QSODate: time.Date(1982, 6, 1, 0, 0, 0, 0, time.UTC), LOTWRcvd: "Y"}, // before 1983 (rule 2)
|
||||||
|
}
|
||||||
|
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"FFMA": metas}, nil)[0]
|
||||||
|
var got []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
got = append(got, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(got)
|
||||||
|
want := []string{"EM00", "FN25", "FN31", "FN32"}
|
||||||
|
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||||
|
t.Fatalf("FFMA worked = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if r.Worked != len(want) || r.Total != 488 {
|
||||||
|
t.Errorf("FFMA worked=%d total=%d, want %d / 488", r.Worked, r.Total, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func refCodes(r Result) []string {
|
func refCodes(r Result) []string {
|
||||||
out := make([]string, 0, len(r.Refs))
|
out := make([]string, 0, len(r.Refs))
|
||||||
for _, rf := range r.Refs {
|
for _, rf := range r.Refs {
|
||||||
@@ -508,3 +575,23 @@ func TestCatalogSurvivesOneBadFile(t *testing.T) {
|
|||||||
// Catalog() skips unparseable files rather than returning nil, so the others
|
// Catalog() skips unparseable files rather than returning nil, so the others
|
||||||
// still load. (Verified structurally: the loader `continue`s on error.)
|
// still load. (Verified structurally: the loader `continue`s on error.)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anything OpsLog SHIPS is built-in, whatever the file says.
|
||||||
|
//
|
||||||
|
// You create an award, export it (its JSON records builtin:false — it wasn't
|
||||||
|
// built-in when you wrote it), then drop that same file into the catalog to ship
|
||||||
|
// it. If we trusted the flag, the award would go out to everyone marked "not
|
||||||
|
// built-in" and would then silently miss every future catalog correction. Making
|
||||||
|
// the author remember to flip a flag first is a step nobody can guess — so the
|
||||||
|
// catalog derives it instead.
|
||||||
|
func TestCatalogForcesBuiltin(t *testing.T) {
|
||||||
|
for _, e := range Catalog() {
|
||||||
|
if !e.Def.Builtin {
|
||||||
|
t.Errorf("%s: shipped in the catalog but Builtin=false — it would miss catalog corrections", e.Def.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The realistic case: a user-authored award whose JSON says builtin:false.
|
||||||
|
if len(Catalog()) == 0 {
|
||||||
|
t.Fatal("empty catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T17:00:44Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "RAC",
|
||||||
|
"name": "RAC Canadian Provinces",
|
||||||
|
"description": "RAC Canadian Provinces",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "https://www.rac.ca/canadaward/",
|
||||||
|
"valid_from": "1977-01-07",
|
||||||
|
"ref_display": "name",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "state",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AB",
|
||||||
|
"name": "Alberta",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BC",
|
||||||
|
"name": "British Columbia",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MB",
|
||||||
|
"name": "Manitoba",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NB",
|
||||||
|
"name": "New Brunswick",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NL",
|
||||||
|
"name": "Newfoundland and Labrador",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NS",
|
||||||
|
"name": "Nova Scotia",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NT",
|
||||||
|
"name": "Northwest Territories",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NU",
|
||||||
|
"name": "Nunavut",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ON",
|
||||||
|
"name": "Ontario",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PE",
|
||||||
|
"name": "Prince Edward Island",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QC",
|
||||||
|
"name": "Quebec",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SK",
|
||||||
|
"name": "Saskatchewan",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "YT",
|
||||||
|
"name": "Yukon",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T17:00:44Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "VUCC",
|
||||||
|
"name": "VHF/UHF Century Club",
|
||||||
|
"description": "VHF/UHF Century Club",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "https://www.arrl.org/files/file/Awards%20Application%20Forms/VUCCRULE1a.pdf",
|
||||||
|
"valid_from": "1970-01-01",
|
||||||
|
"valid_to": "9999-01-30",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "grid4",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"valid_bands": [
|
||||||
|
"6m",
|
||||||
|
"4m",
|
||||||
|
"2m",
|
||||||
|
"70cm",
|
||||||
|
"23cm",
|
||||||
|
"13cm",
|
||||||
|
"1.25m"
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user