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 }