up
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user