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:
+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
|
||||
}
|
||||
Reference in New Issue
Block a user