diff --git a/app.go b/app.go index c399a5c..7b15146 100644 --- a/app.go +++ b/app.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "math" "os" "path/filepath" @@ -349,9 +350,10 @@ type App struct { pttMu sync.Mutex pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle - startupErr string // captured for surfacing to the frontend - dbPath string // active database file (may be a user-chosen location) - dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat + startupErr string // captured for surfacing to the frontend + dbPath string // active database file (may be a user-chosen location) + dataDir string // /data — holds config.json, logs, cty.dat + migratedFromAppData bool // true when we auto-copied AppData on first portable launch // shuttingDown gates beforeClose re-entry: the first user attempt to // close fires shutdown tasks (backup, future LoTW upload, ...) while @@ -475,6 +477,15 @@ func (a *App) startup(ctx context.Context) { fmt.Println("OpsLog:", a.startupErr) return } + // First-launch migration: if the portable data dir has no database yet, + // copy whatever is in AppData/OpsLog (or AppData/HamLog) so the user + // keeps their log after the switch to fully-portable layout. + if migrated, migrErr := autoMigrateFromAppData(dataDir); migrated { + a.migratedFromAppData = true + if migrErr != nil { + fmt.Println("OpsLog: migration warning:", migrErr) + } + } if err := os.MkdirAll(dataDir, 0o755); err != nil { a.startupErr = "cannot create data dir: " + err.Error() fmt.Println("OpsLog:", a.startupErr) @@ -676,18 +687,20 @@ func (a *App) startup(ctx context.Context) { // StartupStatus returns a diagnostic snapshot for the frontend. // dbPath is always populated; err is empty when the app is healthy. type StartupStatus struct { - OK bool `json:"ok"` - Err string `json:"err"` - DBPath string `json:"db_path"` + OK bool `json:"ok"` + Err string `json:"err"` + DBPath string `json:"db_path"` + MigratedFromAppData bool `json:"migrated_from_app_data"` } // GetStartupStatus exposes whatever happened during startup so the UI // can show a useful error instead of just "db not initialized". func (a *App) GetStartupStatus() StartupStatus { return StartupStatus{ - OK: a.startupErr == "", - Err: a.startupErr, - DBPath: a.dbPath, + OK: a.startupErr == "", + Err: a.startupErr, + DBPath: a.dbPath, + MigratedFromAppData: a.migratedFromAppData, } } @@ -829,25 +842,95 @@ func (a *App) shutdown(ctx context.Context) { } } -// userDataDir returns the OpsLog data directory under the user's config -// dir. The app was previously called HamLog — if the old folder exists -// and the new one doesn't, we rename it atomically so the user keeps -// their database, settings and cluster history through the rebrand. +// userDataDir returns the OpsLog data directory: always "/data". +// All data (database, settings, cty.dat, logs) travels with the executable, +// making OpsLog fully portable for USB sticks and PC migrations. func userDataDir() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("cannot locate executable: %w", err) + } + return filepath.Join(filepath.Dir(exe), "data"), nil +} + +// autoMigrateFromAppData copies existing AppData/OpsLog (or AppData/HamLog) +// data into targetDir the first time the portable layout is used (i.e. when +// targetDir has no database yet). Returns true when a migration was performed. +func autoMigrateFromAppData(targetDir string) (bool, error) { + // Already have a database — nothing to migrate. + if fileExists(filepath.Join(targetDir, "opslog.db")) || + fileExists(filepath.Join(targetDir, "hamlog.db")) { + return false, nil + } base, err := os.UserConfigDir() if err != nil { - return "", err + return false, nil } - newDir := filepath.Join(base, "OpsLog") - oldDir := filepath.Join(base, "HamLog") - if _, err := os.Stat(newDir); os.IsNotExist(err) { - if _, err := os.Stat(oldDir); err == nil { - // One-shot migration: HamLog → OpsLog. Best-effort: on - // failure we fall through and create OpsLog fresh. - _ = os.Rename(oldDir, newDir) + var srcDir string + for _, name := range []string{"OpsLog", "HamLog"} { + d := filepath.Join(base, name) + if _, err := os.Stat(d); err == nil { + srcDir = d + break } } - return newDir, nil + if srcDir == "" { + return false, nil // fresh install — no AppData to migrate + } + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return false, err + } + return true, copyDirContents(srcDir, targetDir) +} + +// fileExists reports whether path exists and is a regular file. +func fileExists(path string) bool { + fi, err := os.Stat(path) + return err == nil && fi.Mode().IsRegular() +} + +// GetDataDir returns the current data directory path. +func (a *App) GetDataDir() string { return a.dataDir } + +// copyDirContents recursively copies all files and subdirectories from src to dst. +func copyDirContents(src, dst string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, e := range entries { + srcPath := filepath.Join(src, e.Name()) + dstPath := filepath.Join(dst, e.Name()) + if e.IsDir() { + if err := os.MkdirAll(dstPath, 0o755); err != nil { + return err + } + if err := copyDirContents(srcPath, dstPath); err != nil { + return err + } + } else { + if err := copyFileData(srcPath, dstPath); err != nil { + return err + } + } + } + return nil +} + +// copyFileData copies a single file from src to dst, creating or overwriting dst. +func copyFileData(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err } // ── Database location (config.json pointer) ──────────────────────────── @@ -1569,18 +1652,36 @@ func (a *App) SavePOTAToken(token string) error { return a.settings.Set(a.ctx, keyExtPotaToken, strings.TrimSpace(token)) } +// POTAUnmatched is one hunter-log entry that found no local QSO, with a reason +// and (when a near-match exists) the id of the candidate QSO so the UI can open +// it for correction. +type POTAUnmatched struct { + Activator string `json:"activator"` + Date string `json:"date"` + Band string `json:"band"` + Reference string `json:"reference"` + Reason string `json:"reason"` + QSOID int64 `json:"qso_id"` // 0 = no candidate to open +} + // POTASyncResult summarises a hunter-log sync run for the UI. type POTASyncResult struct { - Fetched int `json:"fetched"` // hunter-log entries downloaded - Updated int `json:"updated"` // QSOs newly stamped with a park ref - AlreadyTagged int `json:"already_tagged"` // matched but already had a pota_ref - Unmatched int `json:"unmatched"` // no local QSO matched + Fetched int `json:"fetched"` // hunter-log entries downloaded + Updated int `json:"updated"` // QSOs stamped/appended with a park ref + AlreadyTagged int `json:"already_tagged"` // already carried the park + Added int `json:"added"` // new QSOs inserted (addMissing) + Unmatched int `json:"unmatched"` // no local QSO and not added + UnmatchedList []POTAUnmatched `json:"unmatched_list"` // per-entry detail (capped) } // SyncPOTAHunterLog downloads the user's POTA hunter log and stamps pota_ref on -// matching local QSOs (same callsign + band within ±5 min), filling only QSOs -// that don't already carry a park reference. -func (a *App) SyncPOTAHunterLog() (POTASyncResult, error) { +// matching local QSOs. Matching is by callsign + band only — time skew between +// the activator's log and yours is ignored (we just need the park reference); +// when several QSOs share a call+band, the closest in time is used. n-fer parks +// (same QSO at several parks, logged within minutes) are appended. +// When addMissing is true, hunter-log entries whose callsign isn't in the log +// at all are inserted as new QSOs (callsign/date/band/mode/park, cty.dat-enriched). +func (a *App) SyncPOTAHunterLog(addMissing bool) (POTASyncResult, error) { if a.qso == nil || a.settings == nil { return POTASyncResult{}, fmt.Errorf("db not initialized") } @@ -1596,58 +1697,209 @@ func (a *App) SyncPOTAHunterLog() (POTASyncResult, error) { }); err != nil { return POTASyncResult{}, err } - idx := map[string][]int{} + idx := map[string][]int{} // baseCall|band → QSO indices + byCall := map[string][]int{} // baseCall → QSO indices for i := range all { idx[potaMatchKey(all[i].Callsign, all[i].Band)] = append(idx[potaMatchKey(all[i].Callsign, all[i].Band)], i) + bc := pota.BaseCall(all[i].Callsign) + byCall[bc] = append(byCall[bc], i) } - const window = 5 * time.Minute + const nferWindow = 15 * time.Minute // append a 2nd park only for the same physical QSO + const maxDetail = 300 res := POTASyncResult{Fetched: len(entries)} toUpdate := map[int]struct{}{} + var toAdd []pota.HunterQSO + addUnmatched := func(e pota.HunterQSO, reason string, qsoID int64) { + res.Unmatched++ + if len(res.UnmatchedList) < maxDetail { + d := "" + if !e.Date.IsZero() { + d = e.Date.Format("2006-01-02 15:04") + } + res.UnmatchedList = append(res.UnmatchedList, POTAUnmatched{ + Activator: e.Worked, Date: d, Band: e.Band, Reference: e.Reference, Reason: reason, QSOID: qsoID, + }) + } + } + for _, e := range entries { if e.Date.IsZero() { - res.Unmatched++ + addUnmatched(e, "POTA entry has no usable date", 0) continue } - best, bestEmpty, found := -1, false, false - var bestDiff time.Duration - for _, i := range idx[potaMatchKey(e.Worked, e.Band)] { + cands := idx[potaMatchKey(e.Worked, e.Band)] + // Already covered? (any same call+band QSO carries this park) + covered := false + for _, i := range cands { + if potaRefHas(all[i].POTARef, e.Reference) { + covered = true + break + } + } + // Closest empty + closest non-empty (any time skew — we only need the ref). + emptyBest, nonEmptyBest := -1, -1 + var emptyDiff, nonEmptyDiff time.Duration + for _, i := range cands { diff := all[i].QSODate.Sub(e.Date) if diff < 0 { diff = -diff } - if diff > window { - continue - } - found = true - if best < 0 || diff < bestDiff { - best, bestDiff, bestEmpty = i, diff, all[i].POTARef == "" + if all[i].POTARef == "" { + if emptyBest < 0 || diff < emptyDiff { + emptyBest, emptyDiff = i, diff + } + } else if nonEmptyBest < 0 || diff < nonEmptyDiff { + nonEmptyBest, nonEmptyDiff = i, diff } } switch { - case !found: - res.Unmatched++ - case !bestEmpty: + case covered: res.AlreadyTagged++ - default: - all[best].POTARef = e.Reference // also prevents re-using this QSO - toUpdate[best] = struct{}{} + case emptyBest >= 0: + all[emptyBest].POTARef = e.Reference // stamp regardless of time skew + toUpdate[emptyBest] = struct{}{} res.Updated++ + case nonEmptyBest >= 0 && nonEmptyDiff <= nferWindow: + // n-fer: same physical QSO at another park. + all[nonEmptyBest].POTARef += "," + e.Reference + toUpdate[nonEmptyBest] = struct{}{} + res.Updated++ + case len(byCall[pota.BaseCall(e.Worked)]) == 0 && addMissing: + toAdd = append(toAdd, e) + default: + reason, candidate := potaUnmatchReason(e, idx, byCall, all) + addUnmatched(e, reason, candidate) } } + for i := range toUpdate { _ = a.qso.Update(a.ctx, all[i]) } - applog.Printf("pota: hunter-log sync — %d fetched, %d updated, %d already, %d unmatched", - res.Fetched, res.Updated, res.AlreadyTagged, res.Unmatched) + if len(toAdd) > 0 { + res.Added = a.insertPOTAQSOs(toAdd) + } + applog.Printf("pota: hunter-log sync — %d fetched, %d updated, %d already, %d added, %d unmatched", + res.Fetched, res.Updated, res.AlreadyTagged, res.Added, res.Unmatched) return res, nil } +// insertPOTAQSOs inserts hunter-log entries that aren't in the log as new QSOs, +// grouping n-fer entries (same call+band+minute) into one QSO with several +// parks. Country/DXCC/zones are filled from cty.dat. Returns how many inserted. +func (a *App) insertPOTAQSOs(entries []pota.HunterQSO) int { + type group struct { + e pota.HunterQSO + parks []string + } + groups := map[string]*group{} + var order []string + for _, e := range entries { + key := pota.BaseCall(e.Worked) + "|" + e.Band + "|" + e.Date.Format("2006-01-02T15:04") + g := groups[key] + if g == nil { + g = &group{e: e} + groups[key] = g + order = append(order, key) + } + already := false + for _, p := range g.parks { + if p == e.Reference { + already = true + } + } + if !already { + g.parks = append(g.parks, e.Reference) + } + } + added := 0 + for _, key := range order { + g := groups[key] + q := qso.QSO{ + Callsign: g.e.Worked, + QSODate: g.e.Date, + Band: g.e.Band, + Mode: g.e.Mode, + POTARef: strings.Join(g.parks, ","), + Comment: "Added from POTA hunter log", + } + a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat + if _, err := a.qso.Add(a.ctx, q); err == nil { + added++ + } + } + return added +} + // potaMatchKey indexes a QSO by base callsign + band for hunter-log matching. func potaMatchKey(call, band string) string { return pota.BaseCall(call) + "|" + strings.ToLower(strings.TrimSpace(band)) } +// potaRefHas reports whether a (possibly comma-separated) pota_ref already +// contains the given park reference. +func potaRefHas(existing, ref string) bool { + ref = strings.ToUpper(strings.TrimSpace(ref)) + for _, p := range strings.Split(existing, ",") { + if strings.ToUpper(strings.TrimSpace(p)) == ref { + return true + } + } + return false +} + +// potaUnmatchReason explains why a hunter-log entry matched no local QSO and, +// when a near-match exists (right band but wrong time, or right call on another +// band), returns the candidate QSO id so the UI can open it for correction. +func potaUnmatchReason(e pota.HunterQSO, idx, byCall map[string][]int, all []qso.QSO) (string, int64) { + closest := func(cands []int) (int, time.Duration) { + best, bestDiff := -1, time.Duration(1<<62-1) + for _, i := range cands { + d := all[i].QSODate.Sub(e.Date) + if d < 0 { + d = -d + } + if d < bestDiff { + best, bestDiff = i, d + } + } + return best, bestDiff + } + if sameBand := idx[potaMatchKey(e.Worked, e.Band)]; len(sameBand) > 0 { + // Same call+band exists but every QSO was outside the ±5 min window. + i, diff := closest(sameBand) + return fmt.Sprintf("same call+band logged, but closest is Δ%s away", roundDur(diff)), all[i].ID + } + others := byCall[pota.BaseCall(e.Worked)] + if len(others) == 0 { + return "this callsign isn't in your log", 0 + } + bands := map[string]struct{}{} + for _, i := range others { + if b := strings.ToLower(strings.TrimSpace(all[i].Band)); b != "" { + bands[b] = struct{}{} + } + } + list := make([]string, 0, len(bands)) + for b := range bands { + list = append(list, b) + } + sort.Strings(list) + i, _ := closest(others) + return fmt.Sprintf("logged on %s, not %s", strings.Join(list, "/"), e.Band), all[i].ID +} + +// roundDur renders a duration compactly (e.g. "3m", "2h5m", "45s"). +func roundDur(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + return d.Round(time.Minute).String() +} + // AwardStatRow is one row of the award statistics matrix (e.g. "CONFIRMED CW"): // distinct-reference counts per band, plus Total (distinct on any band) and // GrandTotal (sum of the per-band band-slots). @@ -2248,6 +2500,56 @@ func (a *App) DeleteQSO(id int64) error { return a.qso.Delete(a.ctx, id) } +// QSLBulkUpdate carries the paper-QSL fields to apply to a selection. An empty +// string leaves that field unchanged (so you can set only "received = Y + date" +// without touching the sent side). +type QSLBulkUpdate struct { + SentStatus string `json:"sent_status"` // Y|N|R|I — "" = unchanged + RcvdStatus string `json:"rcvd_status"` // Y|N|R|I — "" = unchanged + SentDate string `json:"sent_date"` // YYYYMMDD — "" = unchanged + RcvdDate string `json:"rcvd_date"` // YYYYMMDD — "" = unchanged + Via string `json:"via"` // QSL_VIA — "" = unchanged +} + +// BulkUpdateQSL applies paper-QSL sent/received status, dates and via to the +// given QSOs (used by the QSL Manager "Paper QSL" mode to confirm a stack of +// cards for one callsign at once). Returns how many rows were updated. +func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) { + if a.qso == nil { + return 0, fmt.Errorf("db not initialized") + } + up := func(s string) string { return strings.ToUpper(strings.TrimSpace(s)) } + n := 0 + for _, id := range ids { + q, err := a.qso.GetByID(a.ctx, id) + if err != nil { + continue + } + changed := false + if v := up(u.SentStatus); v != "" { + q.QSLSent, changed = v, true + } + if v := up(u.RcvdStatus); v != "" { + q.QSLRcvd, changed = v, true + } + if v := strings.TrimSpace(u.SentDate); v != "" { + q.QSLSentDate, changed = v, true + } + if v := strings.TrimSpace(u.RcvdDate); v != "" { + q.QSLRcvdDate, changed = v, true + } + if v := strings.TrimSpace(u.Via); v != "" { + q.QSLVia, changed = v, true + } + if changed { + if a.qso.Update(a.ctx, q) == nil { + n++ + } + } + } + return n, nil +} + // WorkedBefore returns prior contacts with the given callsign at both // call and DXCC granularity. Pass dxccHint=0 when unknown — the function // will infer it from past QSOs with the same call when possible. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1d88657..7cd75e1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -421,6 +421,7 @@ export default function App() { const [qsos, setQsos] = useState([]); const [total, setTotal] = useState(0); const [error, setError] = useState(''); + const [migratedBanner, setMigratedBanner] = useState(false); // Transient success toast (bottom-right, auto-dismiss). Used for things // like "spot sent" where a blocking error banner would be overkill. const [toast, setToast] = useState(''); @@ -818,6 +819,7 @@ export default function App() { try { const st = await GetStartupStatus(); if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; } + if ((st as any).migrated_from_app_data) setMigratedBanner(true); } catch {} loadStation(); loadLists(); @@ -1994,6 +1996,16 @@ export default function App() { {/* Transient toasts (bottom-right). Errors stack on top of the green success toast; both auto-dismiss. */} + {migratedBanner && ( +
+ + Migration complete. Your data has been copied to the data folder next to OpsLog.exe. + Please restart OpsLog to use the new location. + + +
+ )} + {(error || toast) && (
{error && ( @@ -2680,7 +2692,7 @@ export default function App() { updating) while you work on other tabs. */} {qslTabOpen && ( - + )} diff --git a/frontend/src/components/AwardRefSelector.tsx b/frontend/src/components/AwardRefSelector.tsx index 4a19d39..6fec24a 100644 --- a/frontend/src/components/AwardRefSelector.tsx +++ b/frontend/src/components/AwardRefSelector.tsx @@ -13,6 +13,9 @@ type Meta = { code: string; count: number; can_update: boolean }; // 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']); +// If DXCC-filtered auto-results exceed this, require the user to type instead. +const AUTO_SHOW_MAX = 100; + interface Props { dxcc?: number; // Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064" @@ -26,7 +29,11 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) { const [awardCode, setAwardCode] = useState('POTA'); const [q, setQ] = useState(''); - const [results, setResults] = useState([]); + // autoResults: loaded immediately when award/dxcc changes (empty query, DXCC-filtered). + // Shown when q is short and count ≤ AUTO_SHOW_MAX (e.g. 5 IOTA refs for France). + const [autoResults, setAutoResults] = useState([]); + // searchResults: loaded when user types 2+ chars. + const [searchResults, setSearchResults] = useState([]); const [busy, setBusy] = useState(false); const [selectedRef, setSelectedRef] = useState(null); const [selectedEntry, setSelectedEntry] = useState(null); @@ -65,20 +72,34 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) { if (!awards.find((a) => a.code === awardCode)) setAwardCode(awards[0].code); }, [awards, awardCode]); + // Auto-load DXCC-filtered refs on award/dxcc change with empty query. + // Fetches AUTO_SHOW_MAX+1 so we can distinguish "all results shown" from "too many". useEffect(() => { - if (q.length < 2) { setResults([]); return; } + setAutoResults([]); + if (!dxcc) return; + SearchAwardReferences(awardCode, '', dxcc, AUTO_SHOW_MAX + 1) + .then((r) => setAutoResults((r ?? []) as any)) + .catch(() => {}); + }, [awardCode, dxcc]); + + // Typed search (2+ chars). + useEffect(() => { + if (q.length < 2) { setSearchResults([]); return; } const t = window.setTimeout(async () => { setBusy(true); try { - // References are always scoped to the contacted DXCC entity. const r = await SearchAwardReferences(awardCode, q, dxcc ?? 0, 50); - setResults((r ?? []) as any); - } catch { setResults([]); } + setSearchResults((r ?? []) as any); + } catch { setSearchResults([]); } finally { setBusy(false); } }, 200); return () => window.clearTimeout(t); }, [awardCode, q, dxcc]); + const tooManyAuto = autoResults.length > AUTO_SHOW_MAX; + // When q is empty/short: show auto-results (if ≤ AUTO_SHOW_MAX); when typing: show search results. + const results: AwardRef[] = q.length >= 2 ? searchResults : (tooManyAuto ? [] : autoResults); + function addRef(ref: AwardRef) { const entry = `${awardCode}@${ref.code}`; if (!entries.includes(entry)) { @@ -99,7 +120,7 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
-
- - -
- + {service === 'pota' ? ( + <> + + + Token in Settings → External services → POTA. + + ) : service === 'paper' ? ( + <> +
+ + setPaperCall(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }} + placeholder="e.g. DL1ABC" /> +
+ + Find a callsign, then set QSL sent/received + via + date on the selection. + + ) : ( + <> +
+ + +
+ + + )}
+ {service === 'pota' && potaRes && ( + + {potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched / {potaRes.fetched} + + )} + {service === 'paper' && paperRows.length > 0 && ( + {paperRows.length} QSO · {paperSel.size} selected + )} {!showLog && viewMode === 'confirmations' && (
@@ -192,7 +322,81 @@ export function QSLManagerPanel() {
{error &&
{error}
} - {showLog ? ( + {service === 'paper' ? ( + paperRows.length === 0 ? ( +
Search a callsign to list its QSOs, then set QSL status below.
+ ) : ( + + + + + + + + + + + {paperRows.map((r) => ( + togglePaper(r.id)}> + + + + + + + + + + ))} + +
setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} />Date UTCCallsignBandModeQSL SentQSL RcvdVia
e.stopPropagation()}> togglePaper(r.id)} />{fmtDate(r.qso_date)}{r.callsign}{r.band}{r.mode}{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}{r.qsl_via}
+ ) + ) : service === 'pota' ? ( +
+ {potaErr &&
{potaErr}
} + {!potaRes && !potaErr && !potaSyncing && ( +
Click “Sync hunter log” to fetch your pota.app log and stamp park references.
+ )} + {potaSyncing &&
Syncing with pota.app…
} + {potaRes && ( + <> +
+ {potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries). Rescan the POTA award to count the new references. +
+ {potaRes.unmatched_list?.length > 0 && ( + + + + + + + + + + {potaRes.unmatched_list.map((u, i) => ( + 0 && 'cursor-pointer hover:bg-accent/30')} + onClick={() => u.qso_id > 0 && onEditQSO?.(u.qso_id)} + title={u.qso_id > 0 ? 'Open this QSO to fix it' : ''}> + + + + + + + ))} + +
ActivatorDate UTCBandParkWhy unmatched
{u.activator}{u.date}{u.band}{u.reference} + {u.reason} + {u.qso_id > 0 && } +
+ )} + + )} +
+ ) : showLog ? (
{logLines.length === 0 ? (
starting…
@@ -270,7 +474,43 @@ export function QSLManagerPanel() { )}
- {/* Action bar */} + {/* Paper-QSL apply form */} + {service === 'paper' && ( +
+
+ +
+ + setQslRcvdDate(e.target.value)} title="QSL received date" /> +
+
+
+ +
+ + setQslSentDate(e.target.value)} title="QSL sent date" /> +
+
+
+ + setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" /> +
+
+ {paperMsg && {paperMsg}} + +
+ )} + + {/* Action bar (upload/download — not for POTA / Paper QSL) */} + {service !== 'pota' && service !== 'paper' && (
+ )}
); } diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index 94d3f00..91170b5 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -74,8 +74,12 @@ function fmtDateUTC(s: any): string { } function fmtDateOnly(s: any): string { if (!s) return ''; - const d = new Date(s); - if (isNaN(d.getTime())) return s; + const t = String(s).trim(); + // QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO. + const m = t.match(/^(\d{4})(\d{2})(\d{2})/); + if (m) return `${m[1]}-${m[2]}-${m[3]}`; + const d = new Date(t); + if (isNaN(d.getTime())) return t; const p = (n: number) => String(n).padStart(2, '0'); return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; } diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 04410cd..8eda91b 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -22,9 +22,10 @@ import { ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase, + GetDataDir, GetQSLDefaults, SaveQSLDefaults, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, - GetPOTAToken, SavePOTAToken, SyncPOTAHunterLog, + GetPOTAToken, SavePOTAToken, TestLoTWUpload, ListTQSLStationLocations, ComputeStationInfo, } from '../../wailsjs/go/main/App'; @@ -486,7 +487,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { // POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log). const [potaToken, setPotaToken] = useState(''); const [potaBusy, setPotaBusy] = useState(false); - const [potaResult, setPotaResult] = useState<{ ok: boolean; msg: string } | null>(null); + const [potaResult, setPotaResult] = useState<{ ok: boolean; msg: string; unmatched?: any[] } | null>(null); useEffect(() => { GetPOTAToken().then((t) => setPotaToken(t || '')).catch(() => {}); }, []); const [backupCfg, setBackupCfg] = useState({ @@ -498,6 +499,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false }); const [dbMsg, setDbMsg] = useState(''); + const [dataDir, setDataDir] = useState(''); const [clusterServers, setClusterServers] = useState([]); const [clusterAutoConnect, setClusterAutoConnectState] = useState(false); @@ -585,6 +587,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { setQslDefaults(qd as any); setExtSvc(es as any); try { setDbSettings(await GetDatabaseSettings() as any); } catch {} + try { setDataDir(await GetDataDir()); } catch {} try { const locs: any = await ListTQSLStationLocations(); setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean)); @@ -2132,13 +2135,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { { k: 'pota', label: 'POTA', ready: true }, ]; - async function syncPota() { + async function savePotaToken() { setPotaBusy(true); setPotaResult(null); try { await SavePOTAToken(potaToken); - const r: any = await SyncPOTAHunterLog(); - setPotaResult({ ok: true, msg: `${r.updated} QSO updated · ${r.already_tagged} already tagged · ${r.unmatched} unmatched (of ${r.fetched} hunter-log entries).` }); + setPotaResult({ ok: true, msg: 'Token saved. Run the sync from the QSL Manager → POTA hunter log.' }); } catch (e: any) { setPotaResult({ ok: false, msg: String(e?.message ?? e) }); } finally { @@ -2476,10 +2478,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { />
- - Matches by callsign + band within ±5 min. Only fills QSOs without a POTA ref. + + Then run the sync from the QSL Manager tab → service POTA hunter log (you can see and fix unmatched QSOs there). +
{potaResult && (
)} -

After a sync, rescan the POTA award to see the new references counted.

) : (
@@ -2578,6 +2581,22 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { )}
+ {/* Data location */} +
+
+
Data location
+
+ OpsLog is fully portable — all data lives next to the executable so you can run it from a USB stick or reinstall Windows without losing anything. +
+
+
+ +
+ {dataDir || '—'} +
+
+
+ {/* Backup settings, merged into this Database section. */}
{BackupPanel()} diff --git a/frontend/src/lib/awardRefs.ts b/frontend/src/lib/awardRefs.ts index 55463ad..5a30159 100644 --- a/frontend/src/lib/awardRefs.ts +++ b/frontend/src/lib/awardRefs.ts @@ -92,7 +92,11 @@ export function buildAwardRefs(qso: any, pickable: Array<{ code: string; field: const out: string[] = []; for (const { code, field } of pickable) { const v = awardRefValue(qso, code, field); - if (v) out.push(`${code.toUpperCase()}@${v}`); + // A multi-reference field (n-fer POTA "US-6544,US-0680") becomes one + // editor entry per reference, so each shows on its own removable line. + for (const ref of v.split(/[,;]/).map((s) => s.trim()).filter(Boolean)) { + out.push(`${code.toUpperCase()}@${ref}`); + } } return out.join(';'); } diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 394d585..4294430 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -25,6 +25,8 @@ export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise>; +export function BulkUpdateQSL(arg1:Array,arg2:main.QSLBulkUpdate):Promise; + export function ClearLookupCache():Promise; export function ClusterSpotStatuses(arg1:Array):Promise>; @@ -75,6 +77,8 @@ export function DeleteQSO(arg1:number):Promise; export function DeleteUDPIntegration(arg1:number):Promise; +export function DisablePortableMode():Promise; + export function DisconnectAllClusters():Promise; export function DisconnectClusterServer(arg1:number):Promise; @@ -85,6 +89,8 @@ export function DownloadConfirmations(arg1:string,arg2:boolean):Promise; export function DuplicateProfile(arg1:number,arg2:string):Promise; +export function EnablePortableMode():Promise; + export function ExportADIF(arg1:string,arg2:boolean):Promise; export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise; @@ -129,6 +135,8 @@ export function GetDVKMessages():Promise>; export function GetDVKStatus():Promise; +export function GetDataDir():Promise; + export function GetDatabaseSettings():Promise; export function GetEmailSettings():Promise; @@ -167,6 +175,8 @@ export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise; +export function IsPortableMode():Promise; + export function ListAudioInputDevices():Promise>; export function ListAudioOutputDevices():Promise>; @@ -305,7 +315,7 @@ export function SetUIPref(arg1:string,arg2:string):Promise; export function SwitchCATRig(arg1:number):Promise; -export function SyncPOTAHunterLog():Promise; +export function SyncPOTAHunterLog(arg1:boolean):Promise; export function TestClublogUpload():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 2064837..ceca01f 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -22,6 +22,10 @@ export function AwardFields() { return window['go']['main']['App']['AwardFields'](); } +export function BulkUpdateQSL(arg1, arg2) { + return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2); +} + export function ClearLookupCache() { return window['go']['main']['App']['ClearLookupCache'](); } @@ -122,6 +126,10 @@ export function DeleteUDPIntegration(arg1) { return window['go']['main']['App']['DeleteUDPIntegration'](arg1); } +export function DisablePortableMode() { + return window['go']['main']['App']['DisablePortableMode'](); +} + export function DisconnectAllClusters() { return window['go']['main']['App']['DisconnectAllClusters'](); } @@ -142,6 +150,10 @@ export function 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) { return window['go']['main']['App']['ExportADIF'](arg1, arg2); } @@ -230,6 +242,10 @@ export function GetDVKStatus() { return window['go']['main']['App']['GetDVKStatus'](); } +export function GetDataDir() { + return window['go']['main']['App']['GetDataDir'](); +} + export function GetDatabaseSettings() { return window['go']['main']['App']['GetDatabaseSettings'](); } @@ -306,6 +322,10 @@ export function ImportAwardReferencesText(arg1, arg2) { return window['go']['main']['App']['ImportAwardReferencesText'](arg1, arg2); } +export function IsPortableMode() { + return window['go']['main']['App']['IsPortableMode'](); +} + export function ListAudioInputDevices() { return window['go']['main']['App']['ListAudioInputDevices'](); } @@ -582,8 +602,8 @@ export function SwitchCATRig(arg1) { return window['go']['main']['App']['SwitchCATRig'](arg1); } -export function SyncPOTAHunterLog() { - return window['go']['main']['App']['SyncPOTAHunterLog'](); +export function SyncPOTAHunterLog(arg1) { + return window['go']['main']['App']['SyncPOTAHunterLog'](arg1); } export function TestClublogUpload() { diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index ff166bd..4608619 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -973,11 +973,35 @@ export namespace main { } } + export class POTAUnmatched { + activator: string; + date: string; + band: string; + reference: string; + reason: string; + qso_id: number; + + static createFrom(source: any = {}) { + return new POTAUnmatched(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.activator = source["activator"]; + this.date = source["date"]; + this.band = source["band"]; + this.reference = source["reference"]; + this.reason = source["reason"]; + this.qso_id = source["qso_id"]; + } + } export class POTASyncResult { fetched: number; updated: number; already_tagged: number; + added: number; unmatched: number; + unmatched_list: POTAUnmatched[]; static createFrom(source: any = {}) { return new POTASyncResult(source); @@ -988,7 +1012,48 @@ export namespace main { this.fetched = source["fetched"]; this.updated = source["updated"]; this.already_tagged = source["already_tagged"]; + this.added = source["added"]; this.unmatched = source["unmatched"]; + this.unmatched_list = this.convertValues(source["unmatched_list"], POTAUnmatched); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + + export class QSLBulkUpdate { + sent_status: string; + rcvd_status: string; + sent_date: string; + rcvd_date: string; + via: string; + + static createFrom(source: any = {}) { + return new QSLBulkUpdate(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.sent_status = source["sent_status"]; + this.rcvd_status = source["rcvd_status"]; + this.sent_date = source["sent_date"]; + this.rcvd_date = source["rcvd_date"]; + this.via = source["via"]; } } export class QSLDefaults { diff --git a/internal/award/award.go b/internal/award/award.go index 91f0627..598f275 100644 --- a/internal/award/award.go +++ b/internal/award/award.go @@ -449,8 +449,10 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) } } default: - // Whole field value is the candidate. - found = []string{normalizeRef(raw)} + // Whole field value is the candidate, split on comma/semicolon so a + // multi-reference field (e.g. an n-fer POTA QSO "US-6544,US-0680") + // counts each reference separately. + found = splitRefs(raw) } if !predefined { @@ -547,6 +549,22 @@ func stripAffix(s, lead, trail string) string { func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) } +// splitRefs splits a field value on comma/semicolon into normalized references, +// so a multi-reference field (n-fer POTA "US-6544,US-0680") yields one entry +// per reference. A value with no separator yields a single reference. +func splitRefs(raw string) []string { + if !strings.ContainsAny(raw, ",;") { + return []string{normalizeRef(raw)} + } + var out []string + for _, p := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' }) { + if n := normalizeRef(p); n != "" { + out = append(out, n) + } + } + return dedupe(out) +} + func isDigit(b byte) bool { return b >= '0' && b <= '9' } // natLess is a natural ("human") comparison: digit runs compare as numbers, so diff --git a/internal/award/award_test.go b/internal/award/award_test.go index db6c5e7..d650c5c 100644 --- a/internal/award/award_test.go +++ b/internal/award/award_test.go @@ -83,6 +83,19 @@ func TestNatLess(t *testing.T) { } } +// A multi-reference field (n-fer POTA) counts each park separately. +func TestComputeMultiRef(t *testing.T) { + def := Def{Code: "POTA", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: []string{"lotw", "qsl"}, Valid: true} + qsos := []qso.QSO{ + {Callsign: "W2QMI", Band: "20m", POTARef: "US-6544,US-0680", LOTWRcvd: "Y"}, + {Callsign: "K1ABC", Band: "40m", POTARef: "US-0680"}, // shared park + } + r := Compute([]Def{def}, qsos, nil, nil)[0] + if r.Worked != 2 { // distinct parks: US-6544, US-0680 + t.Errorf("POTA worked = %d, want 2 (%v)", r.Worked, refCodes(r)) + } +} + func refCodes(r Result) []string { out := make([]string, 0, len(r.Refs)) for _, rf := range r.Refs { diff --git a/internal/awardref/importers.go b/internal/awardref/importers.go index d63ac48..6572568 100644 --- a/internal/awardref/importers.go +++ b/internal/awardref/importers.go @@ -3,6 +3,7 @@ package awardref import ( "context" "encoding/csv" + "encoding/json" "fmt" "io" "net/http" @@ -24,6 +25,38 @@ var Importers = map[string]Importer{ "POTA": {AwardCode: "POTA", URL: "https://pota.app/all_parks.csv", Fetch: parsePOTA}, "SOTA": {AwardCode: "SOTA", URL: "https://www.sotadata.org.uk/summitslist.csv", Fetch: parseSOTA}, "WWFF": {AwardCode: "WWFF", URL: "https://wwff.co/wwff-data/wwff_directory.csv", Fetch: parseWWFF}, + "IOTA": {AwardCode: "IOTA", URL: "https://www.iota-world.org/islands-on-the-air/downloads/download-file.html?path=groups.json", Fetch: parseIOTA}, +} + +// parseIOTA reads iota-world.org's groups.json (refreshed daily): an array of +// {refno, name, dxcc_num, grp_region}. The reference is the IOTA number +// (EU-005); the DXCC number lets the per-QSO picker filter by entity. +func parseIOTA(_ context.Context, body io.Reader) ([]Ref, error) { + var groups []struct { + RefNo string `json:"refno"` + Name string `json:"name"` + DXCC string `json:"dxcc_num"` + Region string `json:"grp_region"` + } + if err := json.NewDecoder(body).Decode(&groups); err != nil { + return nil, fmt.Errorf("parse IOTA json: %w", err) + } + out := make([]Ref, 0, len(groups)) + for _, g := range groups { + ref := strings.ToUpper(strings.TrimSpace(g.RefNo)) + if ref == "" { + continue + } + dxcc, _ := strconv.Atoi(strings.TrimSpace(g.DXCC)) + grp := strings.TrimSpace(g.Region) + if grp == "" { // fall back to the continent prefix (AF/EU/NA/…) + if i := strings.IndexByte(ref, '-'); i > 0 { + grp = ref[:i] + } + } + out = append(out, Ref{Code: ref, Name: strings.TrimSpace(g.Name), DXCC: dxcc, Group: grp}) + } + return out, nil } // CanUpdate reports whether an award has an online reference list.