559 lines
18 KiB
Go
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
|
|
}
|