feat(sync): the folder loop, the hooks and the panel — it can be switched on now

The three layers underneath were already there and unreachable: the change-log
format (7d664bd), the identity column (724e68b) and the no-backfill decision
(c48294b). This is the wiring that gives the operator a switch.

Settings, all profile-scoped, because each profile can point at its own logbook:
the folder, this PC's name, the machine id minted once from it, the per-peer read
offsets and the tie-break counter. Scoping the machine id is what keeps two
profiles sharing one folder from writing two logbooks into one file.

Three hooks. Add and update publish asynchronously, down with the rest of the
after-the-fact work — a folder on a network share can block for seconds and a
contact belongs on screen long before another machine hears about it. Deletion
publishes SYNCHRONOUSLY and BEFORE the row goes, for the same reason
deleteRemoteCopies does: once it is gone its identity is gone with it and the
tombstone names nothing.

The apply path uses the repository directly and never AddQSO/UpdateQSO/DeleteQSO
— those publish, and a change applied here would be written straight back out,
two machines echoing each other for ever.

Saving writes a probe file to the chosen folder rather than asking whether it
exists. A read-only cloud folder, or a share whose credentials expired, exists
perfectly well and would swallow every contact in silence; if the probe fails the
switch goes back off instead of sitting on while nothing is written.

The panel is mostly status, and deliberately: every part of this runs on another
machine and on a sync client OpsLog cannot see, so "it is not working" has to be
answerable from the settings page — which PCs are in the folder, when each last
logged, what is waiting unread.

Four tests on the apply path, the middle two being the ones that matter: an edit
made on the other PC lands on the copy already here, matched on the contact
itself, instead of becoming a second row — that is what makes the no-backfill
decision safe — and a contact with the same station on another band stays a
separate contact.
This commit is contained in:
2026-08-17 01:34:00 +02:00
parent eab11db766
commit 91b21a4a36
9 changed files with 979 additions and 2 deletions
+18
View File
@@ -67,6 +67,7 @@ import (
"hamlog/internal/solar"
"hamlog/internal/spe"
"hamlog/internal/steppir"
"hamlog/internal/syncfolder"
"hamlog/internal/tunergenius"
"hamlog/internal/uls"
"hamlog/internal/ultrabeam"
@@ -735,6 +736,11 @@ type App struct {
confDLCancel context.CancelFunc
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)
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
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
@@ -1164,6 +1170,7 @@ func (a *App) startup(ctx context.Context) {
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.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
// cty.dat for offline DXCC / country resolution. Cached on disk; first
@@ -2787,6 +2794,14 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
a.maybeAutoSendEQSL(qc)
a.maybeSelfSpot(qc)
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 {
rec := adif.SingleRecordADIF(qc)
a.udp.EmitLoggedADIF(rec)
@@ -6014,6 +6029,7 @@ func (a *App) UpdateQSO(q qso.QSO) error {
if err == nil {
a.invalidateAwardStats()
a.materializeAwardRefs(q) // fields may have changed → refresh award_refs
a.syncPublishAsync(syncfolder.OpUpdate, q.ID, nil)
}
return err
}
@@ -6107,6 +6123,7 @@ func (a *App) DeleteQSO(id int64) error {
return fmt.Errorf("db not initialized")
}
a.deleteRemoteCopies([]int64{id})
a.syncPublishDeletes([]int64{id})
return a.qso.Delete(a.ctx, id)
}
@@ -6177,6 +6194,7 @@ func (a *App) DeleteQSOs(ids []int64) (int64, error) {
return 0, fmt.Errorf("db not initialized")
}
a.deleteRemoteCopies(ids)
a.syncPublishDeletes(ids)
return a.qso.DeleteMany(a.ctx, ids)
}