up
This commit is contained in:
@@ -2141,9 +2141,14 @@ func bandForHz(hz int64) string {
|
|||||||
// the operator assigns by hand. Such awards are read-only in the per-QSO editor.
|
// the operator assigns by hand. Such awards are read-only in the per-QSO editor.
|
||||||
func isComputedAwardField(field string) bool {
|
func isComputedAwardField(field string) bool {
|
||||||
switch field {
|
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
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2326,6 +2331,145 @@ func (a *App) HasBuiltinReferences(code string) bool {
|
|||||||
return ok
|
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
|
// builtinRefsVersion is bumped whenever the built-in reference data changes
|
||||||
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
|
// (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.
|
// 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.
|
// 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.
|
// Streams from DB so memory stays flat even with 100k+ records.
|
||||||
// includeAppFields=false → portable standard ADIF (for other loggers);
|
// includeAppFields=false → portable standard ADIF (for other loggers);
|
||||||
|
|||||||
@@ -2861,7 +2861,7 @@ export default function App() {
|
|||||||
<DialogHeader className="px-2">
|
<DialogHeader className="px-2">
|
||||||
<DialogTitle>Export ADIF</DialogTitle>
|
<DialogTitle>Export ADIF</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Choose which fields to include in the export.
|
Choose which fields to include. OpsLog writes ADIF 3.1.7.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="px-2 py-1 space-y-2.5">
|
<div className="px-2 py-1 space-y-2.5">
|
||||||
@@ -2870,10 +2870,10 @@ export default function App() {
|
|||||||
onClick={() => runExport(false)}
|
onClick={() => runExport(false)}
|
||||||
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-sm">Standard ADIF</div>
|
<div className="font-semibold text-sm">Standard ADIF only</div>
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
Only standard ADIF-defined fields — portable to other loggers (Log4OM, N1MM, LoTW…).
|
Only fields defined in the ADIF 3.1.7 spec — portable to other loggers (Log4OM, N1MM, LoTW…).
|
||||||
Application-specific <span className="font-mono">APP_*</span> tags are stripped.
|
Application-specific <span className="font-mono">APP_*</span> and any non-standard / vendor tags are stripped.
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -2881,10 +2881,10 @@ export default function App() {
|
|||||||
onClick={() => runExport(true)}
|
onClick={() => runExport(true)}
|
||||||
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-sm">Full (OpsLog round-trip)</div>
|
<div className="font-semibold text-sm">All fields (OpsLog round-trip)</div>
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
Every field including OpsLog/application-specific <span className="font-mono">APP_*</span> tags —
|
Every field including application-specific <span className="font-mono">APP_*</span> and vendor tags —
|
||||||
for a lossless backup you'll re-import into OpsLog.
|
a lossless backup you'll re-import into OpsLog.
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { X, Plus } from 'lucide-react';
|
||||||
|
import { ADIFFields } from '../../wailsjs/go/main/App';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
type FieldDef = {
|
||||||
|
name: string; kind: string; category: string;
|
||||||
|
promoted: boolean; deprecated: boolean; intl: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// The QSO's extras map (uppercase ADIF tag → raw value).
|
||||||
|
value: Record<string, string> | undefined;
|
||||||
|
onChange: (next: Record<string, string> | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdifExtrasEditor — a dictionary-driven editor for every ADIF field that is
|
||||||
|
// NOT promoted to a first-class column. Backed by the QSO's extras map, so any
|
||||||
|
// ADIF 3.1.7 field (plus custom/vendor tags) can be viewed and edited. This is
|
||||||
|
// what makes "100% of ADIF fields available" true without 160 DB columns.
|
||||||
|
export function AdifExtrasEditor({ value, onChange }: Props) {
|
||||||
|
const [dict, setDict] = useState<FieldDef[]>([]);
|
||||||
|
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ADIFFields().then((f) => setDict((f ?? []) as any)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Entries currently set, sorted for stable display.
|
||||||
|
const entries = useMemo(
|
||||||
|
() => Object.entries(value ?? {}).sort((a, b) => a[0].localeCompare(b[0])),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Addable fields: standard non-promoted ADIF fields not already present.
|
||||||
|
// Deprecated (import-only) fields are hidden unless the toggle is on.
|
||||||
|
const addable = useMemo(() => {
|
||||||
|
const have = new Set(Object.keys(value ?? {}));
|
||||||
|
return dict
|
||||||
|
.filter((f) => !f.promoted && !have.has(f.name) && (showDeprecated || !f.deprecated))
|
||||||
|
.map((f) => f.name);
|
||||||
|
}, [dict, value, showDeprecated]);
|
||||||
|
|
||||||
|
const meta = useMemo(() => {
|
||||||
|
const m: Record<string, FieldDef> = {};
|
||||||
|
for (const f of dict) m[f.name] = f;
|
||||||
|
return m;
|
||||||
|
}, [dict]);
|
||||||
|
|
||||||
|
function setKV(key: string, val: string) {
|
||||||
|
const next = { ...(value ?? {}) };
|
||||||
|
next[key] = val;
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
function remove(key: string) {
|
||||||
|
const next = { ...(value ?? {}) };
|
||||||
|
delete next[key];
|
||||||
|
onChange(Object.keys(next).length ? next : undefined);
|
||||||
|
}
|
||||||
|
function addField(name: string) {
|
||||||
|
const key = name.trim().toUpperCase();
|
||||||
|
if (!key || (value && key in value)) return;
|
||||||
|
setKV(key, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it,
|
||||||
|
or type a custom/vendor tag (e.g. <code className="bg-muted px-1 rounded font-mono">APP_*</code>).
|
||||||
|
Stored losslessly and exported in the <strong>full</strong> ADIF mode.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Add a field */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 max-w-xs">
|
||||||
|
<Combobox
|
||||||
|
value=""
|
||||||
|
options={addable}
|
||||||
|
placeholder="Add ADIF field…"
|
||||||
|
allowFreeText
|
||||||
|
onChange={addField}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer">
|
||||||
|
<input type="checkbox" checked={showDeprecated} onChange={(e) => setShowDeprecated(e.target.checked)} />
|
||||||
|
Show deprecated
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current entries */}
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="text-[11px] text-muted-foreground italic border border-dashed border-border rounded-md px-3 py-4 text-center">
|
||||||
|
No extra ADIF fields. Use the picker above to add one.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{entries.map(([k, v]) => {
|
||||||
|
const def = meta[k];
|
||||||
|
return (
|
||||||
|
<div key={k} className="flex items-center gap-2">
|
||||||
|
<div className="w-48 shrink-0">
|
||||||
|
<span className="font-mono text-xs font-semibold">{k}</span>
|
||||||
|
{def && (
|
||||||
|
<span className="block text-[10px] text-muted-foreground leading-tight">
|
||||||
|
{def.category}{def.deprecated ? ' · deprecated' : ''}{def.intl ? ' · intl' : ''}
|
||||||
|
{!def && ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!def && <span className="block text-[10px] text-amber-600 leading-tight">non-standard</span>}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
className="flex-1 h-8 text-xs font-mono"
|
||||||
|
value={v}
|
||||||
|
onChange={(e) => setKV(k, e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => remove(k)} title="Remove field"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, RotateCcw, Save, Download, Loader2, Search } from 'lucide-react';
|
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
|
ExportAwards, ImportAwards,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// Above this many references the editor stops loading the whole list and
|
// Above this many references the editor stops loading the whole list and
|
||||||
@@ -182,6 +183,28 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
async function reset() {
|
async function reset() {
|
||||||
try { setDefs((await ResetAwardDefs()) as any); setSel(0); } catch (e: any) { setErr(String(e?.message ?? e)); }
|
try { setDefs((await ResetAwardDefs()) as any); setSel(0); } catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
// Export every award definition + reference list to a JSON bundle (backup
|
||||||
|
// that survives a reinstall / PC change, independent of the database).
|
||||||
|
async function exportAwards() {
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const p = await ExportAwards();
|
||||||
|
if (p) setErr(`Awards exported to:\n${p}`);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
// Import an award bundle: definitions are upserted by code, reference lists
|
||||||
|
// replaced. Reloads the editor afterwards.
|
||||||
|
async function importAwards() {
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const r: any = await ImportAwards();
|
||||||
|
if (!r || (!r.awards && !r.references)) return; // cancelled
|
||||||
|
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
||||||
|
setDefs((d ?? []) as any); setSel(0);
|
||||||
|
onSaved();
|
||||||
|
setErr(`Imported ${r.awards} award(s) and ${r.references} reference(s).`);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
async function updateList(code: string) {
|
async function updateList(code: string) {
|
||||||
setUpdating(code); setErr('');
|
setUpdating(code); setErr('');
|
||||||
try { await UpdateAwardReferenceList(code); await loadMeta(); }
|
try { await UpdateAwardReferenceList(code); await loadMeta(); }
|
||||||
@@ -228,7 +251,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
{/* Right: tabbed editor for selected award */}
|
{/* Right: tabbed editor for selected award */}
|
||||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
{err && <div className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{err}</div>}
|
{err && <div className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all">{err}</div>}
|
||||||
{!cur ? (
|
{!cur ? (
|
||||||
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</div>
|
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -338,6 +361,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
<DialogFooter className="px-5 py-3 border-t !flex-row">
|
||||||
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> Reset to defaults</Button>
|
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> Reset to defaults</Button>
|
||||||
|
<Button variant="outline" onClick={exportAwards} title="Export all award definitions + reference lists to a JSON backup">
|
||||||
|
<Download className="size-3.5 mr-1" /> Export…
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={importAwards} title="Import an award bundle (definitions + reference lists)">
|
||||||
|
<Upload className="size-3.5 mr-1" /> Import…
|
||||||
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> Save</Button>
|
<Button onClick={save}><Save className="size-3.5 mr-1" /> Save</Button>
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ type Meta = { code: string; count: number; can_update: boolean };
|
|||||||
|
|
||||||
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
|
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
|
||||||
// are computed, never manually picked, so they don't belong in this picker.
|
// are computed, never manually picked, so they don't belong in this picker.
|
||||||
const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'state', 'cont', 'country', 'grid']);
|
// Fields purely derived from the callsign / cty.dat — their awards are computed,
|
||||||
|
// never picked. NB: 'state' and 'cnty' are NOT here — they're operator-settable
|
||||||
|
// QSO fields driving predefined-list awards (WAS/RAC/WAJA/JCC), so they ARE
|
||||||
|
// pickable (a lookup rarely fills the JA prefecture or VE province).
|
||||||
|
const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'cont', 'country', 'grid']);
|
||||||
|
|
||||||
// If DXCC-filtered auto-results exceed this, require the user to type instead.
|
// If DXCC-filtered auto-results exceed this, require the user to type instead.
|
||||||
const AUTO_SHOW_MAX = 100;
|
const AUTO_SHOW_MAX = 100;
|
||||||
@@ -72,15 +76,26 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
|
|||||||
if (!awards.find((a) => a.code === awardCode)) setAwardCode(awards[0].code);
|
if (!awards.find((a) => a.code === awardCode)) setAwardCode(awards[0].code);
|
||||||
}, [awards, awardCode]);
|
}, [awards, awardCode]);
|
||||||
|
|
||||||
// Auto-load DXCC-filtered refs on award/dxcc change with empty query.
|
// Is the selected award a giant dynamic list (POTA/SOTA/IOTA)? Those carry a
|
||||||
// Fetches AUTO_SHOW_MAX+1 so we can distinguish "all results shown" from "too many".
|
// per-reference DXCC so we filter by entity; predefined lists (WAS/RAC/WAJA)
|
||||||
|
// are small and their refs may lack a per-ref DXCC, so we load them whole.
|
||||||
|
const isDynamic = useMemo(
|
||||||
|
() => !!defs.find((d) => d.code === awardCode)?.dynamic,
|
||||||
|
[defs, awardCode],
|
||||||
|
);
|
||||||
|
// For dynamic lists, restrict to the contacted entity; otherwise load all.
|
||||||
|
const refDxcc = isDynamic ? (dxcc ?? 0) : 0;
|
||||||
|
|
||||||
|
// Auto-load refs on award/dxcc change with empty query. Fetches AUTO_SHOW_MAX+1
|
||||||
|
// so we can distinguish "all results shown" from "too many to list".
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAutoResults([]);
|
setAutoResults([]);
|
||||||
if (!dxcc) return;
|
// Dynamic lists need an entity to scope to; predefined lists load regardless.
|
||||||
SearchAwardReferences(awardCode, '', dxcc, AUTO_SHOW_MAX + 1)
|
if (isDynamic && !dxcc) return;
|
||||||
|
SearchAwardReferences(awardCode, '', refDxcc, AUTO_SHOW_MAX + 1)
|
||||||
.then((r) => setAutoResults((r ?? []) as any))
|
.then((r) => setAutoResults((r ?? []) as any))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [awardCode, dxcc]);
|
}, [awardCode, dxcc, isDynamic, refDxcc]);
|
||||||
|
|
||||||
// Typed search (2+ chars).
|
// Typed search (2+ chars).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,13 +103,13 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
|
|||||||
const t = window.setTimeout(async () => {
|
const t = window.setTimeout(async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await SearchAwardReferences(awardCode, q, dxcc ?? 0, 50);
|
const r = await SearchAwardReferences(awardCode, q, refDxcc, 50);
|
||||||
setSearchResults((r ?? []) as any);
|
setSearchResults((r ?? []) as any);
|
||||||
} catch { setSearchResults([]); }
|
} catch { setSearchResults([]); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}, 200);
|
}, 200);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCode, q, dxcc]);
|
}, [awardCode, q, refDxcc]);
|
||||||
|
|
||||||
const tooManyAuto = autoResults.length > AUTO_SHOW_MAX;
|
const tooManyAuto = autoResults.length > AUTO_SHOW_MAX;
|
||||||
// When q is empty/short: show auto-results (if ≤ AUTO_SHOW_MAX); when typing: show search results.
|
// When q is empty/short: show auto-results (if ≤ AUTO_SHOW_MAX); when typing: show search results.
|
||||||
@@ -212,22 +227,19 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
|
|||||||
<Loader2 className="size-3 animate-spin" />Searching…
|
<Loader2 className="size-3 animate-spin" />Searching…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* No callsign yet */}
|
{/* Too many auto-results → require typed search */}
|
||||||
{!busy && !dxcc && q.length < 2 && (
|
{!busy && q.length < 2 && tooManyAuto && (
|
||||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
|
||||||
Enter a callsign, or type to search.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* DXCC known but too many auto-results → require typed search */}
|
|
||||||
{!busy && !!dxcc && q.length < 2 && tooManyAuto && (
|
|
||||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||||
Type 2+ chars to search
|
Type 2+ chars to search
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* DXCC known, auto-results loaded, none found */}
|
{/* Empty short-query state: prompt for a callsign (dynamic lists) or
|
||||||
{!busy && !!dxcc && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
|
note the list is empty (predefined awards with no references). */}
|
||||||
|
{!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
|
||||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||||
No references for this entity.
|
{isDynamic && !dxcc
|
||||||
|
? 'Enter a callsign, or type to search.'
|
||||||
|
: 'No references for this entity.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Typed search, no results */}
|
{/* Typed search, no results */}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function AwardsPanel() {
|
|||||||
const filteredRefs = useMemo(() => {
|
const filteredRefs = useMemo(() => {
|
||||||
if (!current) return [];
|
if (!current) return [];
|
||||||
const q = refSearch.trim().toUpperCase();
|
const q = refSearch.trim().toUpperCase();
|
||||||
return current.refs.filter((r) => {
|
return (current.refs ?? []).filter((r) => {
|
||||||
if (refFilter === 'worked' && !r.worked) return false;
|
if (refFilter === 'worked' && !r.worked) return false;
|
||||||
if (refFilter === 'notworked' && r.worked) return false;
|
if (refFilter === 'notworked' && r.worked) return false;
|
||||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||||
@@ -206,11 +206,11 @@ export function AwardsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Band breakdown */}
|
{/* Band breakdown */}
|
||||||
{current.bands.length > 0 && (
|
{(current.bands ?? []).length > 0 && (
|
||||||
<div className="px-4 py-2 border-b border-border/60">
|
<div className="px-4 py-2 border-b border-border/60">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
|
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{current.bands.map((b) => (
|
{(current.bands ?? []).map((b) => (
|
||||||
<div key={b.band} className="rounded-md border border-border bg-card px-2 py-1 text-xs">
|
<div key={b.band} className="rounded-md border border-border bg-card px-2 py-1 text-xs">
|
||||||
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
||||||
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
|
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { Trash2, Search, Loader2 } from 'lucide-react';
|
import { Trash2, Search, Loader2 } from 'lucide-react';
|
||||||
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
import { applyAwardRefs, buildAwardRefs } from '@/lib/awardRefs';
|
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||||
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@@ -100,23 +101,6 @@ function parseLocalISO(s: string): string | null {
|
|||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00.000Z`;
|
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00.000Z`;
|
||||||
}
|
}
|
||||||
function stringifyExtras(e?: Record<string, string>): string {
|
|
||||||
if (!e) return '';
|
|
||||||
return Object.entries(e).map(([k, v]) => `${k} = ${v}`).join('\n');
|
|
||||||
}
|
|
||||||
function parseExtras(t: string): Record<string, string> | undefined {
|
|
||||||
const out: Record<string, string> = {};
|
|
||||||
for (const raw of t.split('\n')) {
|
|
||||||
const line = raw.trim();
|
|
||||||
if (!line) continue;
|
|
||||||
const idx = line.indexOf('=');
|
|
||||||
if (idx < 0) continue;
|
|
||||||
const k = line.slice(0, idx).trim().toUpperCase();
|
|
||||||
const v = line.slice(idx + 1).trim();
|
|
||||||
if (k && v) out[k] = v;
|
|
||||||
}
|
|
||||||
return Object.keys(out).length ? out : undefined;
|
|
||||||
}
|
|
||||||
function numOrUndef(v: any): number | undefined {
|
function numOrUndef(v: any): number | undefined {
|
||||||
if (v === '' || v === null || v === undefined) return undefined;
|
if (v === '' || v === null || v === undefined) return undefined;
|
||||||
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
||||||
@@ -163,7 +147,6 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
|
|||||||
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
|
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
|
||||||
const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off);
|
const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off);
|
||||||
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
|
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
|
||||||
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
|
|
||||||
const [localErr, setLocalErr] = useState('');
|
const [localErr, setLocalErr] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [looking, setLooking] = useState(false);
|
const [looking, setLooking] = useState(false);
|
||||||
@@ -183,15 +166,17 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
|
|||||||
const fieldOf: Record<string, string> = {};
|
const fieldOf: Record<string, string> = {};
|
||||||
for (const d of list) fieldOf[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
|
for (const d of list) fieldOf[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
|
||||||
awardFieldRef.current = fieldOf;
|
awardFieldRef.current = fieldOf;
|
||||||
// Which awards are reference-list (manual) ones? Ask the backend, which
|
// Seed the editable manual refs from the backend, which already matched
|
||||||
// also tells us pickable vs computed for the current QSO.
|
// each reference against its award's own list. Seeding from the raw QSO
|
||||||
|
// field instead would wrongly seed every state-award (WAS/RAC/WAJA) from
|
||||||
|
// the same `state` value — e.g. a US "CA" would seed RAC@CA too.
|
||||||
try {
|
try {
|
||||||
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
|
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
|
||||||
const pickableCodes = new Set(all.filter((r: any) => r.pickable).map((r: any) => String(r.code).toUpperCase()));
|
const seed = all
|
||||||
const pickable = list
|
.filter((r: any) => r.pickable)
|
||||||
.filter((d) => pickableCodes.has(String(d.code).toUpperCase()))
|
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
||||||
.map((d) => ({ code: String(d.code), field: String(d.field || '').toLowerCase() }));
|
.join(';');
|
||||||
setAwardRefs(buildAwardRefs(draft, pickable));
|
setAwardRefs(seed);
|
||||||
} catch { /* leave manual refs empty on failure */ }
|
} catch { /* leave manual refs empty on failure */ }
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -292,7 +277,12 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
|
|||||||
my_lat: numOrUndef(draft.my_lat), my_lon: numOrUndef(draft.my_lon),
|
my_lat: numOrUndef(draft.my_lat), my_lon: numOrUndef(draft.my_lon),
|
||||||
ant_az: numOrUndef(draft.ant_az), ant_el: numOrUndef(draft.ant_el),
|
ant_az: numOrUndef(draft.ant_az), ant_el: numOrUndef(draft.ant_el),
|
||||||
tx_pwr: numOrUndef(draft.tx_pwr),
|
tx_pwr: numOrUndef(draft.tx_pwr),
|
||||||
extras: parseExtras(extrasText),
|
distance: numOrUndef(draft.distance),
|
||||||
|
rx_pwr: numOrUndef(draft.rx_pwr),
|
||||||
|
a_index: numOrUndef(draft.a_index),
|
||||||
|
k_index: numOrUndef(draft.k_index),
|
||||||
|
sfi: numOrUndef(draft.sfi),
|
||||||
|
extras: draft.extras && Object.keys(draft.extras).length ? draft.extras : undefined,
|
||||||
};
|
};
|
||||||
// The Award Refs tab is authoritative for the reference-list awards. Reset
|
// The Award Refs tab is authoritative for the reference-list awards. Reset
|
||||||
// the dedicated columns, then route the picked refs back onto the payload
|
// the dedicated columns, then route the picked refs back onto the payload
|
||||||
@@ -334,8 +324,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
|
|||||||
<TabsTrigger value="contest">Contest</TabsTrigger>
|
<TabsTrigger value="contest">Contest</TabsTrigger>
|
||||||
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
||||||
<TabsTrigger value="mystation">My Station</TabsTrigger>
|
<TabsTrigger value="mystation">My Station</TabsTrigger>
|
||||||
|
<TabsTrigger value="moreadif">More ADIF</TabsTrigger>
|
||||||
<TabsTrigger value="extras">
|
<TabsTrigger value="extras">
|
||||||
Extras
|
ADIF fields
|
||||||
{extrasCount > 0 && (
|
{extrasCount > 0 && (
|
||||||
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -602,12 +593,74 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="extras" className="mt-0 space-y-2">
|
<TabsContent value="moreadif" className="mt-0 space-y-4">
|
||||||
<p className="text-xs text-muted-foreground">
|
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
|
||||||
ADIF fields not promoted to first-class columns. One per line:{' '}
|
<div>
|
||||||
<code className="bg-muted px-1 py-0.5 rounded font-mono">FIELD_NAME = value</code>
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Special activity</p>
|
||||||
</p>
|
<div className="grid grid-cols-6 gap-3">
|
||||||
<Textarea rows={14} className="font-mono text-xs" value={extrasText} onChange={(e) => setExtrasText(e.target.value)} />
|
<F label="SIG"><Input value={draft.sig ?? ''} placeholder="POTA" onChange={(e) => set('sig', e.target.value)} /></F>
|
||||||
|
<F label="SIG info" span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
|
||||||
|
<F label="WWFF ref" span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||||
|
<F label="Region"><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Power & propagation */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Power & space weather</p>
|
||||||
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
<F label="RX power (W)"><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="Distance (km)"><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="A index"><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="K index"><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="SFI"><Input type="number" value={draft.sfi ?? ''} onChange={(e) => set('sfi', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Identity & clubs */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Identity & clubs</p>
|
||||||
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
<F label="Contacted op" span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
|
||||||
|
<F label="Former call (EQ_CALL)" span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
|
||||||
|
<F label="Class"><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
|
||||||
|
<F label="SKCC"><Input value={draft.skcc ?? ''} onChange={(e) => set('skcc', e.target.value)} /></F>
|
||||||
|
<F label="FISTS"><Input value={draft.fists ?? ''} onChange={(e) => set('fists', e.target.value)} /></F>
|
||||||
|
<F label="Ten-Ten"><Input value={draft.ten_ten ?? ''} onChange={(e) => set('ten_ten', e.target.value)} /></F>
|
||||||
|
<F label="DARC DOK"><Input value={draft.darc_dok ?? ''} onChange={(e) => set('darc_dok', e.target.value)} /></F>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flags & credits */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Flags & credits</p>
|
||||||
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
<F label="QSO complete"><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
|
||||||
|
<F label="QSO random"><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
|
||||||
|
<F label="Silent key"><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
|
||||||
|
<F label="SWL"><Input value={draft.swl ?? ''} placeholder="Y/N" onChange={(e) => set('swl', e.target.value)} /></F>
|
||||||
|
<F label="Credit granted" span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
|
||||||
|
<F label="Credit submitted" span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My station extras */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">My station (ADIF)</p>
|
||||||
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
<F label="My name" span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
|
||||||
|
<F label="My WWFF ref" span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
|
||||||
|
<F label="My ARRL sect" span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
|
||||||
|
<F label="My SIG"><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
|
||||||
|
<F label="My SIG info" span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
|
||||||
|
<F label="My DARC DOK"><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
|
||||||
|
<F label="My VUCC grids" span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="extras" className="mt-0">
|
||||||
|
<AdifExtrasEditor value={draft.extras} onChange={(next) => set('extras', next as any)} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<
|
|||||||
case 'iota': payload.iota = ref; break;
|
case 'iota': payload.iota = ref; break;
|
||||||
case 'sota_ref': payload.sota_ref = ref; break;
|
case 'sota_ref': payload.sota_ref = ref; break;
|
||||||
case 'pota_ref': payload.pota_ref = ref; break;
|
case 'pota_ref': payload.pota_ref = ref; break;
|
||||||
|
// Predefined-list awards on a QSO field (WAS/RAC/WAJA on state, JCC on
|
||||||
|
// county): picking a reference writes it straight into that column.
|
||||||
|
case 'state': payload.state = ref; break;
|
||||||
|
case 'cnty': payload.cnty = ref; break;
|
||||||
case 'wwff':
|
case 'wwff':
|
||||||
extras['WWFF_REF'] = ref;
|
extras['WWFF_REF'] = ref;
|
||||||
extras['SIG'] = 'WWFF';
|
extras['SIG'] = 'WWFF';
|
||||||
@@ -73,6 +77,8 @@ export function awardRefValue(qso: any, code: string, field: string): string {
|
|||||||
case 'iota': return (qso.iota ?? '').toUpperCase();
|
case 'iota': return (qso.iota ?? '').toUpperCase();
|
||||||
case 'sota_ref': return (qso.sota_ref ?? '').toUpperCase();
|
case 'sota_ref': return (qso.sota_ref ?? '').toUpperCase();
|
||||||
case 'pota_ref': return (qso.pota_ref ?? '').toUpperCase();
|
case 'pota_ref': return (qso.pota_ref ?? '').toUpperCase();
|
||||||
|
case 'state': return (qso.state ?? '').toUpperCase();
|
||||||
|
case 'cnty': return (qso.cnty ?? '').toUpperCase();
|
||||||
case 'wwff': {
|
case 'wwff': {
|
||||||
const ex = qso.extras ?? {};
|
const ex = qso.extras ?? {};
|
||||||
if (ex['WWFF_REF']) return String(ex['WWFF_REF']).toUpperCase();
|
if (ex['WWFF_REF']) return String(ex['WWFF_REF']).toUpperCase();
|
||||||
|
|||||||
Vendored
+8
-6
@@ -1,9 +1,9 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
import {adif} from '../models';
|
||||||
import {qso} from '../models';
|
import {qso} from '../models';
|
||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
import {profile} from '../models';
|
import {profile} from '../models';
|
||||||
import {adif} from '../models';
|
|
||||||
import {award} from '../models';
|
import {award} from '../models';
|
||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
@@ -15,6 +15,10 @@ import {operating} from '../models';
|
|||||||
import {udp} from '../models';
|
import {udp} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
|
|
||||||
|
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||||
|
|
||||||
|
export function ADIFVersion():Promise<string>;
|
||||||
|
|
||||||
export function ActivateProfile(arg1:number):Promise<void>;
|
export function ActivateProfile(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
@@ -77,8 +81,6 @@ export function DeleteQSO(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DisablePortableMode():Promise<void>;
|
|
||||||
|
|
||||||
export function DisconnectAllClusters():Promise<void>;
|
export function DisconnectAllClusters():Promise<void>;
|
||||||
|
|
||||||
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
||||||
@@ -89,14 +91,14 @@ export function DownloadConfirmations(arg1:string,arg2:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function EnablePortableMode():Promise<void>;
|
|
||||||
|
|
||||||
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
|
export function ExportAwards():Promise<string>;
|
||||||
|
|
||||||
export function FilterFields():Promise<Array<string>>;
|
export function FilterFields():Promise<Array<string>>;
|
||||||
|
|
||||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
||||||
@@ -175,7 +177,7 @@ export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.Im
|
|||||||
|
|
||||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
export function IsPortableMode():Promise<boolean>;
|
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||||
|
|
||||||
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function ADIFFields() {
|
||||||
|
return window['go']['main']['App']['ADIFFields']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ADIFVersion() {
|
||||||
|
return window['go']['main']['App']['ADIFVersion']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ActivateProfile(arg1) {
|
export function ActivateProfile(arg1) {
|
||||||
return window['go']['main']['App']['ActivateProfile'](arg1);
|
return window['go']['main']['App']['ActivateProfile'](arg1);
|
||||||
}
|
}
|
||||||
@@ -126,10 +134,6 @@ export function DeleteUDPIntegration(arg1) {
|
|||||||
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
|
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DisablePortableMode() {
|
|
||||||
return window['go']['main']['App']['DisablePortableMode']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DisconnectAllClusters() {
|
export function DisconnectAllClusters() {
|
||||||
return window['go']['main']['App']['DisconnectAllClusters']();
|
return window['go']['main']['App']['DisconnectAllClusters']();
|
||||||
}
|
}
|
||||||
@@ -150,10 +154,6 @@ export function DuplicateProfile(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnablePortableMode() {
|
|
||||||
return window['go']['main']['App']['EnablePortableMode']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExportADIF(arg1, arg2) {
|
export function ExportADIF(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -166,6 +166,10 @@ export function ExportADIFSelected(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportAwards() {
|
||||||
|
return window['go']['main']['App']['ExportAwards']();
|
||||||
|
}
|
||||||
|
|
||||||
export function FilterFields() {
|
export function FilterFields() {
|
||||||
return window['go']['main']['App']['FilterFields']();
|
return window['go']['main']['App']['FilterFields']();
|
||||||
}
|
}
|
||||||
@@ -322,8 +326,8 @@ export function ImportAwardReferencesText(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ImportAwardReferencesText'](arg1, arg2);
|
return window['go']['main']['App']['ImportAwardReferencesText'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IsPortableMode() {
|
export function ImportAwards() {
|
||||||
return window['go']['main']['App']['IsPortableMode']();
|
return window['go']['main']['App']['ImportAwards']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListAudioInputDevices() {
|
export function ListAudioInputDevices() {
|
||||||
|
|||||||
@@ -16,6 +16,28 @@ export namespace adif {
|
|||||||
this.size_kb = source["size_kb"];
|
this.size_kb = source["size_kb"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class FieldDef {
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
category: string;
|
||||||
|
promoted: boolean;
|
||||||
|
deprecated: boolean;
|
||||||
|
intl: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FieldDef(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.kind = source["kind"];
|
||||||
|
this.category = source["category"];
|
||||||
|
this.promoted = source["promoted"];
|
||||||
|
this.deprecated = source["deprecated"];
|
||||||
|
this.intl = source["intl"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ImportResult {
|
export class ImportResult {
|
||||||
total: number;
|
total: number;
|
||||||
imported: number;
|
imported: number;
|
||||||
@@ -660,6 +682,20 @@ export namespace main {
|
|||||||
this.mic_gain = source["mic_gain"];
|
this.mic_gain = source["mic_gain"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class AwardImportResult {
|
||||||
|
awards: number;
|
||||||
|
references: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AwardImportResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.awards = source["awards"];
|
||||||
|
this.references = source["references"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AwardRefMeta {
|
export class AwardRefMeta {
|
||||||
code: string;
|
code: string;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -1184,6 +1220,7 @@ export namespace main {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
err: string;
|
err: string;
|
||||||
db_path: string;
|
db_path: string;
|
||||||
|
migrated_from_app_data: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new StartupStatus(source);
|
return new StartupStatus(source);
|
||||||
@@ -1194,6 +1231,7 @@ export namespace main {
|
|||||||
this.ok = source["ok"];
|
this.ok = source["ok"];
|
||||||
this.err = source["err"];
|
this.err = source["err"];
|
||||||
this.db_path = source["db_path"];
|
this.db_path = source["db_path"];
|
||||||
|
this.migrated_from_app_data = source["migrated_from_app_data"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class StationInfoComputed {
|
export class StationInfoComputed {
|
||||||
@@ -1696,6 +1734,36 @@ export namespace qso {
|
|||||||
tx_pwr?: number;
|
tx_pwr?: number;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
sig?: string;
|
||||||
|
sig_info?: string;
|
||||||
|
my_sig?: string;
|
||||||
|
my_sig_info?: string;
|
||||||
|
wwff_ref?: string;
|
||||||
|
my_wwff_ref?: string;
|
||||||
|
distance?: number;
|
||||||
|
rx_pwr?: number;
|
||||||
|
a_index?: number;
|
||||||
|
k_index?: number;
|
||||||
|
sfi?: number;
|
||||||
|
skcc?: string;
|
||||||
|
fists?: string;
|
||||||
|
ten_ten?: string;
|
||||||
|
contacted_op?: string;
|
||||||
|
eq_call?: string;
|
||||||
|
pfx?: string;
|
||||||
|
my_name?: string;
|
||||||
|
class?: string;
|
||||||
|
darc_dok?: string;
|
||||||
|
my_darc_dok?: string;
|
||||||
|
region?: string;
|
||||||
|
silent_key?: string;
|
||||||
|
swl?: string;
|
||||||
|
qso_complete?: string;
|
||||||
|
qso_random?: string;
|
||||||
|
credit_granted?: string;
|
||||||
|
credit_submitted?: string;
|
||||||
|
my_arrl_sect?: string;
|
||||||
|
my_vucc_grids?: string;
|
||||||
extras?: Record<string, string>;
|
extras?: Record<string, string>;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
created_at: any;
|
created_at: any;
|
||||||
@@ -1803,6 +1871,36 @@ export namespace qso {
|
|||||||
this.tx_pwr = source["tx_pwr"];
|
this.tx_pwr = source["tx_pwr"];
|
||||||
this.comment = source["comment"];
|
this.comment = source["comment"];
|
||||||
this.notes = source["notes"];
|
this.notes = source["notes"];
|
||||||
|
this.sig = source["sig"];
|
||||||
|
this.sig_info = source["sig_info"];
|
||||||
|
this.my_sig = source["my_sig"];
|
||||||
|
this.my_sig_info = source["my_sig_info"];
|
||||||
|
this.wwff_ref = source["wwff_ref"];
|
||||||
|
this.my_wwff_ref = source["my_wwff_ref"];
|
||||||
|
this.distance = source["distance"];
|
||||||
|
this.rx_pwr = source["rx_pwr"];
|
||||||
|
this.a_index = source["a_index"];
|
||||||
|
this.k_index = source["k_index"];
|
||||||
|
this.sfi = source["sfi"];
|
||||||
|
this.skcc = source["skcc"];
|
||||||
|
this.fists = source["fists"];
|
||||||
|
this.ten_ten = source["ten_ten"];
|
||||||
|
this.contacted_op = source["contacted_op"];
|
||||||
|
this.eq_call = source["eq_call"];
|
||||||
|
this.pfx = source["pfx"];
|
||||||
|
this.my_name = source["my_name"];
|
||||||
|
this.class = source["class"];
|
||||||
|
this.darc_dok = source["darc_dok"];
|
||||||
|
this.my_darc_dok = source["my_darc_dok"];
|
||||||
|
this.region = source["region"];
|
||||||
|
this.silent_key = source["silent_key"];
|
||||||
|
this.swl = source["swl"];
|
||||||
|
this.qso_complete = source["qso_complete"];
|
||||||
|
this.qso_random = source["qso_random"];
|
||||||
|
this.credit_granted = source["credit_granted"];
|
||||||
|
this.credit_submitted = source["credit_submitted"];
|
||||||
|
this.my_arrl_sect = source["my_arrl_sect"];
|
||||||
|
this.my_vucc_grids = source["my_vucc_grids"];
|
||||||
this.extras = source["extras"];
|
this.extras = source["extras"];
|
||||||
this.created_at = this.convertValues(source["created_at"], null);
|
this.created_at = this.convertValues(source["created_at"], null);
|
||||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
|
|||||||
+38
-5
@@ -92,7 +92,7 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
|
|||||||
ver := strings.TrimSpace(e.AppVersion)
|
ver := strings.TrimSpace(e.AppVersion)
|
||||||
now := time.Now().UTC().Format("20060102 150405")
|
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 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 != "" {
|
if ver != "" {
|
||||||
fmt.Fprintf(bw, " <PROGRAMVERSION:%d>%s", len(ver), 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, "COMMENT", q.Comment)
|
||||||
writeField(bw, "NOTES", q.Notes)
|
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) ---
|
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
||||||
// In standard mode we drop application-specific tags (APP_*) so the file
|
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
|
||||||
// stays portable to other loggers; in full mode they're kept for a
|
// application-specific tags AND any non-standard / vendor tag — keeping
|
||||||
// lossless OpsLog round-trip.
|
// the file strictly portable to other loggers. Full mode keeps every
|
||||||
|
// extra for a lossless OpsLog round-trip.
|
||||||
for k, v := range q.Extras {
|
for k, v := range q.Extras {
|
||||||
tag := strings.ToUpper(k)
|
tag := strings.ToUpper(k)
|
||||||
if !includeApp && strings.HasPrefix(tag, "APP_") {
|
if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
writeField(bw, tag, v)
|
writeField(bw, tag, v)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -284,6 +284,12 @@ var adifPromoted = stringSet(
|
|||||||
"my_street", "my_city", "my_postal_code", "my_rig", "my_antenna",
|
"my_street", "my_city", "my_postal_code", "my_rig", "my_antenna",
|
||||||
// Misc
|
// Misc
|
||||||
"tx_pwr", "comment", "notes",
|
"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{} {
|
func stringSet(items ...string) map[string]struct{} {
|
||||||
@@ -482,6 +488,48 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
|||||||
q.Comment = rec["comment"]
|
q.Comment = rec["comment"]
|
||||||
q.Notes = rec["notes"]
|
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).
|
// Everything else lands in extras (uppercased ADIF names).
|
||||||
var extras map[string]string
|
var extras map[string]string
|
||||||
for k, v := range rec {
|
for k, v := range rec {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -210,8 +210,13 @@ type refList struct {
|
|||||||
byCode map[string]RefMeta // uppercased code → metadata
|
byCode map[string]RefMeta // uppercased code → metadata
|
||||||
codes []string // codes in input order (for stable unworked listing)
|
codes []string // codes in input order (for stable unworked listing)
|
||||||
withPattern []string // codes whose reference declares a regex (usually none)
|
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
|
// 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
|
// predefined list, per-reference DXCC scoping, a per-reference pattern, and to
|
||||||
// label results.
|
// label results.
|
||||||
@@ -243,6 +248,9 @@ func NewRefList(metas []RefMeta) refList {
|
|||||||
m.Code = code
|
m.Code = code
|
||||||
if _, dup := rl.byCode[code]; !dup {
|
if _, dup := rl.byCode[code]; !dup {
|
||||||
rl.codes = append(rl.codes, code)
|
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
|
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) {
|
for _, b := range sortedBands(bandWorked) {
|
||||||
r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]})
|
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
|
out[i] = r
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -428,12 +445,27 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
predefined := hasList && !d.Dynamic
|
predefined := hasList && !d.Dynamic
|
||||||
|
byDesc := predefined && strings.EqualFold(strings.TrimSpace(d.MatchBy), "description")
|
||||||
|
|
||||||
var found []string
|
var found []string
|
||||||
switch {
|
switch {
|
||||||
case re != nil:
|
case re != nil:
|
||||||
// Award-level regex: capture group 1 (or whole match) for each hit.
|
// Award-level regex: capture group 1 (or whole match) for each hit.
|
||||||
found = regexTokens(re, raw)
|
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:
|
case predefined && !d.ExactMatch:
|
||||||
// "Search reference inside the field": look up each token of the field in
|
// "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
|
// the list — O(tokens), not O(all references) — plus test the few
|
||||||
|
|||||||
@@ -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 {
|
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 {
|
||||||
|
|||||||
@@ -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
@@ -156,8 +156,42 @@ type QSO struct {
|
|||||||
Comment string `json:"comment,omitempty"`
|
Comment string `json:"comment,omitempty"`
|
||||||
Notes string `json:"notes,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
|
// 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"`
|
Extras map[string]string `json:"extras,omitempty"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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,
|
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_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,
|
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`
|
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.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.MySOTARef, q.MyPOTARef, q.MyDXCC, q.MyCQZone, q.MyITUZone, q.MyLat, q.MyLon,
|
||||||
q.MyStreet, q.MyCity, q.MyPostalCode, q.MyRig, q.MyAntenna,
|
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
|
myRig, myAntenna sql.NullString
|
||||||
txp sql.NullFloat64
|
txp sql.NullFloat64
|
||||||
comment, notes sql.NullString
|
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
|
extrasJSON sql.NullString
|
||||||
createdStr, updatedStr string
|
createdStr, updatedStr string
|
||||||
)
|
)
|
||||||
@@ -1494,7 +1549,13 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
|
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
|
||||||
&mySOTA, &myPOTA, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon,
|
&mySOTA, &myPOTA, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon,
|
||||||
&myStreet, &myCity, &myPostal, &myRig, &myAntenna,
|
&myStreet, &myCity, &myPostal, &myRig, &myAntenna,
|
||||||
&txp, &comment, ¬es, &extrasJSON, &createdStr, &updatedStr,
|
&txp, &comment, ¬es,
|
||||||
|
&sig, &sigInfo, &mySig, &mySigInfo, &wwffRef, &myWWFFRef,
|
||||||
|
&distance, &rxPwr, &aIndex, &kIndex, &sfi,
|
||||||
|
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
||||||
|
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
||||||
|
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
||||||
|
&extrasJSON, &createdStr, &updatedStr,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1645,6 +1706,51 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
}
|
}
|
||||||
q.Comment = comment.String
|
q.Comment = comment.String
|
||||||
q.Notes = notes.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)
|
q.Extras = decodeExtras(extrasJSON.String)
|
||||||
return q, nil
|
return q, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user