Files
OpsLog/syncfolder.go
T
2026-08-20 17:53:55 +02:00

559 lines
18 KiB
Go

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)
}
// A partner's contact counts as worked for this station too — that is the
// point of a multi-op log — so the worked indexes must learn it exactly as
// they do for our own. Raw insert, so nothing else does it.
a.noteWorked(q.Callsign, q.Band, q.Mode)
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
}