up
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -349,9 +350,10 @@ type App struct {
|
|||||||
pttMu sync.Mutex
|
pttMu sync.Mutex
|
||||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||||
startupErr string // captured for surfacing to the frontend
|
startupErr string // captured for surfacing to the frontend
|
||||||
dbPath string // active database file (may be a user-chosen location)
|
dbPath string // active database file (may be a user-chosen location)
|
||||||
dataDir string // %APPDATA%/OpsLog — holds config.json, logs, cty.dat
|
dataDir string // <exeDir>/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
|
// shuttingDown gates beforeClose re-entry: the first user attempt to
|
||||||
// close fires shutdown tasks (backup, future LoTW upload, ...) while
|
// 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)
|
fmt.Println("OpsLog:", a.startupErr)
|
||||||
return
|
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 {
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||||
a.startupErr = "cannot create data dir: " + err.Error()
|
a.startupErr = "cannot create data dir: " + err.Error()
|
||||||
fmt.Println("OpsLog:", a.startupErr)
|
fmt.Println("OpsLog:", a.startupErr)
|
||||||
@@ -676,18 +687,20 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// StartupStatus returns a diagnostic snapshot for the frontend.
|
// StartupStatus returns a diagnostic snapshot for the frontend.
|
||||||
// dbPath is always populated; err is empty when the app is healthy.
|
// dbPath is always populated; err is empty when the app is healthy.
|
||||||
type StartupStatus struct {
|
type StartupStatus struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Err string `json:"err"`
|
Err string `json:"err"`
|
||||||
DBPath string `json:"db_path"`
|
DBPath string `json:"db_path"`
|
||||||
|
MigratedFromAppData bool `json:"migrated_from_app_data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStartupStatus exposes whatever happened during startup so the UI
|
// GetStartupStatus exposes whatever happened during startup so the UI
|
||||||
// can show a useful error instead of just "db not initialized".
|
// can show a useful error instead of just "db not initialized".
|
||||||
func (a *App) GetStartupStatus() StartupStatus {
|
func (a *App) GetStartupStatus() StartupStatus {
|
||||||
return StartupStatus{
|
return StartupStatus{
|
||||||
OK: a.startupErr == "",
|
OK: a.startupErr == "",
|
||||||
Err: a.startupErr,
|
Err: a.startupErr,
|
||||||
DBPath: a.dbPath,
|
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
|
// userDataDir returns the OpsLog data directory: always "<exe dir>/data".
|
||||||
// dir. The app was previously called HamLog — if the old folder exists
|
// All data (database, settings, cty.dat, logs) travels with the executable,
|
||||||
// and the new one doesn't, we rename it atomically so the user keeps
|
// making OpsLog fully portable for USB sticks and PC migrations.
|
||||||
// their database, settings and cluster history through the rebrand.
|
|
||||||
func userDataDir() (string, error) {
|
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()
|
base, err := os.UserConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return false, nil
|
||||||
}
|
}
|
||||||
newDir := filepath.Join(base, "OpsLog")
|
var srcDir string
|
||||||
oldDir := filepath.Join(base, "HamLog")
|
for _, name := range []string{"OpsLog", "HamLog"} {
|
||||||
if _, err := os.Stat(newDir); os.IsNotExist(err) {
|
d := filepath.Join(base, name)
|
||||||
if _, err := os.Stat(oldDir); err == nil {
|
if _, err := os.Stat(d); err == nil {
|
||||||
// One-shot migration: HamLog → OpsLog. Best-effort: on
|
srcDir = d
|
||||||
// failure we fall through and create OpsLog fresh.
|
break
|
||||||
_ = os.Rename(oldDir, newDir)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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) ────────────────────────────
|
// ── 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))
|
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.
|
// POTASyncResult summarises a hunter-log sync run for the UI.
|
||||||
type POTASyncResult struct {
|
type POTASyncResult struct {
|
||||||
Fetched int `json:"fetched"` // hunter-log entries downloaded
|
Fetched int `json:"fetched"` // hunter-log entries downloaded
|
||||||
Updated int `json:"updated"` // QSOs newly stamped with a park ref
|
Updated int `json:"updated"` // QSOs stamped/appended with a park ref
|
||||||
AlreadyTagged int `json:"already_tagged"` // matched but already had a pota_ref
|
AlreadyTagged int `json:"already_tagged"` // already carried the park
|
||||||
Unmatched int `json:"unmatched"` // no local QSO matched
|
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
|
// 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
|
// matching local QSOs. Matching is by callsign + band only — time skew between
|
||||||
// that don't already carry a park reference.
|
// the activator's log and yours is ignored (we just need the park reference);
|
||||||
func (a *App) SyncPOTAHunterLog() (POTASyncResult, error) {
|
// 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 {
|
if a.qso == nil || a.settings == nil {
|
||||||
return POTASyncResult{}, fmt.Errorf("db not initialized")
|
return POTASyncResult{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
@@ -1596,58 +1697,209 @@ func (a *App) SyncPOTAHunterLog() (POTASyncResult, error) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return POTASyncResult{}, err
|
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 {
|
for i := range all {
|
||||||
idx[potaMatchKey(all[i].Callsign, all[i].Band)] = append(idx[potaMatchKey(all[i].Callsign, all[i].Band)], i)
|
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)}
|
res := POTASyncResult{Fetched: len(entries)}
|
||||||
toUpdate := map[int]struct{}{}
|
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 {
|
for _, e := range entries {
|
||||||
if e.Date.IsZero() {
|
if e.Date.IsZero() {
|
||||||
res.Unmatched++
|
addUnmatched(e, "POTA entry has no usable date", 0)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
best, bestEmpty, found := -1, false, false
|
cands := idx[potaMatchKey(e.Worked, e.Band)]
|
||||||
var bestDiff time.Duration
|
// Already covered? (any same call+band QSO carries this park)
|
||||||
for _, i := range idx[potaMatchKey(e.Worked, e.Band)] {
|
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)
|
diff := all[i].QSODate.Sub(e.Date)
|
||||||
if diff < 0 {
|
if diff < 0 {
|
||||||
diff = -diff
|
diff = -diff
|
||||||
}
|
}
|
||||||
if diff > window {
|
if all[i].POTARef == "" {
|
||||||
continue
|
if emptyBest < 0 || diff < emptyDiff {
|
||||||
}
|
emptyBest, emptyDiff = i, diff
|
||||||
found = true
|
}
|
||||||
if best < 0 || diff < bestDiff {
|
} else if nonEmptyBest < 0 || diff < nonEmptyDiff {
|
||||||
best, bestDiff, bestEmpty = i, diff, all[i].POTARef == ""
|
nonEmptyBest, nonEmptyDiff = i, diff
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case !found:
|
case covered:
|
||||||
res.Unmatched++
|
|
||||||
case !bestEmpty:
|
|
||||||
res.AlreadyTagged++
|
res.AlreadyTagged++
|
||||||
default:
|
case emptyBest >= 0:
|
||||||
all[best].POTARef = e.Reference // also prevents re-using this QSO
|
all[emptyBest].POTARef = e.Reference // stamp regardless of time skew
|
||||||
toUpdate[best] = struct{}{}
|
toUpdate[emptyBest] = struct{}{}
|
||||||
res.Updated++
|
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 {
|
for i := range toUpdate {
|
||||||
_ = a.qso.Update(a.ctx, all[i])
|
_ = a.qso.Update(a.ctx, all[i])
|
||||||
}
|
}
|
||||||
applog.Printf("pota: hunter-log sync — %d fetched, %d updated, %d already, %d unmatched",
|
if len(toAdd) > 0 {
|
||||||
res.Fetched, res.Updated, res.AlreadyTagged, res.Unmatched)
|
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
|
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.
|
// potaMatchKey indexes a QSO by base callsign + band for hunter-log matching.
|
||||||
func potaMatchKey(call, band string) string {
|
func potaMatchKey(call, band string) string {
|
||||||
return pota.BaseCall(call) + "|" + strings.ToLower(strings.TrimSpace(band))
|
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"):
|
// 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
|
// distinct-reference counts per band, plus Total (distinct on any band) and
|
||||||
// GrandTotal (sum of the per-band band-slots).
|
// 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)
|
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
|
// WorkedBefore returns prior contacts with the given callsign at both
|
||||||
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
|
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
|
||||||
// will infer it from past QSOs with the same call when possible.
|
// will infer it from past QSOs with the same call when possible.
|
||||||
|
|||||||
+13
-1
@@ -421,6 +421,7 @@ export default function App() {
|
|||||||
const [qsos, setQsos] = useState<QSO[]>([]);
|
const [qsos, setQsos] = useState<QSO[]>([]);
|
||||||
const [total, setTotal] = useState<number>(0);
|
const [total, setTotal] = useState<number>(0);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [migratedBanner, setMigratedBanner] = useState(false);
|
||||||
// Transient success toast (bottom-right, auto-dismiss). Used for things
|
// Transient success toast (bottom-right, auto-dismiss). Used for things
|
||||||
// like "spot sent" where a blocking error banner would be overkill.
|
// like "spot sent" where a blocking error banner would be overkill.
|
||||||
const [toast, setToast] = useState('');
|
const [toast, setToast] = useState('');
|
||||||
@@ -818,6 +819,7 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const st = await GetStartupStatus();
|
const st = await GetStartupStatus();
|
||||||
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
|
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 {}
|
} catch {}
|
||||||
loadStation();
|
loadStation();
|
||||||
loadLists();
|
loadLists();
|
||||||
@@ -1994,6 +1996,16 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
||||||
success toast; both auto-dismiss. */}
|
success toast; both auto-dismiss. */}
|
||||||
|
{migratedBanner && (
|
||||||
|
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-[110] flex items-start gap-3 rounded-lg border border-emerald-400 bg-emerald-50 text-emerald-900 px-4 py-3 text-sm shadow-xl max-w-lg animate-in fade-in slide-in-from-top-2">
|
||||||
|
<span className="flex-1">
|
||||||
|
<strong>Migration complete.</strong> Your data has been copied to the data folder next to OpsLog.exe.
|
||||||
|
Please <strong>restart OpsLog</strong> to use the new location.
|
||||||
|
</span>
|
||||||
|
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setMigratedBanner(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(error || toast) && (
|
{(error || toast) && (
|
||||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2 max-w-md">
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2 max-w-md">
|
||||||
{error && (
|
{error && (
|
||||||
@@ -2680,7 +2692,7 @@ export default function App() {
|
|||||||
updating) while you work on other tabs. */}
|
updating) while you work on other tabs. */}
|
||||||
{qslTabOpen && (
|
{qslTabOpen && (
|
||||||
<TabsContent value="qsl" forceMount className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
<TabsContent value="qsl" forceMount className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
<QSLManagerPanel />
|
<QSLManagerPanel onEditQSO={openEdit} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// 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']);
|
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 {
|
interface Props {
|
||||||
dxcc?: number;
|
dxcc?: number;
|
||||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064"
|
// 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 [awardCode, setAwardCode] = useState('POTA');
|
||||||
|
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [results, setResults] = useState<AwardRef[]>([]);
|
// 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<AwardRef[]>([]);
|
||||||
|
// searchResults: loaded when user types 2+ chars.
|
||||||
|
const [searchResults, setSearchResults] = useState<AwardRef[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [selectedRef, setSelectedRef] = useState<AwardRef | null>(null);
|
const [selectedRef, setSelectedRef] = useState<AwardRef | null>(null);
|
||||||
const [selectedEntry, setSelectedEntry] = useState<string | null>(null);
|
const [selectedEntry, setSelectedEntry] = useState<string | null>(null);
|
||||||
@@ -65,20 +72,34 @@ 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.
|
||||||
|
// Fetches AUTO_SHOW_MAX+1 so we can distinguish "all results shown" from "too many".
|
||||||
useEffect(() => {
|
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 () => {
|
const t = window.setTimeout(async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
// References are always scoped to the contacted DXCC entity.
|
|
||||||
const r = await SearchAwardReferences(awardCode, q, dxcc ?? 0, 50);
|
const r = await SearchAwardReferences(awardCode, q, dxcc ?? 0, 50);
|
||||||
setResults((r ?? []) as any);
|
setSearchResults((r ?? []) as any);
|
||||||
} catch { setResults([]); }
|
} catch { setSearchResults([]); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}, 200);
|
}, 200);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCode, q, dxcc]);
|
}, [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) {
|
function addRef(ref: AwardRef) {
|
||||||
const entry = `${awardCode}@${ref.code}`;
|
const entry = `${awardCode}@${ref.code}`;
|
||||||
if (!entries.includes(entry)) {
|
if (!entries.includes(entry)) {
|
||||||
@@ -99,7 +120,7 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
|
|||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={awardCode}
|
value={awardCode}
|
||||||
onValueChange={(v) => { setAwardCode(v); setSelectedRef(null); setQ(''); setResults([]); }}
|
onValueChange={(v) => { setAwardCode(v); setSelectedRef(null); setQ(''); setSearchResults([]); }}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs">
|
<SelectTrigger className="h-7 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -191,11 +212,25 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
|
|||||||
<Loader2 className="size-3 animate-spin" />Searching…
|
<Loader2 className="size-3 animate-spin" />Searching…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!busy && q.length < 2 && (
|
{/* No callsign yet */}
|
||||||
|
{!busy && !dxcc && q.length < 2 && (
|
||||||
|
<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 */}
|
||||||
|
{!busy && !!dxcc && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||||
|
No references for this entity.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Typed search, no results */}
|
||||||
{!busy && q.length >= 2 && results.length === 0 && (
|
{!busy && q.length >= 2 && results.length === 0 && (
|
||||||
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
|
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
|
||||||
No results.
|
No results.
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks } from 'lucide-react';
|
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations } from '../../wailsjs/go/main/App';
|
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL } from '../../wailsjs/go/main/App';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
|
||||||
type UploadRow = {
|
type UploadRow = {
|
||||||
@@ -23,8 +24,26 @@ const SERVICES = [
|
|||||||
{ v: 'qrz', label: 'QRZ.com' },
|
{ v: 'qrz', label: 'QRZ.com' },
|
||||||
{ v: 'clublog', label: 'Club Log' },
|
{ v: 'clublog', label: 'Club Log' },
|
||||||
{ v: 'lotw', label: 'LoTW' },
|
{ v: 'lotw', label: 'LoTW' },
|
||||||
|
{ v: 'pota', label: 'POTA hunter log' },
|
||||||
|
{ v: 'paper', label: 'Paper QSL' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const QSL_STATUSES = [
|
||||||
|
{ v: '_', label: '— leave —' },
|
||||||
|
{ v: 'Y', label: 'Yes' },
|
||||||
|
{ v: 'N', label: 'No' },
|
||||||
|
{ v: 'R', label: 'Requested' },
|
||||||
|
{ v: 'I', label: 'Ignore' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type LogQSO = {
|
||||||
|
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
|
||||||
|
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number };
|
||||||
|
type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[] };
|
||||||
|
|
||||||
const SENT_STATUSES = [
|
const SENT_STATUSES = [
|
||||||
{ v: 'R', label: 'Requested' },
|
{ v: 'R', label: 'Requested' },
|
||||||
{ v: 'N', label: 'No' },
|
{ v: 'N', label: 'No' },
|
||||||
@@ -42,10 +61,82 @@ function fmtDate(iso: string): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fmtQslDate renders a QSL/LoTW/eQSL/ClubLog date (ADIF YYYYMMDD, or an ISO
|
||||||
|
// datetime) as YYYY-MM-DD — same shape as the QSO date, without the time.
|
||||||
|
export function fmtQslDate(s?: string): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const t = s.trim();
|
||||||
|
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 d.toISOString().slice(0, 10);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
|
||||||
// and download confirmations, while the rest of the app stays usable.
|
// and download confirmations, while the rest of the app stays usable.
|
||||||
export function QSLManagerPanel() {
|
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||||
const [service, setService] = useState('lotw');
|
const [service, setService] = useState('lotw');
|
||||||
|
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||||
|
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||||
|
const [potaErr, setPotaErr] = useState('');
|
||||||
|
const [potaAddMissing, setPotaAddMissing] = useState(false);
|
||||||
|
|
||||||
|
async function syncPota() {
|
||||||
|
setPotaSyncing(true); setPotaErr(''); setPotaRes(null);
|
||||||
|
try { setPotaRes((await SyncPOTAHunterLog(potaAddMissing)) as any as POTASync); }
|
||||||
|
catch (e: any) { setPotaErr(String(e?.message ?? e)); }
|
||||||
|
finally { setPotaSyncing(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Paper QSL: search a callsign, bulk-set sent/received + via + date ──
|
||||||
|
const [paperCall, setPaperCall] = useState('');
|
||||||
|
const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
|
||||||
|
const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
|
||||||
|
const [paperBusy, setPaperBusy] = useState(false);
|
||||||
|
const [paperMsg, setPaperMsg] = useState('');
|
||||||
|
const [qslRcvd, setQslRcvd] = useState('Y');
|
||||||
|
const [qslSent, setQslSent] = useState('_');
|
||||||
|
const [qslRcvdDate, setQslRcvdDate] = useState('');
|
||||||
|
const [qslSentDate, setQslSentDate] = useState('');
|
||||||
|
const [qslVia, setQslVia] = useState('');
|
||||||
|
|
||||||
|
const searchPaper = useCallback(async () => {
|
||||||
|
const c = paperCall.trim().toUpperCase();
|
||||||
|
if (!c) return;
|
||||||
|
setPaperBusy(true); setPaperMsg('');
|
||||||
|
try {
|
||||||
|
const r: any = await ListQSO({ callsign: c, limit: 1000 } as any);
|
||||||
|
const list = (r ?? []) as LogQSO[];
|
||||||
|
setPaperRows(list);
|
||||||
|
setPaperSel(new Set(list.map((x) => x.id)));
|
||||||
|
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
|
||||||
|
finally { setPaperBusy(false); }
|
||||||
|
}, [paperCall]);
|
||||||
|
|
||||||
|
function togglePaper(id: number) {
|
||||||
|
setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||||
|
}
|
||||||
|
const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length;
|
||||||
|
|
||||||
|
async function applyPaper() {
|
||||||
|
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
setPaperBusy(true); setPaperMsg('');
|
||||||
|
const ymd = (d: string) => d.replaceAll('-', '');
|
||||||
|
try {
|
||||||
|
const n = await BulkUpdateQSL(ids as any, {
|
||||||
|
sent_status: qslSent === '_' ? '' : qslSent,
|
||||||
|
rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
|
||||||
|
sent_date: ymd(qslSentDate),
|
||||||
|
rcvd_date: ymd(qslRcvdDate),
|
||||||
|
via: qslVia,
|
||||||
|
} as any);
|
||||||
|
setPaperMsg(`${n} QSO updated.`);
|
||||||
|
await searchPaper();
|
||||||
|
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); }
|
||||||
|
finally { setPaperBusy(false); }
|
||||||
|
}
|
||||||
const [sent, setSent] = useState('R');
|
const [sent, setSent] = useState('R');
|
||||||
const [rows, setRows] = useState<UploadRow[]>([]);
|
const [rows, setRows] = useState<UploadRow[]>([]);
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
@@ -149,18 +240,57 @@ export function QSLManagerPanel() {
|
|||||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
{service === 'pota' ? (
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
<>
|
||||||
<Select value={sent} onValueChange={setSent}>
|
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
||||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
{potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
|
||||||
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
Sync hunter log
|
||||||
</Select>
|
</Button>
|
||||||
</div>
|
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)">
|
||||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
|
<Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
|
||||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
Add not-found QSOs to my log
|
||||||
Select required
|
</label>
|
||||||
</Button>
|
<span className="text-[11px] text-muted-foreground self-center">Token in Settings → External services → POTA.</span>
|
||||||
|
</>
|
||||||
|
) : service === 'paper' ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
|
||||||
|
<Input className="h-8 w-40 font-mono uppercase" value={paperCall}
|
||||||
|
onChange={(e) => setPaperCall(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }}
|
||||||
|
placeholder="e.g. DL1ABC" />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-8" onClick={searchPaper} disabled={paperBusy || !paperCall.trim()}>
|
||||||
|
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground self-center">Find a callsign, then set QSL sent/received + via + date on the selection.</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
||||||
|
<Select value={sent} onValueChange={setSent}>
|
||||||
|
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
|
||||||
|
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||||
|
Select required
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{service === 'pota' && potaRes && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched / {potaRes.fetched}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{service === 'paper' && paperRows.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">{paperRows.length} QSO · {paperSel.size} selected</span>
|
||||||
|
)}
|
||||||
{!showLog && viewMode === 'confirmations' && (
|
{!showLog && viewMode === 'confirmations' && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
|
||||||
@@ -192,7 +322,81 @@ export function QSLManagerPanel() {
|
|||||||
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
<div className="flex-1 overflow-auto px-3 py-2 min-h-0">
|
||||||
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
||||||
|
|
||||||
{showLog ? (
|
{service === 'paper' ? (
|
||||||
|
paperRows.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead className="sticky top-0 bg-card">
|
||||||
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
|
<th className="py-1.5 px-2 w-8"><Checkbox checked={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></th>
|
||||||
|
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
||||||
|
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
||||||
|
<th className="py-1.5 px-2">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paperRows.map((r) => (
|
||||||
|
<tr key={r.id}
|
||||||
|
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
|
||||||
|
onClick={() => togglePaper(r.id)}>
|
||||||
|
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(r.id)} /></td>
|
||||||
|
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
||||||
|
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
||||||
|
<td className="py-1 px-2">{r.band}</td>
|
||||||
|
<td className="py-1 px-2">{r.mode}</td>
|
||||||
|
<td className="py-1 px-2 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
|
||||||
|
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
|
||||||
|
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
) : service === 'pota' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{potaErr && <div className="text-xs rounded-md px-3 py-2 border border-destructive/30 bg-destructive/10 text-destructive">{potaErr}</div>}
|
||||||
|
{!potaRes && !potaErr && !potaSyncing && (
|
||||||
|
<div className="text-sm text-muted-foreground py-10 text-center">Click “Sync hunter log” to fetch your pota.app log and stamp park references.</div>
|
||||||
|
)}
|
||||||
|
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> Syncing with pota.app…</div>}
|
||||||
|
{potaRes && (
|
||||||
|
<>
|
||||||
|
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800">
|
||||||
|
{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.
|
||||||
|
</div>
|
||||||
|
{potaRes.unmatched_list?.length > 0 && (
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead className="sticky top-0 bg-card">
|
||||||
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
|
<th className="py-1.5 px-2">Activator</th><th className="py-1.5 px-2">Date UTC</th>
|
||||||
|
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Park</th>
|
||||||
|
<th className="py-1.5 px-2">Why unmatched</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{potaRes.unmatched_list.map((u, i) => (
|
||||||
|
<tr key={i}
|
||||||
|
className={cn('border-b border-border/40', u.qso_id > 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' : ''}>
|
||||||
|
<td className="py-1 px-2 font-mono font-bold">{u.activator}</td>
|
||||||
|
<td className="py-1 px-2 font-mono">{u.date}</td>
|
||||||
|
<td className="py-1 px-2">{u.band}</td>
|
||||||
|
<td className="py-1 px-2 font-mono">{u.reference}</td>
|
||||||
|
<td className="py-1 px-2 text-muted-foreground">
|
||||||
|
{u.reason}
|
||||||
|
{u.qso_id > 0 && <ExternalLink className="inline size-3 ml-1 opacity-60" />}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : showLog ? (
|
||||||
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
<div className="font-mono text-[11px] space-y-0.5 py-1">
|
||||||
{logLines.length === 0 ? (
|
{logLines.length === 0 ? (
|
||||||
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting…</div>
|
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting…</div>
|
||||||
@@ -270,7 +474,43 @@ export function QSLManagerPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action bar */}
|
{/* Paper-QSL apply form */}
|
||||||
|
{service === 'paper' && (
|
||||||
|
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">QSL received</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Select value={qslRcvd} onValueChange={setQslRcvd}>
|
||||||
|
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title="QSL received date" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">QSL sent</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Select value={qslSent} onValueChange={setQslSent}>
|
||||||
|
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title="QSL sent date" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
|
||||||
|
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
|
||||||
|
<Button size="sm" onClick={applyPaper} disabled={paperBusy || paperSel.size === 0}>
|
||||||
|
Apply to {paperSel.size} selected
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action bar (upload/download — not for POTA / Paper QSL) */}
|
||||||
|
{service !== 'pota' && service !== 'paper' && (
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||||
@@ -286,6 +526,7 @@ export function QSLManagerPanel() {
|
|||||||
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
|
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,8 +74,12 @@ function fmtDateUTC(s: any): string {
|
|||||||
}
|
}
|
||||||
function fmtDateOnly(s: any): string {
|
function fmtDateOnly(s: any): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
const d = new Date(s);
|
const t = String(s).trim();
|
||||||
if (isNaN(d.getTime())) return s;
|
// 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');
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ import {
|
|||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
||||||
|
GetDataDir,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||||
GetPOTAToken, SavePOTAToken, SyncPOTAHunterLog,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
} from '../../wailsjs/go/main/App';
|
} 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).
|
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||||||
const [potaToken, setPotaToken] = useState('');
|
const [potaToken, setPotaToken] = useState('');
|
||||||
const [potaBusy, setPotaBusy] = useState(false);
|
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(() => {}); }, []);
|
useEffect(() => { GetPOTAToken().then((t) => setPotaToken(t || '')).catch(() => {}); }, []);
|
||||||
|
|
||||||
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
|
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
|
||||||
@@ -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 [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
|
||||||
const [dbMsg, setDbMsg] = useState('');
|
const [dbMsg, setDbMsg] = useState('');
|
||||||
|
const [dataDir, setDataDir] = useState('');
|
||||||
|
|
||||||
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
|
||||||
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
|
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
|
||||||
@@ -585,6 +587,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
setExtSvc(es as any);
|
setExtSvc(es as any);
|
||||||
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
|
||||||
|
try { setDataDir(await GetDataDir()); } catch {}
|
||||||
try {
|
try {
|
||||||
const locs: any = await ListTQSLStationLocations();
|
const locs: any = await ListTQSLStationLocations();
|
||||||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
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 },
|
{ k: 'pota', label: 'POTA', ready: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function syncPota() {
|
async function savePotaToken() {
|
||||||
setPotaBusy(true);
|
setPotaBusy(true);
|
||||||
setPotaResult(null);
|
setPotaResult(null);
|
||||||
try {
|
try {
|
||||||
await SavePOTAToken(potaToken);
|
await SavePOTAToken(potaToken);
|
||||||
const r: any = await SyncPOTAHunterLog();
|
setPotaResult({ ok: true, msg: 'Token saved. Run the sync from the QSL Manager → POTA hunter log.' });
|
||||||
setPotaResult({ ok: true, msg: `${r.updated} QSO updated · ${r.already_tagged} already tagged · ${r.unmatched} unmatched (of ${r.fetched} hunter-log entries).` });
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setPotaResult({ ok: false, msg: String(e?.message ?? e) });
|
setPotaResult({ ok: false, msg: String(e?.message ?? e) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2476,10 +2478,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button onClick={syncPota} disabled={potaBusy || !potaToken.trim()}>
|
<Button onClick={savePotaToken} disabled={potaBusy}>
|
||||||
{potaBusy ? <><Loader2 className="size-4 mr-1.5 animate-spin" /> Syncing…</> : 'Sync hunter log'}
|
{potaBusy ? <><Loader2 className="size-4 mr-1.5 animate-spin" /> Saving…</> : 'Save token'}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-[11px] text-muted-foreground">Matches by callsign + band within ±5 min. Only fills QSOs without a POTA ref.</span>
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
Then run the sync from the <strong>QSL Manager</strong> tab → service <strong>POTA hunter log</strong> (you can see and fix unmatched QSOs there).
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{potaResult && (
|
{potaResult && (
|
||||||
<div className={cn('text-xs rounded-md px-3 py-2 border',
|
<div className={cn('text-xs rounded-md px-3 py-2 border',
|
||||||
@@ -2487,7 +2491,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
{potaResult.msg}
|
{potaResult.msg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-[11px] text-muted-foreground">After a sync, rescan the POTA award to see the new references counted.</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
||||||
@@ -2578,6 +2581,22 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Data location */}
|
||||||
|
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">Data location</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Current data directory</Label>
|
||||||
|
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
||||||
|
{dataDir || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Backup settings, merged into this Database section. */}
|
{/* Backup settings, merged into this Database section. */}
|
||||||
<div className="border-t border-border/60 mt-6 pt-5">
|
<div className="border-t border-border/60 mt-6 pt-5">
|
||||||
{BackupPanel()}
|
{BackupPanel()}
|
||||||
|
|||||||
@@ -92,7 +92,11 @@ export function buildAwardRefs(qso: any, pickable: Array<{ code: string; field:
|
|||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const { code, field } of pickable) {
|
for (const { code, field } of pickable) {
|
||||||
const v = awardRefValue(qso, code, field);
|
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(';');
|
return out.join(';');
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+11
-1
@@ -25,6 +25,8 @@ export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array
|
|||||||
|
|
||||||
export function AwardFields():Promise<Array<string>>;
|
export function AwardFields():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
export function ClearLookupCache():Promise<void>;
|
export function ClearLookupCache():Promise<void>;
|
||||||
|
|
||||||
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
|
||||||
@@ -75,6 +77,8 @@ 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>;
|
||||||
@@ -85,6 +89,8 @@ 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>;
|
||||||
@@ -129,6 +135,8 @@ export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
|
|||||||
|
|
||||||
export function GetDVKStatus():Promise<main.DVKStatus>;
|
export function GetDVKStatus():Promise<main.DVKStatus>;
|
||||||
|
|
||||||
|
export function GetDataDir():Promise<string>;
|
||||||
|
|
||||||
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
||||||
|
|
||||||
export function GetEmailSettings():Promise<main.EmailSettings>;
|
export function GetEmailSettings():Promise<main.EmailSettings>;
|
||||||
@@ -167,6 +175,8 @@ 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 ListAudioInputDevices():Promise<Array<audio.Device>>;
|
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
||||||
|
|
||||||
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
|
||||||
@@ -305,7 +315,7 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SyncPOTAHunterLog():Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export function AwardFields() {
|
|||||||
return window['go']['main']['App']['AwardFields']();
|
return window['go']['main']['App']['AwardFields']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BulkUpdateQSL(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ClearLookupCache() {
|
export function ClearLookupCache() {
|
||||||
return window['go']['main']['App']['ClearLookupCache']();
|
return window['go']['main']['App']['ClearLookupCache']();
|
||||||
}
|
}
|
||||||
@@ -122,6 +126,10 @@ 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']();
|
||||||
}
|
}
|
||||||
@@ -142,6 +150,10 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -230,6 +242,10 @@ export function GetDVKStatus() {
|
|||||||
return window['go']['main']['App']['GetDVKStatus']();
|
return window['go']['main']['App']['GetDVKStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetDataDir() {
|
||||||
|
return window['go']['main']['App']['GetDataDir']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetDatabaseSettings() {
|
export function GetDatabaseSettings() {
|
||||||
return window['go']['main']['App']['GetDatabaseSettings']();
|
return window['go']['main']['App']['GetDatabaseSettings']();
|
||||||
}
|
}
|
||||||
@@ -306,6 +322,10 @@ export function ImportAwardReferencesText(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ImportAwardReferencesText'](arg1, arg2);
|
return window['go']['main']['App']['ImportAwardReferencesText'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IsPortableMode() {
|
||||||
|
return window['go']['main']['App']['IsPortableMode']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListAudioInputDevices() {
|
export function ListAudioInputDevices() {
|
||||||
return window['go']['main']['App']['ListAudioInputDevices']();
|
return window['go']['main']['App']['ListAudioInputDevices']();
|
||||||
}
|
}
|
||||||
@@ -582,8 +602,8 @@ export function SwitchCATRig(arg1) {
|
|||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SyncPOTAHunterLog() {
|
export function SyncPOTAHunterLog(arg1) {
|
||||||
return window['go']['main']['App']['SyncPOTAHunterLog']();
|
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestClublogUpload() {
|
export function TestClublogUpload() {
|
||||||
|
|||||||
@@ -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 {
|
export class POTASyncResult {
|
||||||
fetched: number;
|
fetched: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
already_tagged: number;
|
already_tagged: number;
|
||||||
|
added: number;
|
||||||
unmatched: number;
|
unmatched: number;
|
||||||
|
unmatched_list: POTAUnmatched[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new POTASyncResult(source);
|
return new POTASyncResult(source);
|
||||||
@@ -988,7 +1012,48 @@ export namespace main {
|
|||||||
this.fetched = source["fetched"];
|
this.fetched = source["fetched"];
|
||||||
this.updated = source["updated"];
|
this.updated = source["updated"];
|
||||||
this.already_tagged = source["already_tagged"];
|
this.already_tagged = source["already_tagged"];
|
||||||
|
this.added = source["added"];
|
||||||
this.unmatched = source["unmatched"];
|
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 {
|
export class QSLDefaults {
|
||||||
|
|||||||
+20
-2
@@ -449,8 +449,10 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// Whole field value is the candidate.
|
// Whole field value is the candidate, split on comma/semicolon so a
|
||||||
found = []string{normalizeRef(raw)}
|
// multi-reference field (e.g. an n-fer POTA QSO "US-6544,US-0680")
|
||||||
|
// counts each reference separately.
|
||||||
|
found = splitRefs(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !predefined {
|
if !predefined {
|
||||||
@@ -547,6 +549,22 @@ func stripAffix(s, lead, trail string) string {
|
|||||||
|
|
||||||
func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
|
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' }
|
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
|
||||||
|
|
||||||
// natLess is a natural ("human") comparison: digit runs compare as numbers, so
|
// natLess is a natural ("human") comparison: digit runs compare as numbers, so
|
||||||
|
|||||||
@@ -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 {
|
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 {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package awardref
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -24,6 +25,38 @@ var Importers = map[string]Importer{
|
|||||||
"POTA": {AwardCode: "POTA", URL: "https://pota.app/all_parks.csv", Fetch: parsePOTA},
|
"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},
|
"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},
|
"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.
|
// CanUpdate reports whether an award has an online reference list.
|
||||||
|
|||||||
Reference in New Issue
Block a user