This commit is contained in:
2026-06-06 11:59:32 +02:00
parent 176cc0e62b
commit f91f9ff3b8
13 changed files with 866 additions and 90 deletions
+351 -49
View File
@@ -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 // <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
// 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 "<exe dir>/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.