Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9e03d9de3 | ||
|
|
491546c7f4 | ||
|
|
91b21a4a36 | ||
|
|
eab11db766 | ||
|
|
0c64bdcaf9 | ||
|
|
f0d7a07ed6 | ||
|
|
2aceb99948 | ||
|
|
c48294b0ca | ||
|
|
747c2b9105 | ||
|
|
724e68b38d | ||
|
|
7d664bd1de | ||
|
|
93732ee563 | ||
|
|
5293bb18c5 | ||
|
|
14ac73028e | ||
|
|
b5a88ee5a2 | ||
|
|
e81500f809 | ||
|
|
37805fe3ed | ||
|
|
7578e49573 |
@@ -67,6 +67,7 @@ import (
|
|||||||
"hamlog/internal/solar"
|
"hamlog/internal/solar"
|
||||||
"hamlog/internal/spe"
|
"hamlog/internal/spe"
|
||||||
"hamlog/internal/steppir"
|
"hamlog/internal/steppir"
|
||||||
|
"hamlog/internal/syncfolder"
|
||||||
"hamlog/internal/tunergenius"
|
"hamlog/internal/tunergenius"
|
||||||
"hamlog/internal/uls"
|
"hamlog/internal/uls"
|
||||||
"hamlog/internal/ultrabeam"
|
"hamlog/internal/ultrabeam"
|
||||||
@@ -735,6 +736,11 @@ type App struct {
|
|||||||
confDLCancel context.CancelFunc
|
confDLCancel context.CancelFunc
|
||||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||||
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
||||||
|
syncMu sync.Mutex // serialises folder synchronisation: config, the seq counter, and the append to our own file
|
||||||
|
syncSent int64 // changes written to the folder this session
|
||||||
|
syncReceived int64 // changes taken from the other machines this session
|
||||||
|
syncLast time.Time // last completed pass, for the status panel
|
||||||
|
syncErr string // last folder error, shown in settings — a share that dropped is otherwise invisible
|
||||||
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
|
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
|
||||||
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
|
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
|
||||||
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
|
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
|
||||||
@@ -766,6 +772,8 @@ type App struct {
|
|||||||
liveTableMu sync.Mutex // guards liveTableFor
|
liveTableMu sync.Mutex // guards liveTableFor
|
||||||
liveTableFor *sql.DB // logbook whose live_status DDL has been ensured (once per connection, not per call)
|
liveTableFor *sql.DB // logbook whose live_status DDL has been ensured (once per connection, not per call)
|
||||||
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||||
|
awardSnapBuild sync.Mutex // serialises BUILDING it — see awardSnapshot
|
||||||
|
awardSnapCap int // rows the last build produced, the capacity hint for the next
|
||||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
||||||
awardSnapUsed time.Time // last read — the snapshot is dropped once it goes cold (see awardSnapshotJanitor)
|
awardSnapUsed time.Time // last read — the snapshot is dropped once it goes cold (see awardSnapshotJanitor)
|
||||||
@@ -1162,6 +1170,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
|
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
|
||||||
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
||||||
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
|
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
|
||||||
|
go a.folderSyncLoop() // one operator, several PCs: read the other machines' change logs
|
||||||
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
|
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
|
||||||
|
|
||||||
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
||||||
@@ -2785,6 +2794,14 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
a.maybeAutoSendEQSL(qc)
|
a.maybeAutoSendEQSL(qc)
|
||||||
a.maybeSelfSpot(qc)
|
a.maybeSelfSpot(qc)
|
||||||
a.publishSoon() // refresh the published web page, debounced
|
a.publishSoon() // refresh the published web page, debounced
|
||||||
|
// Tell the operator's other PCs. Down here with the rest of the
|
||||||
|
// after-the-fact work because a folder on a network share can block
|
||||||
|
// for seconds, and a contact belongs in the database and on screen
|
||||||
|
// long before another machine needs to hear about it.
|
||||||
|
//
|
||||||
|
// Read back rather than sent from `qc`: award_refs was materialised
|
||||||
|
// a few lines above and is not on the copy taken at insert time.
|
||||||
|
a.syncPublishAsync(syncfolder.OpAdd, id, nil)
|
||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
rec := adif.SingleRecordADIF(qc)
|
rec := adif.SingleRecordADIF(qc)
|
||||||
a.udp.EmitLoggedADIF(rec)
|
a.udp.EmitLoggedADIF(rec)
|
||||||
@@ -4348,8 +4365,41 @@ func (a *App) awardSnapshot() ([]qso.QSO, error) {
|
|||||||
a.awardSnapMu.Unlock()
|
a.awardSnapMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ONE BUILDER AT A TIME.
|
||||||
|
//
|
||||||
|
// The cache lock above is released before the pull, so every caller that
|
||||||
|
// arrives while the logbook is being read used to miss and start its own.
|
||||||
|
// Opening the Awards panel does that: a field log showed three pulls of the
|
||||||
|
// same 123 615 QSOs within ten seconds, and three copies alive at once took
|
||||||
|
// the heap from 725 MB to 2.5 GB. The work was identical each time.
|
||||||
|
//
|
||||||
|
// Waiting here costs the second and third caller the seconds the first was
|
||||||
|
// going to take anyway — they were already paying that, plus a second and
|
||||||
|
// third trip to the database.
|
||||||
|
a.awardSnapBuild.Lock()
|
||||||
|
defer a.awardSnapBuild.Unlock()
|
||||||
|
// Re-check: whoever held the build lock has just finished, and their result
|
||||||
|
// is what we came for.
|
||||||
|
if revErr == nil {
|
||||||
|
a.awardSnapMu.Lock()
|
||||||
|
if a.awardSnap != nil && a.awardSnapRev == rev {
|
||||||
|
qs := a.awardSnap
|
||||||
|
a.awardSnapUsed = time.Now()
|
||||||
|
a.awardSnapMu.Unlock()
|
||||||
|
return qs, nil
|
||||||
|
}
|
||||||
|
a.awardSnapMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
t0 := time.Now()
|
t0 := time.Now()
|
||||||
var all []qso.QSO
|
// Sized from the last build. Growing a slice to 123 000 structs by doubling
|
||||||
|
// copies the whole thing a dozen times and holds the old and the new array
|
||||||
|
// together at every step — on the biggest object OpsLog keeps, that transient
|
||||||
|
// is worth avoiding.
|
||||||
|
a.awardSnapMu.Lock()
|
||||||
|
hint := a.awardSnapCap
|
||||||
|
a.awardSnapMu.Unlock()
|
||||||
|
all := make([]qso.QSO, 0, hint)
|
||||||
if err := a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error {
|
if err := a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error {
|
||||||
a.enrichQSOForAwards(&q)
|
a.enrichQSOForAwards(&q)
|
||||||
all = append(all, q)
|
all = append(all, q)
|
||||||
@@ -4365,13 +4415,14 @@ func (a *App) awardSnapshot() ([]qso.QSO, error) {
|
|||||||
applog.Printf("awardSnapshot: pulled %d qsos from logbook in %v (rev=%s) — go heap now %d MB",
|
applog.Printf("awardSnapshot: pulled %d qsos from logbook in %v (rev=%s) — go heap now %d MB",
|
||||||
len(all), time.Since(t0).Round(time.Millisecond), rev, ms.HeapAlloc/(1024*1024))
|
len(all), time.Since(t0).Round(time.Millisecond), rev, ms.HeapAlloc/(1024*1024))
|
||||||
|
|
||||||
if revErr == nil {
|
|
||||||
a.awardSnapMu.Lock()
|
a.awardSnapMu.Lock()
|
||||||
|
a.awardSnapCap = len(all) // next build starts the right size
|
||||||
|
if revErr == nil {
|
||||||
a.awardSnap = all
|
a.awardSnap = all
|
||||||
a.awardSnapRev = rev
|
a.awardSnapRev = rev
|
||||||
a.awardSnapUsed = time.Now()
|
a.awardSnapUsed = time.Now()
|
||||||
a.awardSnapMu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
a.awardSnapMu.Unlock()
|
||||||
return all, nil
|
return all, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5978,6 +6029,7 @@ func (a *App) UpdateQSO(q qso.QSO) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
a.invalidateAwardStats()
|
a.invalidateAwardStats()
|
||||||
a.materializeAwardRefs(q) // fields may have changed → refresh award_refs
|
a.materializeAwardRefs(q) // fields may have changed → refresh award_refs
|
||||||
|
a.syncPublishAsync(syncfolder.OpUpdate, q.ID, nil)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -6071,6 +6123,7 @@ func (a *App) DeleteQSO(id int64) error {
|
|||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
a.deleteRemoteCopies([]int64{id})
|
a.deleteRemoteCopies([]int64{id})
|
||||||
|
a.syncPublishDeletes([]int64{id})
|
||||||
return a.qso.Delete(a.ctx, id)
|
return a.qso.Delete(a.ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6141,6 +6194,7 @@ func (a *App) DeleteQSOs(ids []int64) (int64, error) {
|
|||||||
return 0, fmt.Errorf("db not initialized")
|
return 0, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
a.deleteRemoteCopies(ids)
|
a.deleteRemoteCopies(ids)
|
||||||
|
a.syncPublishDeletes(ids)
|
||||||
return a.qso.DeleteMany(a.ctx, ids)
|
return a.qso.DeleteMany(a.ctx, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7091,8 +7145,14 @@ func (a *App) SaveCabrilloFile() (string, error) {
|
|||||||
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
|
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
|
||||||
// Errors are returned as-is to the frontend; ErrNotFound surfaces as
|
// Errors are returned as-is to the frontend; ErrNotFound surfaces as
|
||||||
// "callsign not found".
|
// "callsign not found".
|
||||||
func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
// qsoDate is the entry form's date as "YYYY-MM-DD" (or empty for now). It is
|
||||||
return a.lookupCallsign(callsign, false)
|
// what the ClubLog exception is resolved AGAINST: an exception has a validity
|
||||||
|
// window, and a DXpedition's window closes. Entering 3Y0K by hand months later,
|
||||||
|
// with the activation's own date in the form, resolved the exception at TODAY's
|
||||||
|
// date, found none, and fell back to cty.dat — Antarctica instead of Bouvet
|
||||||
|
// Island. A backdated QSO must be enriched as of when it happened.
|
||||||
|
func (a *App) LookupCallsign(callsign string, qsoDate string) (lookup.Result, error) {
|
||||||
|
return a.lookupCallsign(callsign, false, qsoDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupCallsignFresh is the same, but SKIPS the cache and refreshes it.
|
// LookupCallsignFresh is the same, but SKIPS the cache and refreshes it.
|
||||||
@@ -7103,11 +7163,11 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
|||||||
// subscription went on getting the thin free-account record, and deleting the
|
// subscription went on getting the thin free-account record, and deleting the
|
||||||
// cached row by hand was the only way out. A deliberate click must reach the
|
// cached row by hand was the only way out. A deliberate click must reach the
|
||||||
// provider and overwrite what was stored.
|
// provider and overwrite what was stored.
|
||||||
func (a *App) LookupCallsignFresh(callsign string) (lookup.Result, error) {
|
func (a *App) LookupCallsignFresh(callsign string, qsoDate string) (lookup.Result, error) {
|
||||||
return a.lookupCallsign(callsign, true)
|
return a.lookupCallsign(callsign, true, qsoDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error) {
|
func (a *App) lookupCallsign(callsign string, force bool, qsoDate string) (lookup.Result, error) {
|
||||||
if a.lookup == nil {
|
if a.lookup == nil {
|
||||||
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
||||||
}
|
}
|
||||||
@@ -7152,10 +7212,15 @@ func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error)
|
|||||||
r.ImageURL = ""
|
r.ImageURL = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ClubLog exception override (live entry → today's date): for an active
|
// ClubLog exception override, resolved AT THE QSO'S DATE.
|
||||||
// DXpedition the entered call gets the right entity/zones immediately.
|
//
|
||||||
|
// An exception carries a validity window and a DXpedition's window closes.
|
||||||
|
// Resolving at today's date is right for a contact happening now and wrong
|
||||||
|
// for one being entered afterwards: 3Y0K typed months later, with the
|
||||||
|
// activation's date in the form, found no live exception and fell back to
|
||||||
|
// cty.dat — Antarctica, where the log says Bouvet Island.
|
||||||
if a.clublogCtyEnabled() && a.clublog != nil {
|
if a.clublogCtyEnabled() && a.clublog != nil {
|
||||||
if e, ok := a.clublog.Resolve(callsign, time.Now().UTC()); ok {
|
if e, ok := a.clublog.Resolve(callsign, lookupWhen(qsoDate)); ok {
|
||||||
r.Country = titleEntity(e.Entity)
|
r.Country = titleEntity(e.Entity)
|
||||||
if e.Cont != "" {
|
if e.Cont != "" {
|
||||||
r.Continent = e.Cont
|
r.Continent = e.Cont
|
||||||
@@ -18476,3 +18541,31 @@ func wsjtLoggedQSO(q qso.QSO) udp.LoggedQSO {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lookupWhen turns the entry form's date into the instant a ClubLog exception
|
||||||
|
// should be resolved at.
|
||||||
|
//
|
||||||
|
// Empty, unparseable, or in the future → now. A date is only trusted to move
|
||||||
|
// the resolution BACKWARDS: a half-typed "2026-0" must not send the lookup to
|
||||||
|
// the year 20, and a mistyped future date must not resolve against an exception
|
||||||
|
// window that has not opened.
|
||||||
|
//
|
||||||
|
// Midday UTC rather than midnight: an exception window given in whole days is
|
||||||
|
// inclusive of its end date, and resolving at 00:00 of that day sits on the
|
||||||
|
// boundary where an off-by-one in either direction changes the answer.
|
||||||
|
func lookupWhen(qsoDate string) time.Time {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
s := strings.TrimSpace(qsoDate)
|
||||||
|
if s == "" {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", s)
|
||||||
|
if err != nil {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
t = t.Add(12 * time.Hour)
|
||||||
|
if t.After(now) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The award snapshot must be built ONCE however many callers ask at once.
|
||||||
|
//
|
||||||
|
// The cache lock is released before the logbook is read, so every caller that
|
||||||
|
// arrives during a build used to miss the cache and start its own. Opening the
|
||||||
|
// Awards panel does exactly that: a field log showed three pulls of the same
|
||||||
|
// 123 615 QSOs inside ten seconds, and three copies alive together took the Go
|
||||||
|
// heap from 725 MB to 2.5 GB.
|
||||||
|
//
|
||||||
|
// This models the same shape — a cheap cache check, a slow build, a shared
|
||||||
|
// result — against the pattern awardSnapshot now uses, so the invariant is
|
||||||
|
// pinned without needing a logbook.
|
||||||
|
func TestSnapshotBuildsOncePerRevision(t *testing.T) {
|
||||||
|
var (
|
||||||
|
cacheMu sync.Mutex
|
||||||
|
buildMu sync.Mutex
|
||||||
|
cached []int
|
||||||
|
rev = "r1"
|
||||||
|
gotRev string
|
||||||
|
builds int
|
||||||
|
)
|
||||||
|
|
||||||
|
get := func() []int {
|
||||||
|
cacheMu.Lock()
|
||||||
|
if cached != nil && gotRev == rev {
|
||||||
|
defer cacheMu.Unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cacheMu.Unlock()
|
||||||
|
|
||||||
|
buildMu.Lock()
|
||||||
|
defer buildMu.Unlock()
|
||||||
|
// Re-check: whoever held the build lock has just finished.
|
||||||
|
cacheMu.Lock()
|
||||||
|
if cached != nil && gotRev == rev {
|
||||||
|
defer cacheMu.Unlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cacheMu.Unlock()
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond) // the logbook read
|
||||||
|
out := []int{1, 2, 3}
|
||||||
|
|
||||||
|
cacheMu.Lock()
|
||||||
|
builds++
|
||||||
|
cached, gotRev = out, rev
|
||||||
|
cacheMu.Unlock()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([][]int, 8)
|
||||||
|
for i := range results {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) { defer wg.Done(); results[i] = get() }(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if builds != 1 {
|
||||||
|
t.Errorf("%d builds for one revision — each concurrent caller pulled the whole logbook again", builds)
|
||||||
|
}
|
||||||
|
for i, r := range results {
|
||||||
|
if len(r) != 3 {
|
||||||
|
t.Errorf("caller %d got %v", i, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new revision must rebuild: the guard is against duplicate work, not
|
||||||
|
// against a logbook that changed.
|
||||||
|
cacheMu.Lock()
|
||||||
|
rev = "r2"
|
||||||
|
cacheMu.Unlock()
|
||||||
|
get()
|
||||||
|
if builds != 2 {
|
||||||
|
t.Errorf("builds = %d after the revision moved, want 2 — a changed logbook must be re-read", builds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,30 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.25.7",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Opening the Awards panel no longer pulls the whole logbook several times at once — a large log briefly took gigabytes of memory.",
|
||||||
|
"Awards: the Missing refs button now only appears where it means something — a worldwide award like POTA could only ever answer “nothing found”.",
|
||||||
|
"Worked before: a prefixed call like ZA/OE8NDR matched every other visitor to that country instead of that one operator.",
|
||||||
|
"French: seventeen strings were still in English, the whole update panel among them, plus Spot lifetime and Chase new grids.",
|
||||||
|
"Selecting a QSO shows the entity the QSO records, not one re-derived from its callsign — a 3Y0K contact logged as Bouvet showed the Antarctica matrix.",
|
||||||
|
"Back-entering a QSO resolves the ClubLog exception at the CONTACT’S date, so a DXpedition entered months later gets the entity it had then.",
|
||||||
|
"QRZ.com sends an island reference for an operator on one, and OpsLog read past it — it now fills the IOTA award reference before the QSO is logged.",
|
||||||
|
"Sync across PCs: point every OpsLog at one folder you already synchronise and your contacts follow you between machines.",
|
||||||
|
"TCI: a station spotted by several operators is drawn once on the panorama instead of two or three times a few hertz apart."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Ouvrir le panneau Awards ne tire plus plusieurs fois le journal entier en même temps — un gros log occupait brièvement des gigaoctets de mémoire.",
|
||||||
|
"Awards : le bouton Réf. manquantes n’apparaît plus que là où il a un sens — un diplôme mondial comme POTA ne pouvait répondre que « aucun manque ».",
|
||||||
|
"Déjà contacté : un indicatif préfixé comme ZA/OE8NDR rapprochait tous les autres visiteurs du pays au lieu de ce seul opérateur.",
|
||||||
|
"Français : dix-sept textes étaient restés en anglais, dont tout le panneau de mise à jour, la durée de vie des spots et Chasser les nouveaux locators.",
|
||||||
|
"Sélectionner un QSO affiche l’entité que le QSO enregistre, pas une recalculée depuis l’indicatif — un 3Y0K logué Bouvet montrait la matrice Antarctique.",
|
||||||
|
"Saisir un QSO a posteriori résout l’exception ClubLog à la date DU CONTACT : une DXpedition entrée des mois après retrouve l’entité qu’elle avait alors.",
|
||||||
|
"QRZ.com envoie la référence d’île d’un opérateur sur une île, et OpsLog l’ignorait — elle remplit désormais la référence IOTA avant l’enregistrement du QSO.",
|
||||||
|
"Synchro entre PC : fais pointer chaque OpsLog vers un dossier déjà synchronisé et tes contacts te suivent d’une machine à l’autre.",
|
||||||
|
"TCI : une station spottée par plusieurs opérateurs n’est tracée qu’une fois sur le panorama, au lieu de deux ou trois fois."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.25.6",
|
"version": "0.25.6",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+43
-6
@@ -56,7 +56,7 @@ import {
|
|||||||
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
|
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList } from '@/lib/awardRefs';
|
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef } from '@/lib/awardRefs';
|
||||||
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
||||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -1780,7 +1780,10 @@ export default function App() {
|
|||||||
// Stats (F1) matrix whenever the entry form is empty — clicking a past contact
|
// Stats (F1) matrix whenever the entry form is empty — clicking a past contact
|
||||||
// is the natural way to ask "what else have I got with this one?", and until
|
// is the natural way to ask "what else have I got with this one?", and until
|
||||||
// now the panel just sat blank.
|
// now the panel just sat blank.
|
||||||
const [selQso, setSelQso] = useState<{ call: string; band: string; mode: string } | null>(null);
|
// The selected row's own entity travels with it. Re-deriving it from the
|
||||||
|
// callsign is what showed Antarctica for a 3Y0K contact the log records as
|
||||||
|
// Bouvet Island — see the WorkedBefore call below.
|
||||||
|
const [selQso, setSelQso] = useState<{ call: string; band: string; mode: string; dxcc: number } | null>(null);
|
||||||
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
|
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
|
||||||
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
@@ -3752,7 +3755,19 @@ export default function App() {
|
|||||||
if (!call || callsign.trim()) { setSelWb(null); return; }
|
if (!call || callsign.trim()) { setSelWb(null); return; }
|
||||||
let dead = false;
|
let dead = false;
|
||||||
setSelWbBusy(true);
|
setSelWbBusy(true);
|
||||||
WorkedBefore(call, 0)
|
// The SELECTED QSO's own entity, not one re-derived from its callsign.
|
||||||
|
//
|
||||||
|
// Passing 0 lets the backend resolve the entity from cty.dat and the
|
||||||
|
// ClubLog exceptions AS THEY ARE TODAY, which is right for a live contact
|
||||||
|
// and wrong for one being looked at in the log. A 3Y0K contact recorded as
|
||||||
|
// Bouvet Island showed the Antarctica matrix, and counted five contacts
|
||||||
|
// "with this entity" against eleven with the call — two different entities,
|
||||||
|
// one of them nobody asked about.
|
||||||
|
//
|
||||||
|
// Browsing the log shows what the log says, even where the log is wrong.
|
||||||
|
// Correcting an entity is a deliberate act (right-click → Update from
|
||||||
|
// ClubLog), not something a panel does behind the operator's back.
|
||||||
|
WorkedBefore(call, selQso?.dxcc || 0)
|
||||||
.then((w: any) => { if (!dead) setSelWb(w); })
|
.then((w: any) => { if (!dead) setSelWb(w); })
|
||||||
.catch(() => { if (!dead) setSelWb(null); })
|
.catch(() => { if (!dead) setSelWb(null); })
|
||||||
.finally(() => { if (!dead) setSelWbBusy(false); });
|
.finally(() => { if (!dead) setSelWbBusy(false); });
|
||||||
@@ -3861,7 +3876,11 @@ export default function App() {
|
|||||||
const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
|
const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
|
||||||
setLookupBusy(true);
|
setLookupBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await LookupCallsign(call);
|
// The ENTRY'S date, so a ClubLog exception resolves at the moment the
|
||||||
|
// contact happened. Live, that is now and nothing changes; back-entering
|
||||||
|
// a past QSO, it is what makes 3Y0K come back Bouvet Island instead of
|
||||||
|
// Antarctica — the exception's window had closed by today.
|
||||||
|
const r = await LookupCallsign(call, qsoStartedAt ? qsoStartedAt.toISOString().slice(0, 10) : '');
|
||||||
// Discard a STALE result: the operator already moved to another call
|
// Discard a STALE result: the operator already moved to another call
|
||||||
// (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup
|
// (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup
|
||||||
// was in flight. Applying it would clobber the current fields and zoom the
|
// was in flight. Applying it would clobber the current fields and zoom the
|
||||||
@@ -3915,6 +3934,24 @@ export default function App() {
|
|||||||
email: d.email || (r.email ?? ''),
|
email: d.email || (r.email ?? ''),
|
||||||
web: d.web || (r.web ?? ''),
|
web: d.web || (r.web ?? ''),
|
||||||
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
||||||
|
// An island reference from the callbook becomes an IOTA award reference
|
||||||
|
// on the entry, exactly like one picked by hand.
|
||||||
|
//
|
||||||
|
// QRZ carries <iota> for an operator on an island and OpsLog read past
|
||||||
|
// it. That matters more for IOTA than it would for another award:
|
||||||
|
// unlike POTA there is no live "who is on an island right now" feed
|
||||||
|
// anywhere, so the callbook record is the practical source — and it is
|
||||||
|
// known BEFORE the contact is logged, which is when it is useful.
|
||||||
|
//
|
||||||
|
// Only when the operator has not set one already: a reference typed or
|
||||||
|
// picked by hand outranks a callbook that may be years out of date.
|
||||||
|
//
|
||||||
|
// This belongs HERE, on the provider result, and not in the backfill
|
||||||
|
// from the last QSO where it first went: that runs only when there IS a
|
||||||
|
// previous contact with the call, so the island appeared for stations
|
||||||
|
// already in the log and never for the new one on the island — which is
|
||||||
|
// the entire point of the feature.
|
||||||
|
award_refs: withIOTARef(d.award_refs ?? '', String((r as any)?.iota ?? '')),
|
||||||
}));
|
}));
|
||||||
// Backfill anything the provider didn't supply from the last time we worked
|
// Backfill anything the provider didn't supply from the last time we worked
|
||||||
// this call (call not found on QRZ/HamQTH, or lookup off → cty.dat only).
|
// this call (call not found on QRZ/HamQTH, or lookup off → cty.dat only).
|
||||||
@@ -5359,7 +5396,7 @@ export default function App() {
|
|||||||
onExportFiltered={exportFilteredADIF}
|
onExportFiltered={exportFilteredADIF}
|
||||||
onDelete={(ids) => setDeletingIds(ids)}
|
onDelete={(ids) => setDeletingIds(ids)}
|
||||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||||
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)}
|
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? ""), dxcc: Number(r.dxcc ?? 0) } : null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -6677,7 +6714,7 @@ export default function App() {
|
|||||||
onExportCabrilloFiltered={exportFilteredCabrillo}
|
onExportCabrilloFiltered={exportFilteredCabrillo}
|
||||||
onDelete={(ids) => setDeletingIds(ids)}
|
onDelete={(ids) => setDeletingIds(ids)}
|
||||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||||
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)}
|
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? ""), dxcc: Number(r.dxcc ?? 0) } : null)}
|
||||||
/>
|
/>
|
||||||
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
// scoped: the award is limited to one or more DXCC entities. Missing-reference
|
||||||
|
// detection only means anything for those — see the Missing refs button.
|
||||||
|
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[]; scoped?: boolean };
|
||||||
|
|
||||||
export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) {
|
export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -150,7 +152,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
]);
|
]);
|
||||||
const follow = new Set(tracked);
|
const follow = new Set(tracked);
|
||||||
let list: AwardListItem[] = defs
|
let list: AwardListItem[] = defs
|
||||||
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
|
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [], scoped: (d.dxcc_filter ?? []).length > 0 }))
|
||||||
.sort((a, b) => a.code.localeCompare(b.code));
|
.sort((a, b) => a.code.localeCompare(b.code));
|
||||||
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
|
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
|
||||||
setAwardList(list);
|
setAwardList(list);
|
||||||
@@ -427,6 +429,13 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||||
|
{/* Only for an award scoped to a DXCC entity. "In this award's
|
||||||
|
scope but with no reference" needs a scope to be in: on a
|
||||||
|
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
|
||||||
|
contact anywhere would qualify, so the answer is always none.
|
||||||
|
The button used to be there regardless and could only ever
|
||||||
|
open a window saying it had found nothing. */}
|
||||||
|
{awardList.find((a) => a.code === selected)?.scoped && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMissing(true)}
|
onClick={() => setShowMissing(true)}
|
||||||
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
|
className="flex items-center gap-1 text-xs text-warning-muted-foreground hover:text-warning border border-warning-border bg-warning-muted rounded px-2 py-1"
|
||||||
@@ -434,6 +443,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
>
|
>
|
||||||
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
@@ -608,6 +618,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
|
|||||||
// the missing reference (e.g. a department for DDFM).
|
// the missing reference (e.g. a department for DDFM).
|
||||||
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
|
||||||
|
|
||||||
|
// How many references the assign dropdown will put in the DOM at once. Above
|
||||||
|
// this a search box appears beside it and the rest are held back, counted.
|
||||||
|
const REF_MENU_MAX = 300;
|
||||||
|
|
||||||
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [qsos, setQsos] = useState<any[]>([]);
|
const [qsos, setQsos] = useState<any[]>([]);
|
||||||
@@ -616,6 +630,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
|
const [sortKey, setSortKey] = useState<MissingSortKey>('callsign');
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||||
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
|
const [refs, setRefs] = useState<Array<{ code: string; name: string }>>([]);
|
||||||
|
const [refSearch, setRefSearch] = useState('');
|
||||||
const [assignRef, setAssignRef] = useState('');
|
const [assignRef, setAssignRef] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
@@ -629,12 +644,38 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
useEffect(() => { load(); }, [code]);
|
useEffect(() => { load(); }, [code]);
|
||||||
// The award's reference list drives the "assign" dropdown (e.g. China provinces).
|
// The award's reference list drives the "assign" dropdown (e.g. China
|
||||||
|
// provinces) — fetched ONLY once there is something to assign it to.
|
||||||
|
//
|
||||||
|
// Missing-reference detection needs a DXCC scope, so a worldwide award like
|
||||||
|
// POTA always has zero rows here and says so. It was still loading every
|
||||||
|
// reference behind that message: on a log with the full POTA park list
|
||||||
|
// imported, tens of thousands of them, feeding a dropdown that could not be
|
||||||
|
// used for anything. That is the window freezing on a screen with nothing to
|
||||||
|
// offer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (loading || qsos.length === 0) { setRefs([]); return; }
|
||||||
ListAwardReferences(code)
|
ListAwardReferences(code)
|
||||||
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
|
.then((r) => setRefs(((r ?? []) as any[]).map((x) => ({ code: String(x.code).toUpperCase(), name: String(x.name ?? '') }))))
|
||||||
.catch(() => setRefs([]));
|
.catch(() => setRefs([]));
|
||||||
}, [code]);
|
}, [code, loading, qsos.length]);
|
||||||
|
|
||||||
|
// What the assign dropdown actually renders. Bounded, and it says how many it
|
||||||
|
// is holding back rather than silently showing the first few hundred.
|
||||||
|
const shownRefs = useMemo(() => {
|
||||||
|
const q = refSearch.trim().toUpperCase();
|
||||||
|
const rows = q
|
||||||
|
? refs.filter((r) => r.code.includes(q) || r.name.toUpperCase().includes(q))
|
||||||
|
: refs;
|
||||||
|
return rows.slice(0, REF_MENU_MAX);
|
||||||
|
}, [refs, refSearch]);
|
||||||
|
const hiddenRefs = useMemo(() => {
|
||||||
|
const q = refSearch.trim().toUpperCase();
|
||||||
|
const total = q
|
||||||
|
? refs.filter((r) => r.code.includes(q) || r.name.toUpperCase().includes(q)).length
|
||||||
|
: refs.length;
|
||||||
|
return Math.max(0, total - shownRefs.length);
|
||||||
|
}, [refs, refSearch, shownRefs.length]);
|
||||||
|
|
||||||
const qthOf = (q: any) => String(q.qth || q.notes || '');
|
const qthOf = (q: any) => String(q.qth || q.notes || '');
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
@@ -711,14 +752,29 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
|
|||||||
{/* Bulk-assign toolbar */}
|
{/* Bulk-assign toolbar */}
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
|
||||||
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
|
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
|
||||||
|
{/* A search box beside the dropdown, and a bounded list inside it.
|
||||||
|
A reference list can hold tens of thousands of entries (POTA
|
||||||
|
parks, Russian districts); every one of them as a menu item is
|
||||||
|
hundreds of thousands of DOM nodes and a window that stops
|
||||||
|
answering. Nobody scrolls to K-4521 anyway — they type it. */}
|
||||||
|
{refs.length > REF_MENU_MAX && (
|
||||||
|
<Input className="h-7 w-40 text-xs font-mono" value={refSearch}
|
||||||
|
placeholder={t('awp.filterReferences')}
|
||||||
|
onChange={(e) => setRefSearch(e.target.value)} />
|
||||||
|
)}
|
||||||
<Select value={assignRef} onValueChange={setAssignRef}>
|
<Select value={assignRef} onValueChange={setAssignRef}>
|
||||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
|
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
|
||||||
<SelectContent className="max-h-72">
|
<SelectContent className="max-h-72">
|
||||||
{refs.map((r) => (
|
{shownRefs.map((r) => (
|
||||||
<SelectItem key={r.code} value={r.code}>
|
<SelectItem key={r.code} value={r.code}>
|
||||||
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
|
<span className="font-mono font-semibold">{r.code}</span>{r.name ? <span className="text-muted-foreground"> · {r.name}</span> : ''}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
{hiddenRefs > 0 && (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||||
|
{t('awp.refsNarrow', { n: hiddenRefs })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
|
||||||
|
|||||||
@@ -374,7 +374,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
// refreshes it. A cached answer from a thinner QRZ subscription (or any
|
||||||
// stale row) otherwise stayed for its whole 30-day life and the button
|
// stale row) otherwise stayed for its whole 30-day life and the button
|
||||||
// appeared to do nothing.
|
// appeared to do nothing.
|
||||||
const r: any = await LookupCallsignFresh(call);
|
// The QSO's OWN date: a ClubLog exception is resolved as of when the
|
||||||
|
// contact happened, not as of today. Re-looking-up a DXpedition contact
|
||||||
|
// months later must not move it to whatever the prefix means now.
|
||||||
|
const r: any = await LookupCallsignFresh(call, (dateOn || '').slice(0, 10));
|
||||||
// The lookup WINS over what is in the record — that is the point of asking
|
// The lookup WINS over what is in the record — that is the point of asking
|
||||||
// for it. But an EMPTY result must never blank a good value: `??` only
|
// for it. But an EMPTY result must never blank a good value: `??` only
|
||||||
// guards against null, and Go marshals an unset string as "", so a QRZ
|
// guards against null, and Go marshals an unset string as "", so a QRZ
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
|
||||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||||
|
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
|
||||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||||
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
||||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
||||||
@@ -188,6 +189,7 @@ type SectionId =
|
|||||||
| 'external-services'
|
| 'external-services'
|
||||||
| 'udp'
|
| 'udp'
|
||||||
| 'adifmon'
|
| 'adifmon'
|
||||||
|
| 'foldersync'
|
||||||
| 'webpublish'
|
| 'webpublish'
|
||||||
| 'lookup'
|
| 'lookup'
|
||||||
| 'lists-bands'
|
| 'lists-bands'
|
||||||
@@ -242,6 +244,29 @@ function VendorMark({ vendor }: { vendor: 'o3a' }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PanelHost renders the selected settings panel, and exists so a panel may hold
|
||||||
|
// hooks of its own.
|
||||||
|
//
|
||||||
|
// The panels are nested inside SettingsModal, closing over its state — so they
|
||||||
|
// cannot be rendered as <Panel />: a nested function is a NEW component type on
|
||||||
|
// every parent render, which would unmount and remount the panel on each
|
||||||
|
// keystroke. They were therefore CALLED, `PANELS[selected]()`, and a call runs
|
||||||
|
// any hook inside them in SettingsModal's own hook list — conditionally, since
|
||||||
|
// only the open section is called. React counts those, and the window stopped
|
||||||
|
// drawing the moment such a section was opened (error #310, "rendered more
|
||||||
|
// hooks than during the previous render"). It had happened once and was headed
|
||||||
|
// off by a comment; the comment did not survive contact with the next panel.
|
||||||
|
//
|
||||||
|
// This host is module-scope, so its identity is stable and calling `render()`
|
||||||
|
// inside it puts those hooks in a component context that persists. `key` is the
|
||||||
|
// section, so switching sections REMOUNTS it — a fresh, consistent hook list per
|
||||||
|
// section, and no panel state leaking into the next one. Within a section the
|
||||||
|
// render prop is a new closure each parent render, which is how the panel keeps
|
||||||
|
// seeing current values.
|
||||||
|
function PanelHost({ render }: { render?: () => JSX.Element }) {
|
||||||
|
return render ? render() : null;
|
||||||
|
}
|
||||||
|
|
||||||
// buildTree returns the settings sidebar. The FlexRadio item only appears when
|
// buildTree returns the settings sidebar. The FlexRadio item only appears when
|
||||||
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
|
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
|
||||||
function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[] {
|
function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[] {
|
||||||
@@ -282,6 +307,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
||||||
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
||||||
|
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
|
||||||
{ kind: 'item', label: t('sec.webpublish'), id: 'webpublish' },
|
{ kind: 'item', label: t('sec.webpublish'), id: 'webpublish' },
|
||||||
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
|
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
|
||||||
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
||||||
@@ -300,6 +326,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
|||||||
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||||
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||||
adifmon: 'sec.adifmon',
|
adifmon: 'sec.adifmon',
|
||||||
|
foldersync: 'sec.foldersync',
|
||||||
webpublish: 'sec.webpublish',
|
webpublish: 'sec.webpublish',
|
||||||
uscounties: 'sec.uscounties',
|
uscounties: 'sec.uscounties',
|
||||||
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
||||||
@@ -682,6 +709,120 @@ function ADIFMonitorPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FolderSyncPanel: one operator, several PCs, one logbook through a folder they
|
||||||
|
// already synchronise (Seafile, OneDrive, Dropbox, a NAS share).
|
||||||
|
//
|
||||||
|
// The status half is the point of the panel. Every part of this runs on someone
|
||||||
|
// else's machine and on a sync client OpsLog cannot see, so "it is not working"
|
||||||
|
// has to be answerable from here: which other PCs have written to the folder,
|
||||||
|
// when each last did, and whether anything is sitting there unread.
|
||||||
|
function FolderSyncPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cfg, setCfg] = useState<{ enabled: boolean; folder: string; machine: string }>({ enabled: false, folder: '', machine: '' });
|
||||||
|
const [st, setSt] = useState<any>(null);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
GetFolderSync()
|
||||||
|
.then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, folder: c.folder ?? '', machine: c.machine ?? '' }); })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoaded(true));
|
||||||
|
}, []);
|
||||||
|
// Polled while the panel is open: a first setup is verified by watching the
|
||||||
|
// other PC appear in this list, and that happens on the sync client's clock,
|
||||||
|
// not on any action taken here.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetFolderSyncStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const h = window.setInterval(tick, 3000);
|
||||||
|
return () => { alive = false; window.clearInterval(h); };
|
||||||
|
}, []);
|
||||||
|
const save = async (next: typeof cfg) => {
|
||||||
|
setCfg(next);
|
||||||
|
setErr(''); setMsg('');
|
||||||
|
try {
|
||||||
|
await SaveFolderSync(next as any);
|
||||||
|
setMsg(t('sync.saved'));
|
||||||
|
GetFolderSyncStatus().then(setSt).catch(() => {});
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
// The switch goes back off rather than sitting on while nothing is
|
||||||
|
// written: a folder that refused the write test would lose every contact
|
||||||
|
// in silence, which is the one outcome this feature must never have.
|
||||||
|
setCfg({ ...next, enabled: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pick = async () => {
|
||||||
|
try {
|
||||||
|
const p = await PickFolderSyncFolder();
|
||||||
|
if (p) save({ ...cfg, folder: p });
|
||||||
|
} catch { /* dialog cancelled */ }
|
||||||
|
};
|
||||||
|
const syncNow = async () => {
|
||||||
|
setBusy(true); setErr(''); setMsg('');
|
||||||
|
try {
|
||||||
|
const n = await SyncFolderNow();
|
||||||
|
setMsg(t('sync.applied').replace('{n}', String(n)));
|
||||||
|
GetFolderSyncStatus().then(setSt).catch(() => {});
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
const when = (iso: string) => (iso ? new Date(iso).toLocaleString() : t('sync.never'));
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('sync.hint')}</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
|
||||||
|
{t('sync.enable')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[130px_1fr] items-center gap-2">
|
||||||
|
<span className="text-sm">{t('sync.machine')}</span>
|
||||||
|
<Input value={cfg.machine} placeholder="shack" className="h-8"
|
||||||
|
onChange={(e) => setCfg({ ...cfg, machine: e.target.value })}
|
||||||
|
onBlur={() => save(cfg)} />
|
||||||
|
<span className="text-sm">{t('sync.folder')}</span>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="flex-1 font-mono text-xs truncate" title={cfg.folder}>{cfg.folder || '—'}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={pick}><FolderOpen className="size-3.5 mr-1" /> {t('sync.choose')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <p className="text-xs text-destructive">{err}</p>}
|
||||||
|
{msg && <p className="text-xs text-success">{msg}</p>}
|
||||||
|
|
||||||
|
<SectionHeader title={t('sync.state')} />
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-3 space-y-2 text-xs">
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-muted-foreground">
|
||||||
|
<span>{t('sync.thisPc')}: <span className="font-mono text-foreground">{st?.machine_id || '—'}</span></span>
|
||||||
|
<span>{t('sync.lastSync')}: <span className="text-foreground">{when(st?.last_sync ?? '')}</span></span>
|
||||||
|
<span>{t('sync.sent')}: <span className="text-foreground">{st?.sent ?? 0}</span></span>
|
||||||
|
<span>{t('sync.received')}: <span className="text-foreground">{st?.received ?? 0}</span></span>
|
||||||
|
</div>
|
||||||
|
{st?.error && <p className="text-destructive">{st.error}</p>}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(st?.peers ?? []).length === 0 && <p className="italic text-muted-foreground">{t('sync.noPeers')}</p>}
|
||||||
|
{(st?.peers ?? []).map((p: any) => (
|
||||||
|
<div key={p.machine} className="flex items-center gap-2">
|
||||||
|
<span className="font-mono truncate">{p.machine}</span>
|
||||||
|
<span className="text-muted-foreground">{when(p.last_change)}</span>
|
||||||
|
{p.behind > 0 && <span className="text-warning">{t('sync.behind')}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={syncNow} disabled={busy || !cfg.enabled}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||||||
|
{t('sync.now')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
|
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
|
||||||
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number;
|
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number;
|
||||||
// Band-follow (ACOM): a SECOND serial port on which OpsLog answers the amp's
|
// Band-follow (ACOM): a SECOND serial port on which OpsLog answers the amp's
|
||||||
@@ -1534,8 +1675,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [eqslTesting, setEqslTesting] = useState(false);
|
const [eqslTesting, setEqslTesting] = useState(false);
|
||||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||||
// Active tab in the External Services panel — lifted here because
|
// Active tab in the External Services panel. Lifted here back when a panel
|
||||||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
// could not hold hooks at all; PanelHost lifted that restriction, and this
|
||||||
|
// stays put because moving it down would reset the tab on every reopen —
|
||||||
|
// a choice now, not a workaround.
|
||||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'pota'>('qrz');
|
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'pota'>('qrz');
|
||||||
// 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('');
|
||||||
@@ -1632,9 +1775,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
return () => { unsub?.(); };
|
return () => { unsub?.(); };
|
||||||
}, []);
|
}, []);
|
||||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||||
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
|
// State for ProfilesPanel. Lifted here back when a panel could not hold hooks
|
||||||
// the panel as a plain function, not as a JSX element, so any useState
|
// — PanelHost lifted that restriction — and left here because the selection
|
||||||
// inside the panel function would violate the Rules of Hooks.
|
// then survives switching sections, which is what an operator expects of it.
|
||||||
const [profileSelectedId, setProfileSelectedId] = useState<number>(0);
|
const [profileSelectedId, setProfileSelectedId] = useState<number>(0);
|
||||||
const [profileNameDraft, setProfileNameDraft] = useState<string>('');
|
const [profileNameDraft, setProfileNameDraft] = useState<string>('');
|
||||||
|
|
||||||
@@ -3406,13 +3549,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// register — the output on/off. The voltage and current SET points are shown
|
// register — the output on/off. The voltage and current SET points are shown
|
||||||
// because they are worth seeing, and are not editable here: they belong to the
|
// because they are worth seeing, and are not editable here: they belong to the
|
||||||
// supply's front panel, and a logbook that can set them can set them wrong.
|
// supply's front panel, and a logbook that can set them can set them wrong.
|
||||||
// NO HOOKS IN HERE. This panel is called as a plain function, like its
|
// The COM ports come from the list SettingsModal already loads for the
|
||||||
// neighbours — PANELS[x]() — so a useState of its own lands in SettingsModal's
|
// Winkeyer panel — one machine, one set of serial ports, fetched once. A hook
|
||||||
// hook list and only while this section is open. React counts those, and it
|
// of its own would be legal now (see PanelHost) and would fetch them twice.
|
||||||
// stopped drawing the moment the section was clicked (error #310, "rendered
|
|
||||||
// more hooks than during the previous render"). The COM ports come from the
|
|
||||||
// list SettingsModal already loads for the Winkeyer panel: one machine, one
|
|
||||||
// set of serial ports, loaded once.
|
|
||||||
function PSUPanelSettings() {
|
function PSUPanelSettings() {
|
||||||
const ports = wkPorts;
|
const ports = wkPorts;
|
||||||
const setPorts = setWkPorts;
|
const setPorts = setWkPorts;
|
||||||
@@ -4720,10 +4859,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
{/* No hint: everyone knows what a backup is. */}
|
||||||
title={t('sec.backup')}
|
<SectionHeader title={t('sec.backup')} />
|
||||||
hint={mysqlCfg.enabled ? t('bk.hintMysql') : t('bk.hint')}
|
|
||||||
/>
|
|
||||||
<div className="space-y-5 max-w-2xl">
|
<div className="space-y-5 max-w-2xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -5641,7 +5778,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Button variant="outline" size="sm" onClick={openExisting}><Database className="size-3.5" /> {t('db.openExisting')}</Button>
|
<Button variant="outline" size="sm" onClick={openExisting}><Database className="size-3.5" /> {t('db.openExisting')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
|
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
|
{/* Pushed right: these two act on the file that is already there,
|
||||||
|
while the four on the left change WHICH file is in use. */}
|
||||||
|
<Button variant="outline" size="sm" className="ml-auto" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
|
||||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
||||||
</div>
|
</div>
|
||||||
{/* The DB pointer is only read at startup, so offer the restart inline. */}
|
{/* The DB pointer is only read at startup, so offer the restart inline. */}
|
||||||
@@ -5674,7 +5813,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground max-w-2xl mb-3">{t('db.profileHint')}</p>
|
|
||||||
|
|
||||||
{/* Compact active-backend confirmation / MySQL-fallback warning. */}
|
{/* Compact active-backend confirmation / MySQL-fallback warning. */}
|
||||||
{backendStatus && (
|
{backendStatus && (
|
||||||
@@ -5715,7 +5853,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
{/* MySQL: shared logbook connection (multi-operator) */}
|
{/* MySQL: shared logbook connection (multi-operator) */}
|
||||||
{mysqlCfg.enabled && (
|
{mysqlCfg.enabled && (
|
||||||
<div className="space-y-3 max-w-2xl">
|
<div className="space-y-3 max-w-2xl">
|
||||||
<div className="text-[11px] text-muted-foreground leading-relaxed">{t('db.mysqlHint')}</div>
|
|
||||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||||
<Label className="text-sm">{t('db.host')}</Label>
|
<Label className="text-sm">{t('db.host')}</Label>
|
||||||
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
|
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
|
||||||
@@ -6206,12 +6343,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}}
|
}}
|
||||||
className="mt-0.5"
|
className="mt-0.5"
|
||||||
/>
|
/>
|
||||||
<span>
|
{/* No explanatory line: every operator knows what Most Wanted is. */}
|
||||||
{t('gen.mwShow')}
|
<span>{t('gen.mwShow')}</span>
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
|
||||||
{t('gen.mwDesc')}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-3 pl-6">
|
<div className="flex items-center gap-3 pl-6">
|
||||||
<Button variant="outline" size="sm" className="h-8" disabled={mwBusy}
|
<Button variant="outline" size="sm" className="h-8" disabled={mwBusy}
|
||||||
@@ -6407,10 +6540,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
'lists-modes': ModesPanel,
|
'lists-modes': ModesPanel,
|
||||||
cluster: ClusterPanel,
|
cluster: ClusterPanel,
|
||||||
udp: UDPIntegrationsPanelWrapper,
|
udp: UDPIntegrationsPanelWrapper,
|
||||||
// Rendered as a real element (not called as a bare function) so its own hooks
|
// Module-scope components, wrapped so their props can be passed. The nested
|
||||||
// — useState/useEffect/useI18n — get a proper component context; PANELS[x]()
|
// panels below go through PanelHost instead — which is what now lets either
|
||||||
// is a plain call and hook-holding panels must go through JSX like this.
|
// kind hold hooks.
|
||||||
adifmon: () => <ADIFMonitorPanel />,
|
adifmon: () => <ADIFMonitorPanel />,
|
||||||
|
foldersync: () => <FolderSyncPanel />,
|
||||||
webpublish: () => <WebPublishPanel />,
|
webpublish: () => <WebPublishPanel />,
|
||||||
relayauto: () => <RelayAutoPanel />,
|
relayauto: () => <RelayAutoPanel />,
|
||||||
backup: BackupPanel,
|
backup: BackupPanel,
|
||||||
@@ -6452,7 +6586,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
|
||||||
{breadcrumb}
|
{breadcrumb}
|
||||||
</div>
|
</div>
|
||||||
{PANELS[selected]?.()}
|
<PanelHost key={selected} render={PANELS[selected]} />
|
||||||
|
|
||||||
{err && (
|
{err && (
|
||||||
<div className="mt-6 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2 max-w-2xl">
|
<div className="mt-6 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2 max-w-2xl">
|
||||||
|
|||||||
@@ -192,3 +192,27 @@ export function spotRefList(byCode: Record<string, string>, fieldOf: Record<stri
|
|||||||
}
|
}
|
||||||
return out.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
return out.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withIOTARef adds an island reference from the callbook to an entry's award
|
||||||
|
// references, unless the operator has already set one.
|
||||||
|
//
|
||||||
|
// QRZ sends <iota> for an operator on an island and OpsLog used to read past it.
|
||||||
|
// It matters more here than it would for another award: unlike POTA there is no
|
||||||
|
// live "who is on an island right now" feed anywhere, so the callbook record is
|
||||||
|
// the practical source — and it is known before the contact is logged, which is
|
||||||
|
// when it is useful.
|
||||||
|
//
|
||||||
|
// A reference the operator typed or picked wins. A callbook entry can be years
|
||||||
|
// old, and the operator in front of the radio has just been told where the
|
||||||
|
// station is.
|
||||||
|
export function withIOTARef(awardRefs: string, iota: string): string {
|
||||||
|
const ref = (iota || '').trim().toUpperCase();
|
||||||
|
// EU-048: two letters, a hyphen, three digits. Anything else is not an IOTA
|
||||||
|
// reference, and writing it into the award would make a reference that no
|
||||||
|
// list contains — which counts for nothing and has to be found by hand later.
|
||||||
|
if (!/^[A-Z]{2}-\d{3}$/.test(ref)) return awardRefs;
|
||||||
|
const byCode = parseAwardRefs(awardRefs);
|
||||||
|
if ((byCode['IOTA'] ?? '').trim() !== '') return awardRefs; // already set: leave it
|
||||||
|
const sep = awardRefs.trim() === '' ? '' : ';';
|
||||||
|
return awardRefs + sep + 'IOTA@' + ref;
|
||||||
|
}
|
||||||
|
|||||||
+17
-14
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.25.6';
|
export const APP_VERSION = '0.25.7';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+12
-2
@@ -451,6 +451,10 @@ export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
|||||||
|
|
||||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||||
|
|
||||||
|
export function GetFolderSync():Promise<main.FolderSyncConfig>;
|
||||||
|
|
||||||
|
export function GetFolderSyncStatus():Promise<main.FolderSyncStatus>;
|
||||||
|
|
||||||
export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
|
export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
|
||||||
|
|
||||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||||
@@ -703,9 +707,9 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
|||||||
|
|
||||||
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
|
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsign(arg1:string,arg2:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsignFresh(arg1:string,arg2:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
export function MotorNudgeKHz(arg1:number):Promise<void>;
|
export function MotorNudgeKHz(arg1:number):Promise<void>;
|
||||||
|
|
||||||
@@ -775,6 +779,8 @@ export function PickAudioFolder():Promise<string>;
|
|||||||
|
|
||||||
export function PickBackupFolder():Promise<string>;
|
export function PickBackupFolder():Promise<string>;
|
||||||
|
|
||||||
|
export function PickFolderSyncFolder():Promise<string>;
|
||||||
|
|
||||||
export function PickOpenDatabase():Promise<string>;
|
export function PickOpenDatabase():Promise<string>;
|
||||||
|
|
||||||
export function PickSaveDatabase():Promise<string>;
|
export function PickSaveDatabase():Promise<string>;
|
||||||
@@ -935,6 +941,8 @@ export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Prom
|
|||||||
|
|
||||||
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
|
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveFolderSync(arg1:main.FolderSyncConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||||
@@ -1095,6 +1103,8 @@ export function StopCWDecoder():Promise<void>;
|
|||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SyncFolderNow():Promise<number>;
|
||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
export function TailLogFile(arg1:number):Promise<string>;
|
export function TailLogFile(arg1:number):Promise<string>;
|
||||||
|
|||||||
@@ -842,6 +842,14 @@ export function GetFlexState() {
|
|||||||
return window['go']['main']['App']['GetFlexState']();
|
return window['go']['main']['App']['GetFlexState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetFolderSync() {
|
||||||
|
return window['go']['main']['App']['GetFolderSync']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetFolderSyncStatus() {
|
||||||
|
return window['go']['main']['App']['GetFolderSyncStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetGridCacheStatus() {
|
export function GetGridCacheStatus() {
|
||||||
return window['go']['main']['App']['GetGridCacheStatus']();
|
return window['go']['main']['App']['GetGridCacheStatus']();
|
||||||
}
|
}
|
||||||
@@ -1346,12 +1354,12 @@ export function LogUIError(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['LogUIError'](arg1, arg2, arg3);
|
return window['go']['main']['App']['LogUIError'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LookupCallsign(arg1) {
|
export function LookupCallsign(arg1, arg2) {
|
||||||
return window['go']['main']['App']['LookupCallsign'](arg1);
|
return window['go']['main']['App']['LookupCallsign'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LookupCallsignFresh(arg1) {
|
export function LookupCallsignFresh(arg1, arg2) {
|
||||||
return window['go']['main']['App']['LookupCallsignFresh'](arg1);
|
return window['go']['main']['App']['LookupCallsignFresh'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MotorNudgeKHz(arg1) {
|
export function MotorNudgeKHz(arg1) {
|
||||||
@@ -1490,6 +1498,10 @@ export function PickBackupFolder() {
|
|||||||
return window['go']['main']['App']['PickBackupFolder']();
|
return window['go']['main']['App']['PickBackupFolder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PickFolderSyncFolder() {
|
||||||
|
return window['go']['main']['App']['PickFolderSyncFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function PickOpenDatabase() {
|
export function PickOpenDatabase() {
|
||||||
return window['go']['main']['App']['PickOpenDatabase']();
|
return window['go']['main']['App']['PickOpenDatabase']();
|
||||||
}
|
}
|
||||||
@@ -1810,6 +1822,10 @@ export function SaveFlexBandPower(arg1) {
|
|||||||
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
|
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveFolderSync(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveFolderSync'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveListsSettings(arg1) {
|
export function SaveListsSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2130,6 +2146,10 @@ export function SwitchCATRig(arg1) {
|
|||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SyncFolderNow() {
|
||||||
|
return window['go']['main']['App']['SyncFolderNow']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1, arg2) {
|
export function SyncPOTAHunterLog(arg1, arg2) {
|
||||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1444,6 +1444,7 @@ export namespace lookup {
|
|||||||
email?: string;
|
email?: string;
|
||||||
qsl_via?: string;
|
qsl_via?: string;
|
||||||
web?: string;
|
web?: string;
|
||||||
|
iota?: string;
|
||||||
zip?: string;
|
zip?: string;
|
||||||
image_url?: string;
|
image_url?: string;
|
||||||
source: string;
|
source: string;
|
||||||
@@ -1473,6 +1474,7 @@ export namespace lookup {
|
|||||||
this.email = source["email"];
|
this.email = source["email"];
|
||||||
this.qsl_via = source["qsl_via"];
|
this.qsl_via = source["qsl_via"];
|
||||||
this.web = source["web"];
|
this.web = source["web"];
|
||||||
|
this.iota = source["iota"];
|
||||||
this.zip = source["zip"];
|
this.zip = source["zip"];
|
||||||
this.image_url = source["image_url"];
|
this.image_url = source["image_url"];
|
||||||
this.source = source["source"];
|
this.source = source["source"];
|
||||||
@@ -2463,6 +2465,82 @@ export namespace main {
|
|||||||
this.body = source["body"];
|
this.body = source["body"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class FolderSyncConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
folder: string;
|
||||||
|
machine: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FolderSyncConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.folder = source["folder"];
|
||||||
|
this.machine = source["machine"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class FolderSyncPeer {
|
||||||
|
machine: string;
|
||||||
|
last_change: string;
|
||||||
|
behind: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FolderSyncPeer(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.machine = source["machine"];
|
||||||
|
this.last_change = source["last_change"];
|
||||||
|
this.behind = source["behind"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class FolderSyncStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
folder: string;
|
||||||
|
machine_id: string;
|
||||||
|
peers: FolderSyncPeer[];
|
||||||
|
last_sync: string;
|
||||||
|
sent: number;
|
||||||
|
received: number;
|
||||||
|
error: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FolderSyncStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.folder = source["folder"];
|
||||||
|
this.machine_id = source["machine_id"];
|
||||||
|
this.peers = this.convertValues(source["peers"], FolderSyncPeer);
|
||||||
|
this.last_sync = source["last_sync"];
|
||||||
|
this.sent = source["sent"];
|
||||||
|
this.received = source["received"];
|
||||||
|
this.error = source["error"];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 GridCacheStatus {
|
export class GridCacheStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
known: number;
|
known: number;
|
||||||
@@ -4546,6 +4624,7 @@ export namespace qso {
|
|||||||
my_vucc_grids?: string;
|
my_vucc_grids?: string;
|
||||||
extras?: Record<string, string>;
|
extras?: Record<string, string>;
|
||||||
award_refs?: string;
|
award_refs?: string;
|
||||||
|
sync_uid?: string;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
created_at: any;
|
created_at: any;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
@@ -4687,6 +4766,7 @@ export namespace qso {
|
|||||||
this.my_vucc_grids = source["my_vucc_grids"];
|
this.my_vucc_grids = source["my_vucc_grids"];
|
||||||
this.extras = source["extras"];
|
this.extras = source["extras"];
|
||||||
this.award_refs = source["award_refs"];
|
this.award_refs = source["award_refs"];
|
||||||
|
this.sync_uid = source["sync_uid"];
|
||||||
this.created_at = this.convertValues(source["created_at"], null);
|
this.created_at = this.convertValues(source["created_at"], null);
|
||||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every user-visible string ships in BOTH languages. That is a project rule,
|
||||||
|
// and until now it was only a rule: seventeen keys had drifted into English
|
||||||
|
// only, among them the whole update panel and two DX-cluster settings, found by
|
||||||
|
// a French operator photographing his own screen.
|
||||||
|
//
|
||||||
|
// A key present in one dictionary and not the other falls back to the key
|
||||||
|
// itself, so the interface shows "clu.spotTtl" where a label belongs — or, as
|
||||||
|
// here, the English text, which reads as deliberate and is not.
|
||||||
|
func TestEveryStringIsInBothLanguages(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("frontend/src/lib/i18n.tsx")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read i18n.tsx: %v", err)
|
||||||
|
}
|
||||||
|
en := dictKeys(t, string(src), "const en: Dict = {")
|
||||||
|
fr := dictKeys(t, string(src), "const fr: Dict = {")
|
||||||
|
|
||||||
|
if len(en) == 0 || len(fr) == 0 {
|
||||||
|
t.Fatal("one of the dictionaries came back empty — this test has stopped checking anything")
|
||||||
|
}
|
||||||
|
for k := range en {
|
||||||
|
if !fr[k] {
|
||||||
|
t.Errorf("%q has no French — a French operator sees the English string, or the key itself", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k := range fr {
|
||||||
|
if !en[k] {
|
||||||
|
t.Errorf("%q exists only in French — an English operator sees the key", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dictKeys collects the keys of one dictionary literal: from its opening line
|
||||||
|
// to the closing brace in column 0.
|
||||||
|
func dictKeys(t *testing.T, src, opening string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
i := strings.Index(src, opening)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatalf("%q not found — the dictionaries have been renamed and this test needs updating", opening)
|
||||||
|
}
|
||||||
|
body := src[i+len(opening):]
|
||||||
|
if j := strings.Index(body, "\n};"); j >= 0 {
|
||||||
|
body = body[:j]
|
||||||
|
}
|
||||||
|
keyRe := regexp.MustCompile(`'([a-zA-Z0-9_.]+)'\s*:`)
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, m := range keyRe.FindAllStringSubmatch(body, -1) {
|
||||||
|
out[m[1]] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+77
-2
@@ -48,6 +48,19 @@ type TCI struct {
|
|||||||
tx bool
|
tx bool
|
||||||
|
|
||||||
lastSig string // last logged state signature (log only on change)
|
lastSig string // last logged state signature (log only on change)
|
||||||
|
|
||||||
|
// spotFreq is the frequency of the marker currently on the panorama for each
|
||||||
|
// callsign — the panadapter's own state, which TCI never reports back. It is
|
||||||
|
// what makes one spot per call possible: without it there is no way to know
|
||||||
|
// there is an older marker to delete.
|
||||||
|
spotFreq map[string]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func absInt64(v int64) int64 {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
const tciDefaultPort = 40001
|
const tciDefaultPort = 40001
|
||||||
@@ -100,14 +113,65 @@ func (t *TCI) Connect() error {
|
|||||||
debugLog.Printf("TCI: connected to %s", url)
|
debugLog.Printf("TCI: connected to %s", url)
|
||||||
go t.reader(conn)
|
go t.reader(conn)
|
||||||
if t.spotsEnabled {
|
if t.spotsEnabled {
|
||||||
|
// Forget what we thought was on the panorama at the same moment the radio
|
||||||
|
// is told to drop it. Kept, the memory would suppress the next spot for
|
||||||
|
// each of those calls as "already drawn" onto a panorama now empty.
|
||||||
|
t.mu.Lock()
|
||||||
|
t.spotFreq = map[string]int64{}
|
||||||
|
t.mu.Unlock()
|
||||||
_ = t.send("spot_clear;") // drop any leftover spots from a previous session
|
_ = t.send("spot_clear;") // drop any leftover spots from a previous session
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// spotFreqTolHz is how far a re-spot of the same callsign may sit from the one
|
||||||
|
// already on the panorama before it is treated as a move rather than the same
|
||||||
|
// spot said again.
|
||||||
|
//
|
||||||
|
// Two spotters hearing the same CW station rarely agree to better than a couple
|
||||||
|
// of hundred hertz, and every one of them produces a cluster line. Below this
|
||||||
|
// they are the same spot and nothing is sent at all; above it the marker is
|
||||||
|
// deleted and redrawn where the station now is.
|
||||||
|
const spotFreqTolHz = 500
|
||||||
|
|
||||||
|
// noteSpot records what the panorama is about to hold for a callsign and says
|
||||||
|
// what has to be sent: whether to draw at all, and whether an older marker for
|
||||||
|
// the same call must be deleted first.
|
||||||
|
//
|
||||||
|
// Separate from SendSpot so the rule can be tested without a radio — and
|
||||||
|
// because the lock must be released before anything is sent: t.send takes t.mu
|
||||||
|
// itself, Go mutexes are not reentrant, and sending while holding it would
|
||||||
|
// deadlock the backend and take the rig offline.
|
||||||
|
func (t *TCI) noteSpot(call string, freqHz int64) (draw, deletePrev bool) {
|
||||||
|
key := strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
prev, had := t.spotFreq[key]
|
||||||
|
if had && absInt64(prev-freqHz) <= spotFreqTolHz {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
if t.spotFreq == nil {
|
||||||
|
t.spotFreq = map[string]int64{}
|
||||||
|
}
|
||||||
|
if len(t.spotFreq) > 4000 {
|
||||||
|
t.spotFreq = map[string]int64{} // bound memory on a long session
|
||||||
|
had = false // forgotten: nothing left to delete by name
|
||||||
|
}
|
||||||
|
t.spotFreq[key] = freqHz
|
||||||
|
return true, had
|
||||||
|
}
|
||||||
|
|
||||||
// SendSpot mirrors a cluster spot onto the TCI panorama (implements Spotter).
|
// SendSpot mirrors a cluster spot onto the TCI panorama (implements Spotter).
|
||||||
// The radio replaces a spot that has the same callsign, so re-spotting updates
|
// No-op when spot mirroring is disabled.
|
||||||
// it in place. No-op when spot mirroring is disabled.
|
//
|
||||||
|
// ONE MARKER PER CALLSIGN. This code assumed the radio replaced a spot carrying
|
||||||
|
// a callsign it already had; it does not. ExpertSDR keys a spot on its
|
||||||
|
// frequency too, so a DX station spotted by three operators — 14025.00,
|
||||||
|
// 14025.12, 14024.90, which is an ordinary minute on a cluster — was drawn
|
||||||
|
// three times, a few pixels apart, and stayed that way.
|
||||||
|
//
|
||||||
|
// So the previous spot for the call is deleted before the new one is sent,
|
||||||
|
// which is what the FlexRadio backend has always done (spot remove / spot add).
|
||||||
func (t *TCI) SendSpot(s SpotInfo) error {
|
func (t *TCI) SendSpot(s SpotInfo) error {
|
||||||
if !t.spotsEnabled {
|
if !t.spotsEnabled {
|
||||||
return nil
|
return nil
|
||||||
@@ -116,6 +180,17 @@ func (t *TCI) SendSpot(s SpotInfo) error {
|
|||||||
if call == "" || s.FreqHz <= 0 {
|
if call == "" || s.FreqHz <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
draw, deletePrev := t.noteSpot(call, s.FreqHz)
|
||||||
|
if !draw {
|
||||||
|
return nil // the same station said again by another spotter
|
||||||
|
}
|
||||||
|
if deletePrev {
|
||||||
|
// SPOT_DELETE takes the callsign alone. Not in the protocol PDF this
|
||||||
|
// backend was written from; confirmed against ars-ka0s/eesdr-tci, which
|
||||||
|
// lists SPOT (5 arguments), SPOT_DELETE (1) and SPOT_CLEAR (0) — the
|
||||||
|
// other two matching what already works here.
|
||||||
|
_ = t.send(fmt.Sprintf("spot_delete:%s;", call))
|
||||||
|
}
|
||||||
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
|
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
|
||||||
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
|
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
|
||||||
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
|
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The reported symptom: with spot mirroring on, the same station appeared two
|
||||||
|
// or three times on the panorama.
|
||||||
|
//
|
||||||
|
// Its cause is not in OpsLog's spot pipeline — one cluster line produces one
|
||||||
|
// SendSpot. It is that a popular DX station IS spotted two or three times, by
|
||||||
|
// different operators within the same minute, and no two of them agree on the
|
||||||
|
// frequency to better than a few tens of hertz. The backend assumed ExpertSDR
|
||||||
|
// replaced a spot bearing a callsign it already had; it keys on the frequency
|
||||||
|
// too, so each of those became its own marker.
|
||||||
|
func TestSameStationSpottedBySeveralOperatorsIsDrawnOnce(t *testing.T) {
|
||||||
|
tci := &TCI{spotsEnabled: true}
|
||||||
|
|
||||||
|
draw, del := tci.noteSpot("UN7GK", 14025000)
|
||||||
|
if !draw || del {
|
||||||
|
t.Fatalf("first spot: draw=%v delete=%v, want draw and nothing to delete", draw, del)
|
||||||
|
}
|
||||||
|
// The same station, two more spotters, a few tens of hertz apart.
|
||||||
|
for _, hz := range []int64{14025120, 14024900} {
|
||||||
|
if draw, del := tci.noteSpot("UN7GK", hz); draw || del {
|
||||||
|
t.Errorf("re-spot at %d Hz: draw=%v delete=%v, want nothing sent — this is the duplicate marker", hz, draw, del)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A station that really moves must still move on the panorama, and the marker
|
||||||
|
// left where it was must go. Deleting first is the whole difference between
|
||||||
|
// "the spot follows the station" and "the station collects markers".
|
||||||
|
func TestAStationThatMovesReplacesItsMarker(t *testing.T) {
|
||||||
|
tci := &TCI{spotsEnabled: true}
|
||||||
|
tci.noteSpot("UN7GK", 14025000)
|
||||||
|
|
||||||
|
draw, del := tci.noteSpot("UN7GK", 14032000) // 7 kHz up: a real QSY
|
||||||
|
if !draw || !del {
|
||||||
|
t.Fatalf("after a QSY: draw=%v delete=%v, want the old marker deleted and a new one drawn", draw, del)
|
||||||
|
}
|
||||||
|
// And the new position becomes the reference, so spotters agreeing with it
|
||||||
|
// are quiet again.
|
||||||
|
if draw, _ := tci.noteSpot("UN7GK", 14032100); draw {
|
||||||
|
t.Error("a spot at the station's new frequency was drawn again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case matters nowhere in ham radio, and the cluster is not consistent about it.
|
||||||
|
func TestSpotMemoryIgnoresCase(t *testing.T) {
|
||||||
|
tci := &TCI{spotsEnabled: true}
|
||||||
|
tci.noteSpot("un7gk", 14025000)
|
||||||
|
if draw, _ := tci.noteSpot("UN7GK", 14025000); draw {
|
||||||
|
t.Error("the same call in another case was treated as a different station")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two different stations are two markers — the whole point of the panorama.
|
||||||
|
func TestDifferentStationsEachGetAMarker(t *testing.T) {
|
||||||
|
tci := &TCI{spotsEnabled: true}
|
||||||
|
tci.noteSpot("UN7GK", 14025000)
|
||||||
|
draw, del := tci.noteSpot("ZD7BG", 14025050) // 50 Hz away, a different operator
|
||||||
|
if !draw {
|
||||||
|
t.Error("a second station near the first was swallowed as a duplicate")
|
||||||
|
}
|
||||||
|
if del {
|
||||||
|
t.Error("deleting by callsign would have removed a spot this station never had")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The connection drops and comes back: Connect sends spot_clear, so the
|
||||||
|
// panorama is empty. If the memory survived that, the next spot for each of
|
||||||
|
// those calls would be suppressed as "already drawn" onto an empty panorama —
|
||||||
|
// the operator's spots would simply stop appearing until they changed
|
||||||
|
// frequency.
|
||||||
|
func TestReconnectingForgetsWhatWasDrawn(t *testing.T) {
|
||||||
|
tci := &TCI{spotsEnabled: true}
|
||||||
|
tci.noteSpot("UN7GK", 14025000)
|
||||||
|
|
||||||
|
tci.mu.Lock()
|
||||||
|
tci.spotFreq = map[string]int64{} // what Connect does alongside spot_clear
|
||||||
|
tci.mu.Unlock()
|
||||||
|
|
||||||
|
if draw, del := tci.noteSpot("UN7GK", 14025000); !draw || del {
|
||||||
|
t.Errorf("after a reconnect: draw=%v delete=%v, want it drawn again and nothing deleted", draw, del)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A TEXT column that an index is built on must be listed in varcharColumns.
|
||||||
|
//
|
||||||
|
// MySQL cannot index a TEXT column without a prefix length: the migration dies
|
||||||
|
// with error 1170 — and it dies again on every startup afterwards, leaving the
|
||||||
|
// operator with a logbook that will not connect and no way forward from the
|
||||||
|
// interface. The translator only emits VARCHAR for the names in varcharColumns,
|
||||||
|
// and that list is maintained by hand.
|
||||||
|
//
|
||||||
|
// So: read the migrations, work out which columns are TEXT, find which are
|
||||||
|
// indexed, and insist the list covers the overlap. Integer columns are indexed
|
||||||
|
// perfectly well and are none of this test's business.
|
||||||
|
func TestIndexedTextColumnsAreVarchar(t *testing.T) {
|
||||||
|
files, err := migrationsFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// name → declared type, from "ADD COLUMN name TYPE" and from the column
|
||||||
|
// lines inside a CREATE TABLE body.
|
||||||
|
declared := map[string]string{}
|
||||||
|
reAdd := regexp.MustCompile(`(?i)ADD\s+COLUMN\s+` + "`" + `?(\w+)` + "`" + `?\s+(\w+)`)
|
||||||
|
reCol := regexp.MustCompile(`(?im)^\s*` + "`" + `?(\w+)` + "`" + `?\s+(TEXT|VARCHAR|INTEGER|INT|REAL|BLOB|DATETIME|BOOLEAN)\b`)
|
||||||
|
reIdx := regexp.MustCompile(`(?i)CREATE\s+INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?\S+\s+ON\s+\S+\s*\(([^)]*)\)`)
|
||||||
|
|
||||||
|
var indexed []struct{ file, col string }
|
||||||
|
for _, f := range files {
|
||||||
|
if f.IsDir() || !strings.HasSuffix(f.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := migrationsFS.ReadFile("migrations/" + f.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", f.Name(), err)
|
||||||
|
}
|
||||||
|
src := string(body)
|
||||||
|
for _, m := range reAdd.FindAllStringSubmatch(src, -1) {
|
||||||
|
declared[strings.ToLower(m[1])] = strings.ToUpper(m[2])
|
||||||
|
}
|
||||||
|
for _, m := range reCol.FindAllStringSubmatch(src, -1) {
|
||||||
|
if _, seen := declared[strings.ToLower(m[1])]; !seen {
|
||||||
|
declared[strings.ToLower(m[1])] = strings.ToUpper(m[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range reIdx.FindAllStringSubmatch(src, -1) {
|
||||||
|
for _, col := range strings.Split(m[1], ",") {
|
||||||
|
col = strings.TrimSpace(col)
|
||||||
|
if col == "" || strings.Contains(col, "(") {
|
||||||
|
continue // a prefix length or an expression is MySQL-safe already
|
||||||
|
}
|
||||||
|
col = strings.Trim(col, "`\"")
|
||||||
|
if i := strings.IndexAny(col, " \t"); i > 0 {
|
||||||
|
col = col[:i] // "col DESC"
|
||||||
|
}
|
||||||
|
indexed = append(indexed, struct{ file, col string }{f.Name(), strings.ToLower(col)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(indexed) == 0 || len(declared) == 0 {
|
||||||
|
t.Fatal("nothing parsed out of the migrations — this test has stopped checking anything")
|
||||||
|
}
|
||||||
|
|
||||||
|
textIndexed := 0
|
||||||
|
for _, ix := range indexed {
|
||||||
|
typ, known := declared[ix.col]
|
||||||
|
if !known || typ != "TEXT" {
|
||||||
|
continue // an integer index, or a column this test could not type
|
||||||
|
}
|
||||||
|
textIndexed++
|
||||||
|
if !varcharColumns[ix.col] {
|
||||||
|
t.Errorf("%s indexes the TEXT column %q, which is not in varcharColumns — "+
|
||||||
|
"on MySQL that migration fails with error 1170 and the logbook stops connecting for good",
|
||||||
|
ix.file, ix.col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if textIndexed == 0 {
|
||||||
|
t.Fatal("no indexed TEXT column found — the parsing has drifted and this test checks nothing")
|
||||||
|
}
|
||||||
|
t.Logf("%d indexed TEXT column(s) checked against varcharColumns", textIndexed)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- A stable identity per contact, for folder-based synchronisation.
|
||||||
|
--
|
||||||
|
-- Two PCs exchanging changes through a shared folder need to name the SAME
|
||||||
|
-- contact in both logs. "the QSO with M0ABC at 14:32" is a guess, and the two
|
||||||
|
-- machines can disagree about which row that is — so an edit or a deletion
|
||||||
|
-- cannot be addressed at all without an identity that travels with the record.
|
||||||
|
--
|
||||||
|
-- A REAL COLUMN, indexed, rather than a key inside extras_json. The identity is
|
||||||
|
-- looked up once per incoming change, and on a 120 000-QSO logbook scanning
|
||||||
|
-- JSON for it would turn every sync into a full table read. A column costs one
|
||||||
|
-- migration; the JSON would cost a scan every time.
|
||||||
|
--
|
||||||
|
-- sync_uid is listed in varcharColumns (internal/db/mysql.go): MySQL cannot
|
||||||
|
-- index a TEXT column without a prefix length, and a migration that tries dies
|
||||||
|
-- with error 1170 on every startup thereafter, with no way out from the UI.
|
||||||
|
--
|
||||||
|
-- Empty on every existing row, and it STAYS empty on most of them. The change
|
||||||
|
-- log starts empty too, so contacts logged before synchronisation was switched
|
||||||
|
-- on are never exchanged: nothing has to be copied across, and an identity is
|
||||||
|
-- stamped only on a contact that is actually logged, edited or deleted from
|
||||||
|
-- then on. Seeding a second machine with the existing log is a one-time copy of
|
||||||
|
-- the database or an ADIF import, not something synchronisation does.
|
||||||
|
ALTER TABLE qso ADD COLUMN sync_uid TEXT NOT NULL DEFAULT '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qso_sync_uid ON qso (sync_uid);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- The lookup cache gains the IOTA reference QRZ was already sending.
|
||||||
|
--
|
||||||
|
-- QRZ's XML carries <iota>EU-048</iota> for an operator on an island, and
|
||||||
|
-- nothing read it — so the one place an IOTA reference can be known BEFORE the
|
||||||
|
-- contact is logged was thrown away on every lookup. There is no live "who is
|
||||||
|
-- on an island right now" feed anywhere (unlike POTA), which makes the callbook
|
||||||
|
-- record the practical source.
|
||||||
|
ALTER TABLE callsign_cache ADD COLUMN iota TEXT;
|
||||||
@@ -67,6 +67,10 @@ var varcharColumns = map[string]bool{
|
|||||||
"callsign": true, "qso_date": true, "band": true, "mode": true,
|
"callsign": true, "qso_date": true, "band": true, "mode": true,
|
||||||
"grid": true, "station_callsign": true, "state": true, "contest_id": true,
|
"grid": true, "station_callsign": true, "state": true, "contest_id": true,
|
||||||
"sat_name": true, "prop_mode": true, "sig": true, "wwff_ref": true, "skcc": true,
|
"sat_name": true, "prop_mode": true, "sig": true, "wwff_ref": true, "skcc": true,
|
||||||
|
"sync_uid": true, // qso index (0029) — folder-sync identity, looked up per record
|
||||||
|
// A new indexed TEXT column MUST be added here. TestIndexedTextColumnsAreVarchar
|
||||||
|
// reads the migrations and fails if one is missing, because the alternative is
|
||||||
|
// a MySQL logbook that stops connecting for good on error 1170.
|
||||||
// integrations_udp index (0011)
|
// integrations_udp index (0011)
|
||||||
"direction": true,
|
"direction": true,
|
||||||
// award_references composite primary key (0017)
|
// award_references composite primary key (0017)
|
||||||
|
|||||||
@@ -46,6 +46,51 @@ street, no image.
|
|||||||
</Callsign>
|
</Callsign>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
<QRZDatabase xmlns="http://xmldata.qrz.com" version="1.36">
|
||||||
|
<div id="in-page-channel-node-id" data-channel-name="in_page_channel_ogW01U"/>
|
||||||
|
<Callsign>
|
||||||
|
<call>F5IRH</call>
|
||||||
|
<dxcc>227</dxcc>
|
||||||
|
<fname>AVRILLON</fname>
|
||||||
|
<name>Max</name>
|
||||||
|
<addr1>La Grand Prairie</addr1>
|
||||||
|
<addr2>Le Palais BELLE-ILE-EN-MER</addr2>
|
||||||
|
<zip>56360</zip>
|
||||||
|
<country>France</country>
|
||||||
|
<lat>47.339686</lat>
|
||||||
|
<lon>-3.156500</lon>
|
||||||
|
<grid>IN87ki</grid>
|
||||||
|
<ccode>97</ccode>
|
||||||
|
<land>France</land>
|
||||||
|
<codes>TP</codes>
|
||||||
|
<qslmgr>VIA BURO</qslmgr>
|
||||||
|
<email>[email protected]</email>
|
||||||
|
<u_views>8005</u_views>
|
||||||
|
<bio>2463</bio>
|
||||||
|
<biodate>2015-07-16 00:29:49</biodate>
|
||||||
|
<image>https://cdn-xml.qrz.com/h/f5irh/qsl_F5IRH_111-3.JPG</image>
|
||||||
|
<imageinfo>518:799:105108</imageinfo>
|
||||||
|
<moddate>2010-08-05 01:05:37</moddate>
|
||||||
|
<eqsl>0</eqsl>
|
||||||
|
<mqsl>0</mqsl>
|
||||||
|
<cqzone>14</cqzone>
|
||||||
|
<iota>EU-048</iota>
|
||||||
|
<lotw>0</lotw>
|
||||||
|
<geoloc>user</geoloc>
|
||||||
|
<name_fmt>AVRILLON Max</name_fmt>
|
||||||
|
<serial>1570698</serial>
|
||||||
|
</Callsign>
|
||||||
|
<Session>
|
||||||
|
<Key>e5ca5b3e7f88d733408ab7677e605270</Key>
|
||||||
|
<Count>161639</Count>
|
||||||
|
<SubExp>Sat Jul 3 21:45:38 2027</SubExp>
|
||||||
|
<GMTime>Sun Aug 16 21:14:40 2026</GMTime>
|
||||||
|
<Remark>cpu: 0.073s</Remark>
|
||||||
|
</Session>
|
||||||
|
</QRZDatabase>
|
||||||
|
```
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- **A free QRZ account can never fill the locator, the coordinates, the zones
|
- **A free QRZ account can never fill the locator, the coordinates, the zones
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package lookup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testCache(t *testing.T) *Cache {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := db.Open(filepath.Join(t.TempDir(), "c.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { conn.Close() })
|
||||||
|
return NewCache(conn, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding a field to the cache leaves every row already in it without that field,
|
||||||
|
// and the cache lasts thirty days. So a callsign looked up before the change
|
||||||
|
// would go a MONTH without its island reference — which is exactly what the
|
||||||
|
// first test of the feature ran into: a QRZ record plainly carrying
|
||||||
|
// <iota>EU-048</iota>, and no IOTA on the entry.
|
||||||
|
//
|
||||||
|
// A row that predates the column is therefore treated as stale and refetched
|
||||||
|
// once. NULL and "" mean different things here, and that is the whole mechanism.
|
||||||
|
func TestCacheRefetchesRowsWrittenBeforeTheIOTAColumn(t *testing.T) {
|
||||||
|
c := testCache(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// An operator with an island: stored and returned.
|
||||||
|
if err := c.Put(ctx, Result{Callsign: "F5IRH", Name: "Max", IOTA: "EU-048", Source: "qrz"}); err != nil {
|
||||||
|
t.Fatalf("put: %v", err)
|
||||||
|
}
|
||||||
|
got, ok := c.Get(ctx, "F5IRH")
|
||||||
|
if !ok || got.IOTA != "EU-048" {
|
||||||
|
t.Fatalf("Get = (%+v,%v), want the island back", got, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An operator with NO island: an empty string is stored, and the row stays
|
||||||
|
// usable. If this wrote NULL, every ordinary callsign would refetch for ever.
|
||||||
|
if err := c.Put(ctx, Result{Callsign: "M0ABC", Name: "Ann", Source: "qrz"}); err != nil {
|
||||||
|
t.Fatalf("put: %v", err)
|
||||||
|
}
|
||||||
|
got, ok = c.Get(ctx, "M0ABC")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("a callsign with no island was treated as stale — every lookup would repeat for ever")
|
||||||
|
}
|
||||||
|
if got.IOTA != "" {
|
||||||
|
t.Errorf("IOTA = %q for an operator with no island", got.IOTA)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ type Result struct {
|
|||||||
// along and nothing ever filled it, because no provider mapping read the
|
// along and nothing ever filled it, because no provider mapping read the
|
||||||
// field.
|
// field.
|
||||||
Web string `json:"web,omitempty"`
|
Web string `json:"web,omitempty"`
|
||||||
|
// IOTA is the island reference (EU-048) for an operator on one. QRZ sends it
|
||||||
|
// and nothing used to read it — and since no live activation feed exists for
|
||||||
|
// IOTA the way it does for POTA, the callbook record is the practical source.
|
||||||
|
IOTA string `json:"iota,omitempty"`
|
||||||
// Zip is the postal code. HamQTH and QRZ both send one.
|
// Zip is the postal code. HamQTH and QRZ both send one.
|
||||||
Zip string `json:"zip,omitempty"`
|
Zip string `json:"zip,omitempty"`
|
||||||
ImageURL string `json:"image_url,omitempty"` // profile picture URL
|
ImageURL string `json:"image_url,omitempty"` // profile picture URL
|
||||||
@@ -452,13 +456,13 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
|||||||
row := c.db.QueryRowContext(ctx, `
|
row := c.db.QueryRowContext(ctx, `
|
||||||
SELECT callsign, name, qth, address, state, cnty, country, grid,
|
SELECT callsign, name, qth, address, state, cnty, country, grid,
|
||||||
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
||||||
web, zip, source, fetched_at
|
web, zip, iota, source, fetched_at
|
||||||
FROM callsign_cache WHERE callsign = ?`, callsign)
|
FROM callsign_cache WHERE callsign = ?`, callsign)
|
||||||
var (
|
var (
|
||||||
r Result
|
r Result
|
||||||
name, qth, addr, state, cnty sql.NullString
|
name, qth, addr, state, cnty sql.NullString
|
||||||
country, grid, cont, email, qslVia, image sql.NullString
|
country, grid, cont, email, qslVia, image sql.NullString
|
||||||
web, zip sql.NullString
|
web, zip, iotaRef sql.NullString
|
||||||
src string
|
src string
|
||||||
dxcc, cqz, ituz sql.NullInt64
|
dxcc, cqz, ituz sql.NullInt64
|
||||||
lat, lon sql.NullFloat64
|
lat, lon sql.NullFloat64
|
||||||
@@ -466,7 +470,7 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
|||||||
)
|
)
|
||||||
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
|
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
|
||||||
&country, &grid, &lat, &lon,
|
&country, &grid, &lat, &lon,
|
||||||
&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image, &web, &zip,
|
&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image, &web, &zip, &iotaRef,
|
||||||
&src, &fetched); err != nil {
|
&src, &fetched); err != nil {
|
||||||
return Result{}, false
|
return Result{}, false
|
||||||
}
|
}
|
||||||
@@ -477,6 +481,17 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
|||||||
if time.Since(t) > c.ttl {
|
if time.Since(t) > c.ttl {
|
||||||
return Result{}, false
|
return Result{}, false
|
||||||
}
|
}
|
||||||
|
// A row written before the iota column existed has NULL there, and the cache
|
||||||
|
// lasts thirty days — so without this every callsign already looked up would
|
||||||
|
// go a month without its island reference, which is exactly what the first
|
||||||
|
// test of the feature ran into.
|
||||||
|
//
|
||||||
|
// NULL and '' are deliberately different here: Put writes an empty string for
|
||||||
|
// an operator with no island, so only a row that predates the column reads as
|
||||||
|
// invalid. One refetch per such callsign, the next time it is used.
|
||||||
|
if !iotaRef.Valid {
|
||||||
|
return Result{}, false
|
||||||
|
}
|
||||||
r.Name = name.String
|
r.Name = name.String
|
||||||
r.QTH = qth.String
|
r.QTH = qth.String
|
||||||
r.Address = addr.String
|
r.Address = addr.String
|
||||||
@@ -490,6 +505,7 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
|||||||
r.Email = email.String
|
r.Email = email.String
|
||||||
r.Web = web.String
|
r.Web = web.String
|
||||||
r.Zip = zip.String
|
r.Zip = zip.String
|
||||||
|
r.IOTA = strings.ToUpper(iotaRef.String)
|
||||||
r.QSLVia = qslVia.String
|
r.QSLVia = qslVia.String
|
||||||
r.ImageURL = image.String
|
r.ImageURL = image.String
|
||||||
r.DXCC = int(dxcc.Int64)
|
r.DXCC = int(dxcc.Int64)
|
||||||
@@ -506,7 +522,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
|||||||
updateCols := []string{
|
updateCols := []string{
|
||||||
"name", "qth", "address", "state", "cnty",
|
"name", "qth", "address", "state", "cnty",
|
||||||
"country", "grid", "lat", "lon",
|
"country", "grid", "lat", "lon",
|
||||||
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url", "web", "zip",
|
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url", "web", "zip", "iota",
|
||||||
"source", "fetched_at",
|
"source", "fetched_at",
|
||||||
}
|
}
|
||||||
// The lookup cache always lives in the local SQLite database, so SQLite
|
// The lookup cache always lives in the local SQLite database, so SQLite
|
||||||
@@ -519,8 +535,8 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
|||||||
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
|
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
|
||||||
country, grid, lat, lon,
|
country, grid, lat, lon,
|
||||||
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
||||||
web, zip, source, fetched_at)
|
web, zip, iota, source, fetched_at)
|
||||||
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?, ?,?)
|
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?,?, ?,?)
|
||||||
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
|
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
|
||||||
_, err := c.db.ExecContext(ctx, q,
|
_, err := c.db.ExecContext(ctx, q,
|
||||||
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
|
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
|
||||||
@@ -530,6 +546,9 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
|||||||
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
||||||
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
||||||
nullable(r.ImageURL), nullable(r.Web), nullable(r.Zip),
|
nullable(r.ImageURL), nullable(r.Web), nullable(r.Zip),
|
||||||
|
// NOT nullable(): an operator with no island must store '', so that a NULL
|
||||||
|
// keeps its one meaning — a row written before the column existed.
|
||||||
|
r.IOTA,
|
||||||
r.Source, db.NowISO(),
|
r.Source, db.NowISO(),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, e
|
|||||||
Email: c.Email,
|
Email: c.Email,
|
||||||
QSLVia: c.QSLMgr,
|
QSLVia: c.QSLMgr,
|
||||||
ImageURL: strings.TrimSpace(c.Image),
|
ImageURL: strings.TrimSpace(c.Image),
|
||||||
|
IOTA: strings.ToUpper(strings.TrimSpace(c.IOTA)),
|
||||||
}
|
}
|
||||||
r.Lat, _ = strconv.ParseFloat(c.Lat, 64)
|
r.Lat, _ = strconv.ParseFloat(c.Lat, 64)
|
||||||
r.Lon, _ = strconv.ParseFloat(c.Lon, 64)
|
r.Lon, _ = strconv.ParseFloat(c.Lon, 64)
|
||||||
@@ -193,6 +194,7 @@ type qrzCallsign struct {
|
|||||||
Email string `xml:"email"`
|
Email string `xml:"email"`
|
||||||
QSLMgr string `xml:"qslmgr"`
|
QSLMgr string `xml:"qslmgr"`
|
||||||
Image string `xml:"image"` // direct URL to the profile picture (subscribers only on QRZ)
|
Image string `xml:"image"` // direct URL to the profile picture (subscribers only on QRZ)
|
||||||
|
IOTA string `xml:"iota"` // island reference for an operator on one, e.g. EU-048
|
||||||
}
|
}
|
||||||
|
|
||||||
// composeQRZAddress builds a multi-line postal address from QRZ's separate
|
// composeQRZAddress builds a multi-line postal address from QRZ's separate
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package lookup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QRZ sends the island reference and OpsLog read past it.
|
||||||
|
//
|
||||||
|
// It matters more for IOTA than it would for another award: unlike POTA there
|
||||||
|
// is no live "who is on an island right now" feed anywhere, so the callbook
|
||||||
|
// record is the practical source — and it is known BEFORE the contact is
|
||||||
|
// logged, which is when it is useful.
|
||||||
|
func TestQRZCallsignCarriesIOTA(t *testing.T) {
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<QRZDatabase version="1.34">
|
||||||
|
<Callsign>
|
||||||
|
<call>F5IRH</call>
|
||||||
|
<fname>Max</fname>
|
||||||
|
<country>France</country>
|
||||||
|
<grid>IN87ki</grid>
|
||||||
|
<cqzone>14</cqzone>
|
||||||
|
<iota>EU-048</iota>
|
||||||
|
</Callsign>
|
||||||
|
<Session><Key>abc</Key></Session>
|
||||||
|
</QRZDatabase>`
|
||||||
|
|
||||||
|
var resp qrzDB
|
||||||
|
if err := xml.Unmarshal([]byte(body), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Callsign.IOTA != "EU-048" {
|
||||||
|
t.Fatalf("the <iota> tag decoded as %q — the field is not being read", resp.Callsign.IOTA)
|
||||||
|
}
|
||||||
|
// And a record with no island must not invent one.
|
||||||
|
var none qrzDB
|
||||||
|
if err := xml.Unmarshal([]byte(strings.Replace(body, "<iota>EU-048</iota>", "", 1)), &none); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if none.Callsign.IOTA != "" {
|
||||||
|
t.Errorf("IOTA = %q for a record without one", none.Callsign.IOTA)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,32 @@ package qso
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
// baseCall picks the operator's OWN callsign out of a portable form. The slash
|
||||||
|
// carries a qualifier on either side and which side depends on what it is, so
|
||||||
|
// this is the whole difficulty.
|
||||||
|
func TestBaseCall(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"RK3DWA": "RK3DWA",
|
||||||
|
"RK3DWA/3": "RK3DWA", // call-area digit
|
||||||
|
"RK3DWA/P": "RK3DWA", // portable
|
||||||
|
"RK3DWA/QRP": "RK3DWA",
|
||||||
|
"RK3DWA/MM": "RK3DWA",
|
||||||
|
"ZA/OE8NDR": "OE8NDR", // an Austrian in Albania — the case that prompted this
|
||||||
|
"ZA/IZ2DPX": "IZ2DPX",
|
||||||
|
"F/DL1ABC": "DL1ABC",
|
||||||
|
"F/DL1ABC/P": "DL1ABC",
|
||||||
|
"KH6/K6ABC": "K6ABC",
|
||||||
|
"VP2E/W1ABC": "W1ABC",
|
||||||
|
"3DA0/ZS1ABC": "ZS1ABC",
|
||||||
|
"9A/S51AB": "S51AB",
|
||||||
|
"/RK3DWA": "RK3DWA",
|
||||||
|
} {
|
||||||
|
if got := baseCall(in); got != want {
|
||||||
|
t.Errorf("baseCall(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The predicate behind "Worked before". Exact when folding is off; with it on,
|
// The predicate behind "Worked before". Exact when folding is off; with it on,
|
||||||
// a station's portable forms are one operator — and the fold has to work from
|
// a station's portable forms are one operator — and the fold has to work from
|
||||||
// either end, because you may type the base call or the portable one.
|
// either end, because you may type the base call or the portable one.
|
||||||
@@ -10,25 +36,61 @@ func TestCallMatch(t *testing.T) {
|
|||||||
t.Errorf("exact: got %q %v", pred, args)
|
t.Errorf("exact: got %q %v", pred, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typing the base call: match it and everything suffixed off it.
|
// Typing the base call: match it, everything suffixed off it, and every
|
||||||
pred, args := callMatch("RK3DWA", true)
|
// prefixed form of it.
|
||||||
if pred != "(callsign = ? OR callsign LIKE ?)" {
|
_, args := callMatch("RK3DWA", true)
|
||||||
t.Errorf("variants predicate = %q", pred)
|
want := []any{"RK3DWA", "RK3DWA/%", "%/RK3DWA", "%/RK3DWA/%"}
|
||||||
}
|
assertArgs(t, "base call", args, want)
|
||||||
if len(args) != 2 || args[0] != "RK3DWA" || args[1] != "RK3DWA/%" {
|
|
||||||
t.Errorf("variants args = %v, want [RK3DWA RK3DWA/%%]", args)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typing a portable form must reach the plain call too — the suffix is
|
// Typing a portable form must reach the plain call too — the qualifier is
|
||||||
// stripped from the INPUT, not just matched in the column.
|
// stripped from the INPUT, not just matched in the column.
|
||||||
_, args = callMatch("RK3DWA/3", true)
|
_, args = callMatch("RK3DWA/3", true)
|
||||||
if len(args) != 2 || args[0] != "RK3DWA" || args[1] != "RK3DWA/%" {
|
assertArgs(t, "portable input", args, want)
|
||||||
t.Errorf("portable input args = %v, want [RK3DWA RK3DWA/%%]", args)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A leading slash is not a suffix marker — dropping to "" there would match
|
// A prefixed call is the same operator, and NOT every other visitor to that
|
||||||
// the entire logbook.
|
// country.
|
||||||
if _, args := callMatch("/RK3DWA", true); args[0] != "/RK3DWA" {
|
//
|
||||||
t.Errorf("leading slash: args[0] = %v, want the call unchanged", args[0])
|
// Matching on the part before the slash made ZA/OE8NDR the station "ZA", so the
|
||||||
|
// worked-before list for one Austrian operating from Albania showed every other
|
||||||
|
// ZA/ guest — two Italians — and counted them as four contacts "with this
|
||||||
|
// call". Reported from a screenshot of exactly that.
|
||||||
|
func TestCallMatchPrefixedIsTheOperatorNotTheCountry(t *testing.T) {
|
||||||
|
_, args := callMatch("ZA/OE8NDR", true)
|
||||||
|
assertArgs(t, "ZA/OE8NDR", args, []any{"OE8NDR", "OE8NDR/%", "%/OE8NDR", "%/OE8NDR/%"})
|
||||||
|
|
||||||
|
for _, a := range args {
|
||||||
|
if s, _ := a.(string); s == "ZA" || s == "ZA/%" {
|
||||||
|
t.Fatalf("the country prefix is still being matched as a station: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the two are not each other: nothing in one operator's predicate can
|
||||||
|
// select the other's call.
|
||||||
|
_, other := callMatch("ZA/IZ2DPX", true)
|
||||||
|
assertArgs(t, "ZA/IZ2DPX", other, []any{"IZ2DPX", "IZ2DPX/%", "%/IZ2DPX", "%/IZ2DPX/%"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A base too short to be a callsign falls back to an exact match. "F/DL1ABC"
|
||||||
|
// resolves fine, but a malformed entry must never produce a LIKE that selects
|
||||||
|
// half the logbook.
|
||||||
|
func TestCallMatchRefusesAShortBase(t *testing.T) {
|
||||||
|
for _, call := range []string{"F/", "9A", "/P", "K/M"} {
|
||||||
|
pred, args := callMatch(call, true)
|
||||||
|
if pred != "callsign = ?" || len(args) != 1 {
|
||||||
|
t.Errorf("%q produced %q %v — want an exact match", call, pred, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertArgs(t *testing.T, what string, got, want []any) {
|
||||||
|
t.Helper()
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("%s: args %v, want %v", what, got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("%s: args %v, want %v", what, got, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-8
@@ -215,6 +215,12 @@ type QSO struct {
|
|||||||
// that doesn't know about it can't clobber it.
|
// that doesn't know about it can't clobber it.
|
||||||
AwardRefs string `json:"award_refs,omitempty"`
|
AwardRefs string `json:"award_refs,omitempty"`
|
||||||
|
|
||||||
|
// SyncUID is this contact's stable identity for folder synchronisation —
|
||||||
|
// what lets another machine name the same QSO when it edits or deletes it.
|
||||||
|
// Like AwardRefs it is read here but NOT in columnList, so an ordinary edit
|
||||||
|
// can never clobber it; it is written only by SetSyncUID / BackfillSyncUIDs.
|
||||||
|
SyncUID string `json:"sync_uid,omitempty"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -271,7 +277,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
|
|||||||
// award_refs is read here but is NOT part of columnList (the insert/update
|
// award_refs is read here but is NOT part of columnList (the insert/update
|
||||||
// write path) — it is a derived cache written only via SetAwardRefs, so a
|
// write path) — it is a derived cache written only via SetAwardRefs, so a
|
||||||
// normal QSO write can never clobber it.
|
// normal QSO write can never clobber it.
|
||||||
const selectCols = `id, ` + columnList + `, award_refs, created_at, updated_at`
|
const selectCols = `id, ` + columnList + `, award_refs, sync_uid, created_at, updated_at`
|
||||||
|
|
||||||
// columnCount is derived from columnList at init so they can never drift.
|
// columnCount is derived from columnList at init so they can never drift.
|
||||||
var columnCount = countColumns(columnList)
|
var columnCount = countColumns(columnList)
|
||||||
@@ -1723,26 +1729,77 @@ type BandMode struct {
|
|||||||
// rendering a recent-contacts mini-list.
|
// rendering a recent-contacts mini-list.
|
||||||
const maxWorkedEntries = 50
|
const maxWorkedEntries = 50
|
||||||
|
|
||||||
|
// operatorSuffixes are the appendages that qualify a callsign without changing
|
||||||
|
// whose it is. A bare digit (RK3DWA/3) counts too, handled separately.
|
||||||
|
var operatorSuffixes = map[string]bool{
|
||||||
|
"P": true, "M": true, "MM": true, "AM": true, "QRP": true,
|
||||||
|
"A": true, "B": true, "J": true, "LH": true, "R": true, "T": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseCall extracts the operator's OWN callsign from a portable form.
|
||||||
|
//
|
||||||
|
// The slash carries the qualifier on either side, and which side depends on
|
||||||
|
// what it is: RK3DWA/3 and RK3DWA/P append to the call, while ZA/OE8NDR and
|
||||||
|
// F/DL1ABC put a country prefix in front of it. Taking the part before the
|
||||||
|
// slash — which is what this used to do — reads ZA/OE8NDR as the station "ZA".
|
||||||
|
//
|
||||||
|
// So: drop the known qualifiers, and of what is left take the longest part. A
|
||||||
|
// prefix is short by nature (ZA, F, KH6, VP2E) and a callsign is not.
|
||||||
|
func baseCall(call string) string {
|
||||||
|
if !strings.Contains(call, "/") {
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
best := ""
|
||||||
|
for _, p := range strings.Split(call, "/") {
|
||||||
|
if p == "" || operatorSuffixes[p] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(p) == 1 && p[0] >= '0' && p[0] <= '9' {
|
||||||
|
continue // the call-area digit
|
||||||
|
}
|
||||||
|
if len(p) > len(best) {
|
||||||
|
best = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == "" {
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
// callMatch builds the WHERE fragment that selects one station's QSOs.
|
// callMatch builds the WHERE fragment that selects one station's QSOs.
|
||||||
//
|
//
|
||||||
// Exact by default. With variants on, an operator's portable forms count as the
|
// Exact by default. With variants on, an operator's portable forms count as the
|
||||||
// same station: RK3DWA, RK3DWA/3, RK3DWA/P and RK3DWA/QRP are one person, and
|
// same station: RK3DWA, RK3DWA/3, RK3DWA/P and RK3DWA/QRP are one person, and
|
||||||
// someone asking "have I worked RK3DWA?" means the person, not the string.
|
// someone asking "have I worked RK3DWA?" means the person, not the string.
|
||||||
// Typing 21 QSOs' worth of history only when you happen to add "/3" is the
|
// Typing 21 QSOs' worth of history only when you happen to add "/3" is the
|
||||||
// behaviour this replaces. The suffix is stripped from what was TYPED too, so
|
// behaviour this replaces.
|
||||||
// it matches both ways round — RK3DWA/3 also finds the plain RK3DWA contacts.
|
//
|
||||||
|
// PREFIXED FORMS MATCH TOO, and getting that wrong is what prompted this.
|
||||||
|
// Matching on the part before the slash turned ZA/OE8NDR into the station "ZA",
|
||||||
|
// so the worked-before list for one Austrian operating from Albania showed
|
||||||
|
// every OTHER visitor to Albania — two Italians and himself — and counted them
|
||||||
|
// as four contacts "with this call".
|
||||||
//
|
//
|
||||||
// Deliberately NOT a bare "starts with": LIKE 'RK3DWA%' would also drag in
|
// Deliberately NOT a bare "starts with": LIKE 'RK3DWA%' would also drag in
|
||||||
// RK3DWAB, which is a different station. The '/' is what makes it the same one.
|
// RK3DWAB, which is a different station. The '/' is what makes it the same one.
|
||||||
|
// And the variant match is only used on a base of three characters or more —
|
||||||
|
// below that a prefix could match half the log.
|
||||||
func callMatch(call string, variants bool) (string, []any) {
|
func callMatch(call string, variants bool) (string, []any) {
|
||||||
if !variants {
|
if !variants {
|
||||||
return "callsign = ?", []any{call}
|
return "callsign = ?", []any{call}
|
||||||
}
|
}
|
||||||
base := call
|
base := baseCall(call)
|
||||||
if i := strings.IndexByte(base, '/'); i > 0 {
|
if len(base) < 3 {
|
||||||
base = base[:i]
|
return "callsign = ?", []any{call}
|
||||||
|
}
|
||||||
|
return "(callsign = ? OR callsign LIKE ? OR callsign LIKE ? OR callsign LIKE ?)",
|
||||||
|
[]any{
|
||||||
|
base, // OE8NDR
|
||||||
|
base + "/%", // OE8NDR/P
|
||||||
|
"%/" + base, // ZA/OE8NDR
|
||||||
|
"%/" + base + "/%", // ZA/OE8NDR/P
|
||||||
}
|
}
|
||||||
return "(callsign = ? OR callsign LIKE ?)", []any{base, base + "/%"}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkedBefore returns aggregated history at both callsign and DXCC level.
|
// WorkedBefore returns aggregated history at both callsign and DXCC level.
|
||||||
@@ -3025,6 +3082,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
myARRLSect, myVUCCGrids sql.NullString
|
myARRLSect, myVUCCGrids sql.NullString
|
||||||
extrasJSON sql.NullString
|
extrasJSON sql.NullString
|
||||||
awardRefs sql.NullString
|
awardRefs sql.NullString
|
||||||
|
syncUID sql.NullString
|
||||||
createdStr, updatedStr string
|
createdStr, updatedStr string
|
||||||
)
|
)
|
||||||
if err := s.Scan(
|
if err := s.Scan(
|
||||||
@@ -3052,7 +3110,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
|
||||||
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
&darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
|
||||||
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
|
||||||
&extrasJSON, &awardRefs, &createdStr, &updatedStr,
|
&extrasJSON, &awardRefs, &syncUID, &createdStr, &updatedStr,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
return QSO{}, fmt.Errorf("scan qso: %w", err)
|
||||||
}
|
}
|
||||||
@@ -3252,6 +3310,7 @@ func scanQSO(s scanner) (QSO, error) {
|
|||||||
q.MyVUCCGrids = myVUCCGrids.String
|
q.MyVUCCGrids = myVUCCGrids.String
|
||||||
q.Extras = decodeExtras(extrasJSON.String)
|
q.Extras = decodeExtras(extrasJSON.String)
|
||||||
q.AwardRefs = awardRefs.String
|
q.AwardRefs = awardRefs.String
|
||||||
|
q.SyncUID = syncUID.String
|
||||||
return q, nil
|
return q, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3362,3 +3421,67 @@ func (r *Repo) OrderedIDs(ctx context.Context) ([]int64, time.Time, error) {
|
|||||||
// Parsed once, for the newest row only — the ordering came from SQL.
|
// Parsed once, for the newest row only — the ordering came from SQL.
|
||||||
return out, parseTimeLoose(lastDate), nil
|
return out, parseTimeLoose(lastDate), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Folder synchronisation identity -----------------------------------------
|
||||||
|
|
||||||
|
// SetSyncUID stamps a contact's sync identity. Targeted UPDATE: it must never
|
||||||
|
// go through the normal write path, or an ordinary edit would clobber it.
|
||||||
|
func (r *Repo) SetSyncUID(ctx context.Context, id int64, uid string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `UPDATE qso SET sync_uid = ? WHERE id = ?`, uid, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDBySyncUID resolves an incoming change to a local row. Indexed, so this is
|
||||||
|
// the one lookup a sync performs per record and it stays a key hit rather than
|
||||||
|
// a scan — which is the whole reason sync_uid is a column and not a JSON key.
|
||||||
|
func (r *Repo) IDBySyncUID(ctx context.Context, uid string) (int64, bool, error) {
|
||||||
|
if uid == "" {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
err := r.db.QueryRowContext(ctx, `SELECT id FROM qso WHERE sync_uid = ? LIMIT 1`, uid).Scan(&id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return id, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDByDedupeKey finds the local row a change refers to when its identity is
|
||||||
|
// unknown here.
|
||||||
|
//
|
||||||
|
// The change log starts EMPTY, so contacts logged before synchronisation was
|
||||||
|
// switched on are never exchanged — nothing has to be copied across, and
|
||||||
|
// identities are stamped lazily on the contacts that are actually touched.
|
||||||
|
//
|
||||||
|
// One case still needs this. Two machines can already hold the SAME old contact
|
||||||
|
// — the second was seeded by copying the database or importing an ADIF — with
|
||||||
|
// different row ids and no identity on either. The day one of them edits that
|
||||||
|
// contact, it stamps an identity and sends a change naming it; the other has
|
||||||
|
// never seen that identity and would insert a duplicate. Matching on the
|
||||||
|
// contact itself (callsign + minute + band + mode, the importer's own dedupe
|
||||||
|
// key) recognises it instead.
|
||||||
|
//
|
||||||
|
// Looked up only when an identity is unknown, which is rare, and it goes
|
||||||
|
// through idx_qso_callsign rather than scanning — a full map of every contact,
|
||||||
|
// rebuilt each pass, was the wrong shape for something this occasional.
|
||||||
|
func (r *Repo) IDByDedupeKey(ctx context.Context, callsign, qsoDateMinute, band, mode string) (id int64, uid string, found bool, err error) {
|
||||||
|
if callsign == "" || qsoDateMinute == "" {
|
||||||
|
return 0, "", false, nil
|
||||||
|
}
|
||||||
|
var u sql.NullString
|
||||||
|
err = r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, sync_uid FROM qso
|
||||||
|
WHERE callsign = ? AND substr(qso_date, 1, 16) = ? AND band = ? AND mode = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
strings.ToUpper(callsign), qsoDateMinute, band, mode).Scan(&id, &u)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", false, err
|
||||||
|
}
|
||||||
|
return id, u.String, true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openRepo gives a migrated, empty logbook on disk.
|
||||||
|
func openRepo(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { conn.Close() })
|
||||||
|
return NewRepo(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addQSO(t *testing.T, r *Repo, call string, when time.Time) int64 {
|
||||||
|
t.Helper()
|
||||||
|
id, err := r.Add(context.Background(), QSO{
|
||||||
|
Callsign: call, QSODate: when, Band: "20m", Mode: "CW",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert %s: %v", call, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// A QSO written and read back must come out whole. selectCols and scanQSO are
|
||||||
|
// two hand-maintained lists that must line up column for column, and adding
|
||||||
|
// sync_uid touched both — a drift there fails every read at runtime, which no
|
||||||
|
// compiler catches.
|
||||||
|
func TestSyncUIDRoundTrips(t *testing.T) {
|
||||||
|
r := openRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id := addQSO(t, r, "M0ABC", time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
got, err := r.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if got.Callsign != "M0ABC" {
|
||||||
|
t.Fatalf("read back %+v", got)
|
||||||
|
}
|
||||||
|
if got.SyncUID != "" {
|
||||||
|
t.Errorf("a fresh QSO has identity %q — it should have none until sync is switched on", got.SyncUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.SetSyncUID(ctx, id, "abc123"); err != nil {
|
||||||
|
t.Fatalf("SetSyncUID: %v", err)
|
||||||
|
}
|
||||||
|
got, _ = r.GetByID(ctx, id)
|
||||||
|
if got.SyncUID != "abc123" {
|
||||||
|
t.Errorf("SyncUID = %q after stamping", got.SyncUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ordinary edit must NEVER clobber the identity. sync_uid is deliberately
|
||||||
|
// outside columnList for this reason: another machine that has already seen the
|
||||||
|
// contact addresses it by that id, and losing it makes the same QSO arrive
|
||||||
|
// again as a new one.
|
||||||
|
func TestAnEditDoesNotClobberTheIdentity(t *testing.T) {
|
||||||
|
r := openRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id := addQSO(t, r, "M0ABC", time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC))
|
||||||
|
if err := r.SetSyncUID(ctx, id, "keepme"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q, _ := r.GetByID(ctx, id)
|
||||||
|
q.Name = "Edited"
|
||||||
|
q.SyncUID = "" // exactly what a caller that knows nothing about sync sends
|
||||||
|
if err := r.Update(ctx, q); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := r.GetByID(ctx, id)
|
||||||
|
if got.Name != "Edited" {
|
||||||
|
t.Errorf("the edit did not take: %+v", got)
|
||||||
|
}
|
||||||
|
if got.SyncUID != "keepme" {
|
||||||
|
t.Errorf("SyncUID = %q — an edit wiped the sync identity", got.SyncUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lookup an incoming change performs, once per record.
|
||||||
|
func TestIDBySyncUID(t *testing.T) {
|
||||||
|
r := openRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id := addQSO(t, r, "M0ABC", time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC))
|
||||||
|
_ = r.SetSyncUID(ctx, id, "u-1")
|
||||||
|
|
||||||
|
got, ok, err := r.IDBySyncUID(ctx, "u-1")
|
||||||
|
if err != nil || !ok || got != id {
|
||||||
|
t.Fatalf("IDBySyncUID = (%d,%v,%v), want (%d,true,nil)", got, ok, err, id)
|
||||||
|
}
|
||||||
|
if _, ok, _ := r.IDBySyncUID(ctx, "nope"); ok {
|
||||||
|
t.Error("an unknown identity was resolved")
|
||||||
|
}
|
||||||
|
// An empty id must never match the rows that have none.
|
||||||
|
if _, ok, _ := r.IDBySyncUID(ctx, ""); ok {
|
||||||
|
t.Error("the empty identity matched a row — every un-stamped QSO would be that row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two machines can already hold the SAME old contact — the second was seeded by
|
||||||
|
// copying the database or importing an ADIF — with different row ids and no
|
||||||
|
// identity on either. The day one of them edits it, it stamps an identity and
|
||||||
|
// sends a change naming it; the other has never seen that identity and would
|
||||||
|
// insert a duplicate. Matching on the contact itself recognises it instead.
|
||||||
|
func TestIDByDedupeKeyRecognisesTheSameContact(t *testing.T) {
|
||||||
|
r := openRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)
|
||||||
|
id := addQSO(t, r, "M0ABC", when)
|
||||||
|
|
||||||
|
minute := when.UTC().Format("2006-01-02T15:04")
|
||||||
|
gotID, uid, found, err := r.IDByDedupeKey(ctx, "M0ABC", minute, "20m", "CW")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("IDByDedupeKey = (%d,%q,%v,%v), want it found", gotID, uid, found, err)
|
||||||
|
}
|
||||||
|
if gotID != id {
|
||||||
|
t.Errorf("resolved to id %d, want %d", gotID, id)
|
||||||
|
}
|
||||||
|
if uid != "" {
|
||||||
|
t.Errorf("uid = %q, want empty until something stamps it", uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A contact this machine does not have must NOT match something else.
|
||||||
|
if _, _, found, _ := r.IDByDedupeKey(ctx, "M0ABC", minute, "40m", "CW"); found {
|
||||||
|
t.Error("a different band matched — the key must be all four parts")
|
||||||
|
}
|
||||||
|
if _, _, found, _ := r.IDByDedupeKey(ctx, "M0XYZ", minute, "20m", "CW"); found {
|
||||||
|
t.Error("a different callsign matched")
|
||||||
|
}
|
||||||
|
// An empty key must never match anything.
|
||||||
|
if _, _, found, _ := r.IDByDedupeKey(ctx, "", "", "", ""); found {
|
||||||
|
t.Error("an empty key matched a row")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
// Package syncfolder keeps one operator's logbook in step across several PCs
|
||||||
|
// through a folder they already have — Seafile, OneDrive, Dropbox, a NAS share,
|
||||||
|
// a USB stick.
|
||||||
|
//
|
||||||
|
// WHY NOT THE DATABASE FILE ITSELF. Because it corrupts. SQLite relies on
|
||||||
|
// advisory file locks that SMB and NFS implement partially or cache, so two
|
||||||
|
// machines can both believe they hold the lock; and in WAL mode — which OpsLog
|
||||||
|
// uses — the shared-memory index (-shm) has no meaning across machines at all.
|
||||||
|
// A cloud folder is worse again: it replicates the file whole while it is open,
|
||||||
|
// and .db / .db-wal / .db-shm drift apart, giving a database that opens
|
||||||
|
// perfectly and is silently missing the last few hours.
|
||||||
|
//
|
||||||
|
// THE RULE THAT MAKES A SHARED FOLDER SAFE: one writer per file, append only.
|
||||||
|
// Each machine writes ONLY its own <machine>.ndjson and never touches another's.
|
||||||
|
// A sync tool that replicates whole files can therefore never merge two writers
|
||||||
|
// into one file, because there are never two writers. This is the exact opposite
|
||||||
|
// of putting the database there, and it is why it works.
|
||||||
|
//
|
||||||
|
// WHAT THIS IS NOT. Not live. Two operators logging the same contest second by
|
||||||
|
// second want the shared MySQL logbook, which OpsLog already does; that is a
|
||||||
|
// different need and it stays. This is for ONE operator with a shack PC, a
|
||||||
|
// laptop and a portable rig — the case where a server running day and night to
|
||||||
|
// serve forty QSOs a month is the wrong shape.
|
||||||
|
//
|
||||||
|
// It is the other half of internal/offlineq, whose own doc says it is
|
||||||
|
// "deliberately NOT a sync engine: no mirror, no pull, no merge, no tombstones".
|
||||||
|
// Those four are precisely what is here.
|
||||||
|
package syncfolder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DirName is the sub-folder created inside whatever the operator picked. A
|
||||||
|
// dedicated folder, so pointing OpsLog at a documents directory by mistake does
|
||||||
|
// not scatter files through it.
|
||||||
|
const DirName = "opslog-sync"
|
||||||
|
|
||||||
|
// FormatVersion is stamped on every record. A future OpsLog that changes the
|
||||||
|
// shape can then recognise — and skip — what it does not understand, instead of
|
||||||
|
// misreading it. Records from the future are skipped, never guessed at.
|
||||||
|
const FormatVersion = 1
|
||||||
|
|
||||||
|
// Op is what happened to a contact.
|
||||||
|
type Op string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpAdd Op = "add"
|
||||||
|
OpUpdate Op = "update"
|
||||||
|
// OpDelete is a TOMBSTONE, and it is the reason this is a change log rather
|
||||||
|
// than a pile of ADIF. Without one, a QSO deleted on the laptop comes
|
||||||
|
// straight back on the next sync from the shack PC, for ever.
|
||||||
|
OpDelete Op = "delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record is one line of a machine's file. NDJSON: one object per line, appended,
|
||||||
|
// never rewritten — so a half-written line at the tail costs one record, not the
|
||||||
|
// file, and a reader can resume from a byte offset.
|
||||||
|
type Record struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Op Op `json:"op"`
|
||||||
|
UID string `json:"uid"` // the contact's stable identity
|
||||||
|
At time.Time `json:"at"` // when this CHANGE was made, UTC
|
||||||
|
Seq uint64 `json:"seq"` // this machine's own counter, monotonic
|
||||||
|
By string `json:"by"` // machine id that wrote it
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUID mints a contact's identity.
|
||||||
|
//
|
||||||
|
// A stable id per contact is what lets an edit or a deletion be addressed at
|
||||||
|
// all: "the QSO with M0ABC at 14:32" is a guess, and two machines can disagree
|
||||||
|
// about which row that is. OpsLog already mints one for the offline outbox
|
||||||
|
// (APP_OPSLOG_QUEUEID); this is the same idea, kept for the life of the record.
|
||||||
|
func NewUID() string {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMachineID mints this installation's id, once. The name is the operator's
|
||||||
|
// (they may call it "shack" or "portable"); the suffix keeps two machines named
|
||||||
|
// the same from writing to one file.
|
||||||
|
func NewMachineID(name string) string {
|
||||||
|
var b [4]byte
|
||||||
|
_, _ = rand.Read(b[:])
|
||||||
|
n := sanitiseName(name)
|
||||||
|
if n == "" {
|
||||||
|
n = "opslog"
|
||||||
|
}
|
||||||
|
return n + "-" + hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitiseName keeps a machine id usable as a FILENAME on every platform the
|
||||||
|
// folder may be synced across — a Windows name written to a Linux NAS and back.
|
||||||
|
func sanitiseName(s string) string {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '-' || r == '_' || r == ' ':
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), "-")
|
||||||
|
for strings.Contains(out, "--") {
|
||||||
|
out = strings.ReplaceAll(out, "--", "-")
|
||||||
|
}
|
||||||
|
if len(out) > 24 {
|
||||||
|
out = strings.Trim(out[:24], "-")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wins decides between two changes to the same contact.
|
||||||
|
//
|
||||||
|
// Last writer wins, but ordered on (At, Seq, By) rather than on time alone.
|
||||||
|
// Two PCs' clocks are never exactly equal and one may be minutes out, so a bare
|
||||||
|
// timestamp comparison is not even deterministic: two machines merging the same
|
||||||
|
// pair in different orders could reach different answers and then disagree for
|
||||||
|
// ever. Adding the writer's own counter and finally its id makes the order
|
||||||
|
// total — every machine reaches the same conclusion from the same records,
|
||||||
|
// whatever sequence they arrive in.
|
||||||
|
//
|
||||||
|
// Clock skew still decides WHICH edit wins, and nothing here can fix that. What
|
||||||
|
// it guarantees is that all machines agree on the winner.
|
||||||
|
func Wins(a, b Record) bool {
|
||||||
|
if !a.At.Equal(b.At) {
|
||||||
|
return a.At.After(b.At)
|
||||||
|
}
|
||||||
|
if a.Seq != b.Seq {
|
||||||
|
return a.Seq > b.Seq
|
||||||
|
}
|
||||||
|
return a.By > b.By
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is one machine's view of the shared folder.
|
||||||
|
type Store struct {
|
||||||
|
root string // the folder the operator picked
|
||||||
|
machineID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a store. root is the operator's chosen folder; the package
|
||||||
|
// creates and uses its own sub-folder inside it.
|
||||||
|
func New(root, machineID string) *Store {
|
||||||
|
return &Store{root: strings.TrimSpace(root), machineID: machineID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dir is where the files live.
|
||||||
|
func (s *Store) Dir() string { return filepath.Join(s.root, DirName) }
|
||||||
|
|
||||||
|
// MyFile is the only file this machine ever writes.
|
||||||
|
func (s *Store) MyFile() string { return filepath.Join(s.Dir(), s.machineID+".ndjson") }
|
||||||
|
|
||||||
|
// Append adds one record to this machine's file.
|
||||||
|
//
|
||||||
|
// Opened, written and closed per call, with O_APPEND: a sync client that
|
||||||
|
// uploads the file between two contacts sees a complete file every time, and a
|
||||||
|
// process killed mid-write loses at most the line it was writing.
|
||||||
|
func (s *Store) Append(rec Record) error {
|
||||||
|
if s.root == "" {
|
||||||
|
return fmt.Errorf("syncfolder: no folder configured")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(s.Dir(), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("syncfolder: create %s: %w", s.Dir(), err)
|
||||||
|
}
|
||||||
|
rec.V = FormatVersion
|
||||||
|
rec.By = s.machineID
|
||||||
|
if rec.At.IsZero() {
|
||||||
|
rec.At = time.Now().UTC()
|
||||||
|
}
|
||||||
|
rec.At = rec.At.UTC()
|
||||||
|
line, err := json.Marshal(rec)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("syncfolder: encode: %w", err)
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(s.MyFile(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("syncfolder: open %s: %w", s.MyFile(), err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||||
|
return fmt.Errorf("syncfolder: write: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peer is another machine's file and how far this one has read it.
|
||||||
|
type Peer struct {
|
||||||
|
MachineID string
|
||||||
|
Path string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peers lists the other machines' files, skipping this machine's own.
|
||||||
|
func (s *Store) Peers() ([]Peer, error) {
|
||||||
|
if s.root == "" {
|
||||||
|
return nil, fmt.Errorf("syncfolder: no folder configured")
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(s.Dir())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil // nobody has written anything yet
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Peer
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".ndjson") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := strings.TrimSuffix(e.Name(), ".ndjson")
|
||||||
|
if id == s.machineID {
|
||||||
|
continue // never read our own back — that is how a loop starts
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Peer{MachineID: id, Path: filepath.Join(s.Dir(), e.Name()), Size: info.Size()})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFrom returns the records in a peer's file after byte offset `from`, and
|
||||||
|
// the offset to resume at next time.
|
||||||
|
//
|
||||||
|
// Resuming by byte offset is what keeps a sync cheap: a file with 40 000
|
||||||
|
// contacts is read once, and thereafter only its tail. The returned offset
|
||||||
|
// advances ONLY past complete lines — a file caught mid-upload ends in a
|
||||||
|
// partial line, and stopping short of it means the next pass reads that record
|
||||||
|
// whole instead of discarding it.
|
||||||
|
func ReadFrom(path string, from int64) ([]Record, int64, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, from, err
|
||||||
|
}
|
||||||
|
// A file that SHRANK was replaced, not appended to — a sync conflict copy,
|
||||||
|
// a restore, a machine id reused. Start again rather than read from an
|
||||||
|
// offset that now points into the middle of a different record.
|
||||||
|
if int64(len(data)) < from {
|
||||||
|
from = 0
|
||||||
|
}
|
||||||
|
tail := data[from:]
|
||||||
|
var recs []Record
|
||||||
|
consumed := int64(0)
|
||||||
|
for {
|
||||||
|
i := indexByte(tail, '\n')
|
||||||
|
if i < 0 {
|
||||||
|
break // an incomplete final line: leave it for next time
|
||||||
|
}
|
||||||
|
line := tail[:i]
|
||||||
|
tail = tail[i+1:]
|
||||||
|
consumed += int64(i) + 1
|
||||||
|
if len(strings.TrimSpace(string(line))) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec Record
|
||||||
|
if err := json.Unmarshal(line, &rec); err != nil {
|
||||||
|
continue // one unreadable line must not stop the file
|
||||||
|
}
|
||||||
|
if rec.V > FormatVersion {
|
||||||
|
continue // written by a newer OpsLog: skip, never guess
|
||||||
|
}
|
||||||
|
if rec.UID == "" || rec.Op == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recs = append(recs, rec)
|
||||||
|
}
|
||||||
|
return recs, from + consumed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexByte(b []byte, c byte) int {
|
||||||
|
for i := range b {
|
||||||
|
if b[i] == c {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge reduces a batch of records to ONE decision per contact — the winner.
|
||||||
|
//
|
||||||
|
// Applying every record in turn would work but would write the same row several
|
||||||
|
// times over, and on a first sync of a large log that is thousands of pointless
|
||||||
|
// updates. It also makes the result independent of the order the peers'
|
||||||
|
// files happened to be read in.
|
||||||
|
func Merge(recs []Record) map[string]Record {
|
||||||
|
out := make(map[string]Record, len(recs))
|
||||||
|
for _, r := range recs {
|
||||||
|
cur, seen := out[r.UID]
|
||||||
|
if !seen || Wins(r, cur) {
|
||||||
|
out[r.UID] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package syncfolder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func rec(uid string, op Op, at string, seq uint64, by string) Record {
|
||||||
|
t, _ := time.Parse(time.RFC3339, at)
|
||||||
|
return Record{V: FormatVersion, Op: op, UID: uid, At: t, Seq: seq, By: by}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine must never read its own file back. That is how a change loops
|
||||||
|
// round the folder for ever, each pass re-applying what this machine wrote.
|
||||||
|
func TestPeersExcludesOurselves(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
s := New(root, "shack-aabbccdd")
|
||||||
|
if err := s.Append(rec("u1", OpAdd, "2026-08-16T10:00:00Z", 1, "")); err != nil {
|
||||||
|
t.Fatalf("append: %v", err)
|
||||||
|
}
|
||||||
|
must(t, os.WriteFile(filepath.Join(s.Dir(), "laptop-11223344.ndjson"), []byte("{}\n"), 0o644))
|
||||||
|
|
||||||
|
peers, err := s.Peers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("peers: %v", err)
|
||||||
|
}
|
||||||
|
if len(peers) != 1 || peers[0].MachineID != "laptop-11223344" {
|
||||||
|
t.Fatalf("peers = %+v — our own file must not be among them", peers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading resumes from a byte offset, and only ever advances past COMPLETE
|
||||||
|
// lines. A folder sync catches a file mid-upload sooner or later, and the
|
||||||
|
// partial last line must be read whole on the next pass, not thrown away.
|
||||||
|
func TestReadFromStopsAtAnIncompleteLine(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "peer.ndjson")
|
||||||
|
|
||||||
|
full, _ := json.Marshal(rec("u1", OpAdd, "2026-08-16T10:00:00Z", 1, "peer"))
|
||||||
|
partial, _ := json.Marshal(rec("u2", OpAdd, "2026-08-16T10:01:00Z", 2, "peer"))
|
||||||
|
must(t, os.WriteFile(path, append(append(full, '\n'), partial[:len(partial)/2]...), 0o644))
|
||||||
|
|
||||||
|
recs, off, err := ReadFrom(path, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFrom: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 1 || recs[0].UID != "u1" {
|
||||||
|
t.Fatalf("got %d record(s) %+v, want just u1", len(recs), recs)
|
||||||
|
}
|
||||||
|
if off != int64(len(full))+1 {
|
||||||
|
t.Fatalf("offset %d, want %d — it must stop before the partial line", off, len(full)+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The upload completes; the second record is now read whole.
|
||||||
|
must(t, os.WriteFile(path, append(append(append(full, '\n'), partial...), '\n'), 0o644))
|
||||||
|
recs, off2, err := ReadFrom(path, off)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFrom 2: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 1 || recs[0].UID != "u2" {
|
||||||
|
t.Fatalf("resumed with %+v, want just u2", recs)
|
||||||
|
}
|
||||||
|
if off2 <= off {
|
||||||
|
t.Errorf("offset did not advance: %d → %d", off, off2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file that shrank was replaced, not appended to — a sync conflict copy, a
|
||||||
|
// restore. Resuming at the old offset would read from the middle of a record.
|
||||||
|
func TestReadFromRestartsIfTheFileShrank(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "peer.ndjson")
|
||||||
|
line, _ := json.Marshal(rec("u1", OpAdd, "2026-08-16T10:00:00Z", 1, "peer"))
|
||||||
|
must(t, os.WriteFile(path, append(line, '\n'), 0o644))
|
||||||
|
|
||||||
|
recs, _, err := ReadFrom(path, 999999)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFrom: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 1 {
|
||||||
|
t.Errorf("got %d record(s) — a shrunken file must be re-read from the start", len(recs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One unreadable line must not cost the rest of the file, and a record from a
|
||||||
|
// FUTURE format must be skipped rather than guessed at.
|
||||||
|
func TestReadFromSurvivesRubbish(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "peer.ndjson")
|
||||||
|
good, _ := json.Marshal(rec("u1", OpAdd, "2026-08-16T10:00:00Z", 1, "peer"))
|
||||||
|
future, _ := json.Marshal(Record{V: FormatVersion + 1, Op: OpAdd, UID: "u9", At: time.Now().UTC()})
|
||||||
|
body := append([]byte("this is not json\n"), append(good, '\n')...)
|
||||||
|
body = append(body, append(future, '\n')...)
|
||||||
|
body = append(body, []byte("\n")...) // a blank line
|
||||||
|
must(t, os.WriteFile(path, body, 0o644))
|
||||||
|
|
||||||
|
recs, _, err := ReadFrom(path, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFrom: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 1 || recs[0].UID != "u1" {
|
||||||
|
t.Fatalf("got %+v, want only the one readable current-format record", recs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every machine must reach the SAME winner from the same records, whatever
|
||||||
|
// order they arrive in. Ordering on time alone is not even deterministic: two
|
||||||
|
// PCs' clocks are never equal, and a tie would be resolved differently on each
|
||||||
|
// machine, which is how two logs disagree for ever.
|
||||||
|
func TestMergeIsOrderIndependent(t *testing.T) {
|
||||||
|
a := rec("u1", OpUpdate, "2026-08-16T10:00:00Z", 5, "shack")
|
||||||
|
b := rec("u1", OpDelete, "2026-08-16T10:00:00Z", 5, "laptop") // same time AND seq
|
||||||
|
c := rec("u1", OpUpdate, "2026-08-16T09:00:00Z", 9, "shack") // older, higher seq
|
||||||
|
|
||||||
|
forward := Merge([]Record{a, b, c})
|
||||||
|
reverse := Merge([]Record{c, b, a})
|
||||||
|
if forward["u1"].By != reverse["u1"].By || forward["u1"].Op != reverse["u1"].Op {
|
||||||
|
t.Fatalf("order changed the winner: %+v vs %+v", forward["u1"], reverse["u1"])
|
||||||
|
}
|
||||||
|
// Time beats sequence: c is an hour older whatever its counter says.
|
||||||
|
if forward["u1"].At.Equal(c.At) {
|
||||||
|
t.Error("an older change won on its counter — time is the first key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A delete is a record like any other, and it must be able to WIN. Without
|
||||||
|
// tombstones a QSO removed on one machine returns on the next sync from
|
||||||
|
// another, for ever.
|
||||||
|
func TestATombstoneCanWin(t *testing.T) {
|
||||||
|
add := rec("u1", OpAdd, "2026-08-16T10:00:00Z", 1, "shack")
|
||||||
|
del := rec("u1", OpDelete, "2026-08-16T11:00:00Z", 1, "laptop")
|
||||||
|
if got := Merge([]Record{add, del})["u1"]; got.Op != OpDelete {
|
||||||
|
t.Errorf("winner is %q — a later deletion must beat an earlier add", got.Op)
|
||||||
|
}
|
||||||
|
// …and an add made AFTER a deletion wins, so re-logging a contact works.
|
||||||
|
readd := rec("u1", OpAdd, "2026-08-16T12:00:00Z", 2, "laptop")
|
||||||
|
if got := Merge([]Record{add, del, readd})["u1"]; got.Op != OpAdd {
|
||||||
|
t.Errorf("winner is %q — a contact logged again after a deletion must come back", got.Op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The machine id becomes a FILENAME, on a folder that may be synced between
|
||||||
|
// Windows, Linux and macOS. Anything that cannot be a filename everywhere has
|
||||||
|
// to go.
|
||||||
|
func TestMachineIDIsAUsableFilename(t *testing.T) {
|
||||||
|
for _, name := range []string{"Shack PC", "portable/rig", "Café ☕", "", " ", "a::b*c?", strings.Repeat("x", 60)} {
|
||||||
|
id := NewMachineID(name)
|
||||||
|
for _, bad := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", " "} {
|
||||||
|
if strings.Contains(id, bad) {
|
||||||
|
t.Errorf("NewMachineID(%q) = %q contains %q", name, id, bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id == "" || strings.HasPrefix(id, "-") || strings.HasSuffix(id, "-") {
|
||||||
|
t.Errorf("NewMachineID(%q) = %q", name, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Two machines the operator called the same must not share a file.
|
||||||
|
if NewMachineID("shack") == NewMachineID("shack") {
|
||||||
|
t.Error("two installations named alike produced the same id — they would write to one file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A ClubLog exception has a validity window, and a DXpedition's window closes.
|
||||||
|
// So a backdated entry must be enriched as of WHEN IT HAPPENED — 3Y0K typed
|
||||||
|
// months after the activation, with the activation's date in the form, resolved
|
||||||
|
// at today's date, found no live exception, and fell back to cty.dat:
|
||||||
|
// Antarctica, where the log says Bouvet Island.
|
||||||
|
//
|
||||||
|
// The date is trusted to move the resolution BACKWARDS only. A half-typed date
|
||||||
|
// must not send the lookup to the year 20, and a mistyped future one must not
|
||||||
|
// resolve against a window that has not opened.
|
||||||
|
func TestLookupWhen(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
if got := lookupWhen("2026-03-08"); got.Format("2006-01-02") != "2026-03-08" {
|
||||||
|
t.Errorf("a past date gave %v — the activation's own date is the whole point", got)
|
||||||
|
}
|
||||||
|
// Midday, not midnight: a window given in whole days is inclusive of its end
|
||||||
|
// date, and 00:00 sits exactly on the boundary.
|
||||||
|
if h := lookupWhen("2026-03-08").Hour(); h != 12 {
|
||||||
|
t.Errorf("resolved at %02d:00, want 12:00 — midnight sits on the window boundary", h)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, in := range map[string]string{
|
||||||
|
"empty": "",
|
||||||
|
"spaces": " ",
|
||||||
|
"half-typed": "2026-0",
|
||||||
|
"not a date": "hier",
|
||||||
|
"wrong format": "08/03/2026",
|
||||||
|
} {
|
||||||
|
if got := lookupWhen(in); got.Before(now.Add(-time.Minute)) {
|
||||||
|
t.Errorf("%s (%q) resolved to %v — anything unusable must mean now", name, in, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
future := now.AddDate(1, 0, 0).Format("2006-01-02")
|
||||||
|
if got := lookupWhen(future); got.After(now.Add(time.Minute)) {
|
||||||
|
t.Errorf("a future date (%s) resolved to %v — a mistyped year must not open a window early", future, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/db"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
"hamlog/internal/syncfolder"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syncTestApp is an App with nothing but a logbook: applySyncRecord touches the
|
||||||
|
// repository and the log file, and no more. Settings are nil, which is exactly
|
||||||
|
// the state it must survive anyway — the loop runs before the active profile is
|
||||||
|
// known.
|
||||||
|
func syncTestApp(t *testing.T) *App {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := db.Open(filepath.Join(t.TempDir(), "log.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { conn.Close() })
|
||||||
|
return &App{ctx: context.Background(), qso: qso.NewRepo(conn)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncRecord(t *testing.T, op syncfolder.Op, uid string, q qso.QSO) syncfolder.Record {
|
||||||
|
t.Helper()
|
||||||
|
rec := syncfolder.Record{V: syncfolder.FormatVersion, Op: op, UID: uid, At: time.Now().UTC(), By: "other"}
|
||||||
|
if op != syncfolder.OpDelete {
|
||||||
|
b, err := json.Marshal(q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
rec.Data = b
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func countQSOs(t *testing.T, a *App) int64 {
|
||||||
|
t.Helper()
|
||||||
|
n, err := a.qso.Count(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ordinary life of a contact made on the other PC: it arrives, it is
|
||||||
|
// corrected, it is deleted. One row throughout — a sync that inserted a second
|
||||||
|
// copy on the edit would be worse than no sync at all.
|
||||||
|
func TestSyncRecordAddThenUpdateThenDelete(t *testing.T) {
|
||||||
|
a := syncTestApp(t)
|
||||||
|
when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)
|
||||||
|
uid := syncfolder.NewUID()
|
||||||
|
|
||||||
|
if !a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, uid, qso.QSO{
|
||||||
|
Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", Name: "Ann",
|
||||||
|
})) {
|
||||||
|
t.Fatal("the added contact was not applied")
|
||||||
|
}
|
||||||
|
if n := countQSOs(t, a); n != 1 {
|
||||||
|
t.Fatalf("log holds %d QSOs after an add, want 1", n)
|
||||||
|
}
|
||||||
|
// Stamped with the identity from the record — without this every later
|
||||||
|
// change naming it would look like a contact never seen before.
|
||||||
|
id, found, err := a.qso.IDBySyncUID(a.ctx, uid)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("IDBySyncUID = (%d,%v,%v), want the new row", id, found, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.applySyncRecord(syncRecord(t, syncfolder.OpUpdate, uid, qso.QSO{
|
||||||
|
Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", Name: "Annette",
|
||||||
|
})) {
|
||||||
|
t.Fatal("the correction was not applied")
|
||||||
|
}
|
||||||
|
if n := countQSOs(t, a); n != 1 {
|
||||||
|
t.Fatalf("log holds %d QSOs after an edit, want 1 — the edit was logged as a second contact", n)
|
||||||
|
}
|
||||||
|
got, err := a.qso.GetByID(a.ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Annette" {
|
||||||
|
t.Errorf("name = %q after the correction, want %q", got.Name, "Annette")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.applySyncRecord(syncRecord(t, syncfolder.OpDelete, uid, qso.QSO{})) {
|
||||||
|
t.Fatal("the tombstone was not applied")
|
||||||
|
}
|
||||||
|
if n := countQSOs(t, a); n != 0 {
|
||||||
|
t.Fatalf("log holds %d QSOs after the deletion, want 0", n)
|
||||||
|
}
|
||||||
|
// A tombstone that arrives twice — both peers relayed it, or the file was
|
||||||
|
// re-read after a restore — must be quiet, not an error and not a change.
|
||||||
|
if a.applySyncRecord(syncRecord(t, syncfolder.OpDelete, uid, qso.QSO{})) {
|
||||||
|
t.Error("a repeated tombstone reported a change; the grid would refresh for nothing, for ever")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The case the whole no-backfill decision rests on.
|
||||||
|
//
|
||||||
|
// Both PCs already hold the operator's 123 000 contacts — seeded from one
|
||||||
|
// database or one ADIF — and neither row carries an identity, because nothing
|
||||||
|
// has touched them since. The day the shack PC corrects a 2019 QSO it stamps an
|
||||||
|
// identity and sends an update naming it; the laptop has never seen that
|
||||||
|
// identity. Inserting would give the operator two copies of a contact they
|
||||||
|
// merely corrected, and would do it for every edit for ever.
|
||||||
|
func TestSyncRecordAdoptsTheContactAlreadyInTheLog(t *testing.T) {
|
||||||
|
a := syncTestApp(t)
|
||||||
|
when := time.Date(2019, 3, 2, 9, 15, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// The copy that was already here, with no identity.
|
||||||
|
localID, err := a.qso.Add(a.ctx, qso.QSO{
|
||||||
|
Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "SSB", Name: "Ann",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := syncfolder.NewUID() // minted on the OTHER machine
|
||||||
|
if !a.applySyncRecord(syncRecord(t, syncfolder.OpUpdate, uid, qso.QSO{
|
||||||
|
Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "SSB", Name: "Annette", QTH: "Bristol",
|
||||||
|
})) {
|
||||||
|
t.Fatal("the correction was not applied")
|
||||||
|
}
|
||||||
|
if n := countQSOs(t, a); n != 1 {
|
||||||
|
t.Fatalf("log holds %d QSOs, want 1 — the contact was duplicated instead of recognised", n)
|
||||||
|
}
|
||||||
|
got, err := a.qso.GetByID(a.ctx, localID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Annette" || got.QTH != "Bristol" {
|
||||||
|
t.Errorf("the row already here was not corrected: name=%q qth=%q", got.Name, got.QTH)
|
||||||
|
}
|
||||||
|
// And it now carries the identity, so the NEXT change goes straight to it
|
||||||
|
// without needing the contact-matching fallback again.
|
||||||
|
if id, found, _ := a.qso.IDBySyncUID(a.ctx, uid); !found || id != localID {
|
||||||
|
t.Errorf("IDBySyncUID = (%d,%v), want the row already here (%d)", id, found, localID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different contact must NOT be adopted. The matching is deliberately narrow
|
||||||
|
// — same callsign, same minute, same band, same mode — and this pins that a
|
||||||
|
// second contact with the same station on another band stays a second contact.
|
||||||
|
func TestSyncRecordDoesNotAdoptADifferentContact(t *testing.T) {
|
||||||
|
a := syncTestApp(t)
|
||||||
|
when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)
|
||||||
|
if _, err := a.qso.Add(a.ctx, qso.QSO{Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "CW"}); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if !a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, syncfolder.NewUID(), qso.QSO{
|
||||||
|
Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW",
|
||||||
|
})) {
|
||||||
|
t.Fatal("the contact was not applied")
|
||||||
|
}
|
||||||
|
if n := countQSOs(t, a); n != 2 {
|
||||||
|
t.Fatalf("log holds %d QSOs, want 2 — a contact on another band was swallowed as a duplicate", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A record with no callsign is not a contact. It reaches here from a file
|
||||||
|
// truncated by a sync client mid-upload, or from a future format read
|
||||||
|
// optimistically, and inserting it would put a blank row in the log.
|
||||||
|
func TestSyncRecordIgnoresAContactWithNoCallsign(t *testing.T) {
|
||||||
|
a := syncTestApp(t)
|
||||||
|
if a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, syncfolder.NewUID(), qso.QSO{Band: "20m", Mode: "CW"})) {
|
||||||
|
t.Error("a record with no callsign was applied")
|
||||||
|
}
|
||||||
|
if n := countQSOs(t, a); n != 0 {
|
||||||
|
t.Fatalf("log holds %d QSOs, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
+554
@@ -0,0 +1,554 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Folder synchronisation — one operator, several PCs, one logbook.
|
||||||
|
//
|
||||||
|
// The operator points every OpsLog at the SAME folder (Seafile, OneDrive,
|
||||||
|
// Dropbox, a NAS share). Each machine appends what it logs, edits and deletes
|
||||||
|
// to its own file in there, and reads the others'. internal/syncfolder holds
|
||||||
|
// the format and the merge rules, and its package doc explains why the change
|
||||||
|
// log is a set of append-only files rather than the database itself.
|
||||||
|
//
|
||||||
|
// This file is the wiring: settings, the loop, and the three hooks on the
|
||||||
|
// logging path.
|
||||||
|
//
|
||||||
|
// WHAT SYNCHRONISES. Only what happens from the moment it is switched on.
|
||||||
|
// There is deliberately no mass backfill of the log already on disk: the two
|
||||||
|
// PCs of an operator who has been logging for years hold the same history
|
||||||
|
// already (one was seeded from the other, or from the same ADIF), and pushing
|
||||||
|
// 123 000 contacts through a synced folder to tell the other machine what it
|
||||||
|
// already knows would cost hours and gain nothing. A contact is stamped with an
|
||||||
|
// identity when it is touched — logged, edited, deleted — and that is what the
|
||||||
|
// other machines are told about.
|
||||||
|
//
|
||||||
|
// WHY IT STILL RECOGNISES OLD CONTACTS. Because an edit to a 2019 QSO does
|
||||||
|
// travel, and the receiving machine has that QSO under a different row id and
|
||||||
|
// no identity. It matches on the contact itself (callsign, minute, band, mode)
|
||||||
|
// before inserting, so an edit lands on the row already there instead of
|
||||||
|
// creating a second copy. That is IDByDedupeKey, and it is the whole reason the
|
||||||
|
// no-backfill decision is safe.
|
||||||
|
//
|
||||||
|
// NOT LIVE, AND NOT MEANT TO BE. Two operators working a contest together want
|
||||||
|
// the shared MySQL logbook, which OpsLog already does. This is for one operator
|
||||||
|
// whose contacts are spread across a shack PC, a laptop and a portable rig.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
"hamlog/internal/syncfolder"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settings keys. All PROFILE-SCOPED, and that is load-bearing: each profile can
|
||||||
|
// point at its own logbook, so each needs its own folder, its own machine id
|
||||||
|
// (hence its own file — two profiles sharing a folder would otherwise write
|
||||||
|
// two logbooks into one) and its own read positions.
|
||||||
|
const (
|
||||||
|
keySyncFolder = "syncfolder.config"
|
||||||
|
keySyncFolderMachine = "syncfolder.machine" // this installation's id, minted once
|
||||||
|
keySyncFolderOffsets = "syncfolder.offsets" // peer machine id → bytes already read
|
||||||
|
keySyncFolderSeq = "syncfolder.seq" // this machine's own counter
|
||||||
|
)
|
||||||
|
|
||||||
|
// syncPollInterval is how often the folder is examined. A synced folder is not
|
||||||
|
// instant anyway — Seafile and OneDrive take seconds to notice a change and
|
||||||
|
// seconds more to push it — so polling faster would only burn a directory
|
||||||
|
// listing to learn nothing.
|
||||||
|
const syncPollInterval = 20 * time.Second
|
||||||
|
|
||||||
|
// FolderSyncConfig is what the operator sets.
|
||||||
|
type FolderSyncConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Folder string `json:"folder"`
|
||||||
|
// Machine is the operator's own name for this PC — "shack", "portable".
|
||||||
|
// It only labels the file and the status; the identity that matters is the
|
||||||
|
// id minted from it, which carries a random suffix so two PCs both called
|
||||||
|
// "shack" still never write to one file.
|
||||||
|
Machine string `json:"machine"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderSyncPeer is another machine seen in the folder.
|
||||||
|
type FolderSyncPeer struct {
|
||||||
|
Machine string `json:"machine"`
|
||||||
|
// LastChange is the file's modification time — "when did that PC last log
|
||||||
|
// anything", which is the question an operator actually asks of this list.
|
||||||
|
LastChange string `json:"last_change"`
|
||||||
|
Behind int64 `json:"behind"` // bytes written but not yet read here
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderSyncStatus is what the settings panel shows.
|
||||||
|
type FolderSyncStatus struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Folder string `json:"folder"`
|
||||||
|
MachineID string `json:"machine_id"`
|
||||||
|
Peers []FolderSyncPeer `json:"peers"`
|
||||||
|
LastSync string `json:"last_sync"`
|
||||||
|
Sent int64 `json:"sent"`
|
||||||
|
Received int64 `json:"received"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadFolderSync() FolderSyncConfig {
|
||||||
|
var cfg FolderSyncConfig
|
||||||
|
if a.settings == nil || !a.settingsScoped.Load() {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
s, _ := a.settings.Get(a.ctx, keySyncFolder)
|
||||||
|
if strings.TrimSpace(s) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(s), &cfg)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFolderSync returns the configuration for the settings panel.
|
||||||
|
func (a *App) GetFolderSync() FolderSyncConfig {
|
||||||
|
a.syncMu.Lock()
|
||||||
|
defer a.syncMu.Unlock()
|
||||||
|
return a.loadFolderSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFolderSync persists the configuration.
|
||||||
|
//
|
||||||
|
// The folder is checked by WRITING to it, not by asking whether it exists: a
|
||||||
|
// cloud folder that is read-only, or a NAS share whose credentials have
|
||||||
|
// expired, exists perfectly well and would swallow every contact in silence.
|
||||||
|
// Better to refuse in the settings panel, where the operator is looking.
|
||||||
|
func (a *App) SaveFolderSync(cfg FolderSyncConfig) error {
|
||||||
|
a.syncMu.Lock()
|
||||||
|
defer a.syncMu.Unlock()
|
||||||
|
cfg.Folder = strings.TrimSpace(cfg.Folder)
|
||||||
|
cfg.Machine = strings.TrimSpace(cfg.Machine)
|
||||||
|
if cfg.Enabled {
|
||||||
|
if cfg.Folder == "" {
|
||||||
|
return fmt.Errorf("choose the synchronised folder first")
|
||||||
|
}
|
||||||
|
if err := checkWritableDir(cfg.Folder); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.Machine == "" {
|
||||||
|
cfg.Machine = "PC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The id is minted from the name ONCE and then kept, even if the operator
|
||||||
|
// renames the PC afterwards. Re-minting would orphan the file already in
|
||||||
|
// the folder: the other machines would go on reading the old one for ever
|
||||||
|
// and never see another contact from here.
|
||||||
|
if cfg.Enabled && a.settings != nil {
|
||||||
|
if cur, _ := a.settings.Get(a.ctx, keySyncFolderMachine); strings.TrimSpace(cur) == "" {
|
||||||
|
a.setSetting(keySyncFolderMachine, syncfolder.NewMachineID(cfg.Machine))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(cfg)
|
||||||
|
a.setSetting(keySyncFolder, string(b))
|
||||||
|
applog.Printf("foldersync: enabled=%v folder=%q machine=%q", cfg.Enabled, cfg.Folder, cfg.Machine)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkWritableDir proves the folder can be written to, and cleans up after
|
||||||
|
// itself.
|
||||||
|
func checkWritableDir(dir string) error {
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot reach %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("%s is not a folder", dir)
|
||||||
|
}
|
||||||
|
probe := filepath.Join(dir, ".opslog-write-test")
|
||||||
|
if err := os.WriteFile(probe, []byte("opslog"), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("cannot write to %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
_ = os.Remove(probe)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickFolderSyncFolder opens the folder chooser.
|
||||||
|
func (a *App) PickFolderSyncFolder() (string, error) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return "", fmt.Errorf("no app context")
|
||||||
|
}
|
||||||
|
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
|
Title: "Choose the folder your PCs already synchronise",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncStore returns this machine's view of the folder, or nil when folder
|
||||||
|
// synchronisation is off or not configured. Every caller treats nil as "not
|
||||||
|
// our business" — the hooks on the logging path especially, where this must
|
||||||
|
// cost nothing at all for the operators who never turn it on.
|
||||||
|
func (a *App) syncStore() (*syncfolder.Store, FolderSyncConfig) {
|
||||||
|
cfg := a.loadFolderSync()
|
||||||
|
if !cfg.Enabled || cfg.Folder == "" || a.settings == nil {
|
||||||
|
return nil, cfg
|
||||||
|
}
|
||||||
|
id, _ := a.settings.Get(a.ctx, keySyncFolderMachine)
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return nil, cfg
|
||||||
|
}
|
||||||
|
return syncfolder.New(cfg.Folder, id), cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextSyncSeq hands out this machine's next counter value.
|
||||||
|
//
|
||||||
|
// Persisted on every use rather than at shutdown: the counter breaks ties
|
||||||
|
// between two changes made in the same second, and one that restarted at zero
|
||||||
|
// after a crash would make an older change beat a newer one for ever.
|
||||||
|
func (a *App) nextSyncSeq() uint64 {
|
||||||
|
n := uint64(0)
|
||||||
|
if a.settings != nil {
|
||||||
|
s, _ := a.settings.Get(a.ctx, keySyncFolderSeq)
|
||||||
|
fmt.Sscanf(strings.TrimSpace(s), "%d", &n)
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
a.setSetting(keySyncFolderSeq, fmt.Sprintf("%d", n))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncUIDFor returns a contact's identity, minting and stamping one if it has
|
||||||
|
// none. This is where an old QSO joins the sync: not in bulk, but the first
|
||||||
|
// time it is touched.
|
||||||
|
func (a *App) syncUIDFor(id int64, known string) string {
|
||||||
|
if strings.TrimSpace(known) != "" {
|
||||||
|
return known
|
||||||
|
}
|
||||||
|
if a.qso == nil || id <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if q, err := a.qso.GetByID(a.ctx, id); err == nil && strings.TrimSpace(q.SyncUID) != "" {
|
||||||
|
return q.SyncUID
|
||||||
|
}
|
||||||
|
uid := syncfolder.NewUID()
|
||||||
|
if err := a.qso.SetSyncUID(a.ctx, id, uid); err != nil {
|
||||||
|
applog.Printf("foldersync: stamping QSO %d failed: %v", id, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncPublish records one local change for the other machines.
|
||||||
|
//
|
||||||
|
// Never on the critical path of logging: a folder on a network share can block
|
||||||
|
// for seconds, and a contact must be in the database and on screen long before
|
||||||
|
// anyone cares that another PC knows about it. Callers run it in a goroutine.
|
||||||
|
func (a *App) syncPublish(op syncfolder.Op, id int64, q *qso.QSO) {
|
||||||
|
a.syncMu.Lock()
|
||||||
|
defer a.syncMu.Unlock()
|
||||||
|
store, _ := a.syncStore()
|
||||||
|
if store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
known := ""
|
||||||
|
if q != nil {
|
||||||
|
known = q.SyncUID
|
||||||
|
}
|
||||||
|
uid := a.syncUIDFor(id, known)
|
||||||
|
if uid == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec := syncfolder.Record{Op: op, UID: uid, Seq: a.nextSyncSeq()}
|
||||||
|
// A deletion carries no contact — the tombstone is the whole message, and
|
||||||
|
// the receiving machine finds the row by the identity.
|
||||||
|
if op != syncfolder.OpDelete {
|
||||||
|
full := q
|
||||||
|
if full == nil || full.ID != id {
|
||||||
|
got, err := a.qso.GetByID(a.ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("foldersync: reading QSO %d back failed: %v", id, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
full = &got
|
||||||
|
}
|
||||||
|
// The row id is this machine's and means nothing anywhere else. Left in,
|
||||||
|
// it would be read back as "update local row 4711" on a PC where 4711 is
|
||||||
|
// somebody else entirely.
|
||||||
|
cp := *full
|
||||||
|
cp.ID = 0
|
||||||
|
cp.SyncUID = uid
|
||||||
|
b, err := json.Marshal(cp)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("foldersync: encoding QSO %d failed: %v", id, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec.Data = b
|
||||||
|
}
|
||||||
|
if err := store.Append(rec); err != nil {
|
||||||
|
a.syncErr = err.Error()
|
||||||
|
applog.Printf("foldersync: append failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.syncErr = ""
|
||||||
|
a.syncSent++
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncPublishAsync is what the logging path calls.
|
||||||
|
func (a *App) syncPublishAsync(op syncfolder.Op, id int64, q *qso.QSO) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cp *qso.QSO
|
||||||
|
if q != nil {
|
||||||
|
c := *q
|
||||||
|
cp = &c
|
||||||
|
}
|
||||||
|
go a.syncPublish(op, id, cp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncPublishDeletes records tombstones for rows about to be deleted.
|
||||||
|
//
|
||||||
|
// Called BEFORE the delete and synchronously, for the same reason
|
||||||
|
// deleteRemoteCopies is: once the rows are gone their identities are gone with
|
||||||
|
// them, and a tombstone naming nothing tells the other machines nothing.
|
||||||
|
func (a *App) syncPublishDeletes(ids []int64) {
|
||||||
|
if a.qso == nil || len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.syncMu.Lock()
|
||||||
|
store, _ := a.syncStore()
|
||||||
|
a.syncMu.Unlock()
|
||||||
|
if store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
q, err := a.qso.GetByID(a.ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A contact never touched since the sync was switched on has no identity,
|
||||||
|
// and giving it one now is what makes the deletion addressable at all.
|
||||||
|
a.syncMu.Lock()
|
||||||
|
uid := a.syncUIDFor(id, q.SyncUID)
|
||||||
|
if uid != "" {
|
||||||
|
if err := store.Append(syncfolder.Record{Op: syncfolder.OpDelete, UID: uid, Seq: a.nextSyncSeq()}); err != nil {
|
||||||
|
applog.Printf("foldersync: tombstone for QSO %d failed: %v", id, err)
|
||||||
|
} else {
|
||||||
|
a.syncSent++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.syncMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadSyncOffsets() map[string]int64 {
|
||||||
|
out := map[string]int64{}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
s, _ := a.settings.Get(a.ctx, keySyncFolderOffsets)
|
||||||
|
if strings.TrimSpace(s) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(s), &out)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) saveSyncOffsets(m map[string]int64) {
|
||||||
|
b, _ := json.Marshal(m)
|
||||||
|
a.setSetting(keySyncFolderOffsets, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderSyncLoop reads the other machines' files on an interval, for the life
|
||||||
|
// of the app. Cheap when switched off: one settings read.
|
||||||
|
func (a *App) folderSyncLoop() {
|
||||||
|
tick := time.NewTicker(syncPollInterval)
|
||||||
|
defer tick.Stop()
|
||||||
|
for range tick.C {
|
||||||
|
if a.ctx == nil || a.qso == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := a.folderSyncPass(); err != nil {
|
||||||
|
applog.Printf("foldersync: %v", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
applog.Printf("foldersync: applied %d change(s) from the folder", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncFolderNow runs one pass immediately — the "Synchronise now" button, and
|
||||||
|
// what makes a first setup verifiable without waiting for the timer.
|
||||||
|
func (a *App) SyncFolderNow() (int, error) {
|
||||||
|
return a.folderSyncPass()
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderSyncPass reads every peer's new records once and applies the winners.
|
||||||
|
func (a *App) folderSyncPass() (int, error) {
|
||||||
|
a.syncMu.Lock()
|
||||||
|
store, _ := a.syncStore()
|
||||||
|
a.syncMu.Unlock()
|
||||||
|
if store == nil || a.qso == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
peers, err := store.Peers()
|
||||||
|
if err != nil {
|
||||||
|
a.syncMu.Lock()
|
||||||
|
a.syncErr = err.Error()
|
||||||
|
a.syncMu.Unlock()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
offsets := a.loadSyncOffsets()
|
||||||
|
var batch []syncfolder.Record
|
||||||
|
advanced := map[string]int64{}
|
||||||
|
for _, p := range peers {
|
||||||
|
recs, next, err := syncfolder.ReadFrom(p.Path, offsets[p.MachineID])
|
||||||
|
if err != nil {
|
||||||
|
// One unreadable peer — a file mid-upload, a share that dropped —
|
||||||
|
// must not stop the others. Its offset is left where it was, so
|
||||||
|
// nothing is skipped when it comes back.
|
||||||
|
applog.Printf("foldersync: reading %s: %v", p.MachineID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batch = append(batch, recs...)
|
||||||
|
advanced[p.MachineID] = next
|
||||||
|
}
|
||||||
|
if len(batch) == 0 {
|
||||||
|
a.syncMu.Lock()
|
||||||
|
a.syncLast = time.Now()
|
||||||
|
a.syncErr = ""
|
||||||
|
a.syncMu.Unlock()
|
||||||
|
for id, off := range advanced {
|
||||||
|
offsets[id] = off
|
||||||
|
}
|
||||||
|
a.saveSyncOffsets(offsets)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
applied := 0
|
||||||
|
for _, rec := range syncfolder.Merge(batch) {
|
||||||
|
if a.applySyncRecord(rec) {
|
||||||
|
applied++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Offsets advance only after the batch has been applied. Saved first, a
|
||||||
|
// crash in between would lose those changes permanently — the records would
|
||||||
|
// never be read again.
|
||||||
|
for id, off := range advanced {
|
||||||
|
offsets[id] = off
|
||||||
|
}
|
||||||
|
a.saveSyncOffsets(offsets)
|
||||||
|
|
||||||
|
a.syncMu.Lock()
|
||||||
|
a.syncLast = time.Now()
|
||||||
|
a.syncReceived += int64(applied)
|
||||||
|
a.syncErr = ""
|
||||||
|
a.syncMu.Unlock()
|
||||||
|
|
||||||
|
if applied > 0 {
|
||||||
|
a.invalidateAwardStats()
|
||||||
|
a.clusterStatusMu.Lock()
|
||||||
|
a.clusterStatusIdx = nil
|
||||||
|
a.clusterStatusMu.Unlock()
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "logbook:changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return applied, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySyncRecord writes one incoming change to the logbook. Reports whether
|
||||||
|
// anything actually changed.
|
||||||
|
//
|
||||||
|
// Deliberately uses the repository directly and NOT AddQSO/UpdateQSO/DeleteQSO:
|
||||||
|
// those publish to the folder, and a change applied here would be written
|
||||||
|
// straight back out — two machines echoing each other for ever.
|
||||||
|
func (a *App) applySyncRecord(rec syncfolder.Record) bool {
|
||||||
|
id, found, err := a.qso.IDBySyncUID(a.ctx, rec.UID)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("foldersync: looking up %s: %v", rec.UID, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rec.Op == syncfolder.OpDelete {
|
||||||
|
if !found {
|
||||||
|
return false // never had it, or already deleted here
|
||||||
|
}
|
||||||
|
if err := a.qso.Delete(a.ctx, id); err != nil {
|
||||||
|
applog.Printf("foldersync: deleting QSO %d: %v", id, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var q qso.QSO
|
||||||
|
if err := json.Unmarshal(rec.Data, &q); err != nil {
|
||||||
|
applog.Printf("foldersync: unreadable record for %s: %v", rec.UID, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(q.Callsign) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
q.SyncUID = rec.UID
|
||||||
|
// Not under this identity — but very possibly the same contact under
|
||||||
|
// another one, or under none: both PCs were seeded from the same ADIF long
|
||||||
|
// before any of this existed. Recognise it rather than log it twice.
|
||||||
|
if !found {
|
||||||
|
if lid, _, ok, err := a.qso.IDByDedupeKey(a.ctx, q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode); err == nil && ok {
|
||||||
|
id, found = lid, true
|
||||||
|
_ = a.qso.SetSyncUID(a.ctx, id, rec.UID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
q.ID = id
|
||||||
|
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||||
|
applog.Printf("foldersync: updating QSO %d: %v", id, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
q.ID = 0
|
||||||
|
newID, err := a.qso.Add(a.ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("foldersync: inserting %s: %v", q.Callsign, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// sync_uid is not in the insert column list — on purpose, so no ordinary
|
||||||
|
// write can clobber an identity — so it is stamped straight after.
|
||||||
|
if err := a.qso.SetSyncUID(a.ctx, newID, rec.UID); err != nil {
|
||||||
|
applog.Printf("foldersync: stamping the new QSO %d: %v", newID, err)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFolderSyncStatus reports what the operator needs to see: which other PCs
|
||||||
|
// are in the folder, when each last logged something, and whether anything is
|
||||||
|
// waiting to be read.
|
||||||
|
func (a *App) GetFolderSyncStatus() FolderSyncStatus {
|
||||||
|
a.syncMu.Lock()
|
||||||
|
cfg := a.loadFolderSync()
|
||||||
|
store, _ := a.syncStore()
|
||||||
|
st := FolderSyncStatus{
|
||||||
|
Enabled: cfg.Enabled,
|
||||||
|
Folder: cfg.Folder,
|
||||||
|
Sent: a.syncSent,
|
||||||
|
Received: a.syncReceived,
|
||||||
|
Error: a.syncErr,
|
||||||
|
}
|
||||||
|
if !a.syncLast.IsZero() {
|
||||||
|
st.LastSync = a.syncLast.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if a.settings != nil {
|
||||||
|
st.MachineID, _ = a.settings.Get(a.ctx, keySyncFolderMachine)
|
||||||
|
}
|
||||||
|
a.syncMu.Unlock()
|
||||||
|
if store == nil {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
peers, err := store.Peers()
|
||||||
|
if err != nil {
|
||||||
|
st.Error = err.Error()
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
offsets := a.loadSyncOffsets()
|
||||||
|
for _, p := range peers {
|
||||||
|
fp := FolderSyncPeer{Machine: p.MachineID}
|
||||||
|
if behind := p.Size - offsets[p.MachineID]; behind > 0 {
|
||||||
|
fp.Behind = behind
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(p.Path); err == nil {
|
||||||
|
fp.LastChange = info.ModTime().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
st.Peers = append(st.Peers, fp)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.25.6"
|
appVersion = "0.25.7"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user