diff --git a/app.go b/app.go index 0712bf8..c3d500d 100644 --- a/app.go +++ b/app.go @@ -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) } diff --git a/changelog.json b/changelog.json index 0eda0dc..1133d83 100644 --- a/changelog.json +++ b/changelog.json @@ -9,7 +9,8 @@ "French: seventeen strings were still in English, the whole update panel among them, plus Spot lifetime and Chase new grids.", "Selecting a QSO shows the entity the QSO records, not one re-derived from its callsign — a 3Y0K contact logged as Bouvet showed the Antarctica matrix.", "Back-entering a QSO resolves the ClubLog exception at the CONTACT’S date, so a DXpedition entered months later gets the entity it had then.", - "QRZ.com sends an island reference for an operator on one, and OpsLog read past it — it now fills the IOTA award reference before the QSO is logged." + "QRZ.com sends an island reference for an operator on one, and OpsLog read past it — it now fills the IOTA award reference before the QSO is logged.", + "Sync across PCs: point every OpsLog at one folder you already synchronise and your contacts follow you between machines." ], "fr": [ "Ouvrir le panneau Awards ne tire plus plusieurs fois le journal entier en même temps — un gros log occupait brièvement des gigaoctets de mémoire.", @@ -18,7 +19,8 @@ "Français : dix-sept textes étaient restés en anglais, dont tout le panneau de mise à jour, la durée de vie des spots et Chasser les nouveaux locators.", "Sélectionner un QSO affiche l’entité que le QSO enregistre, pas une recalculée depuis l’indicatif — un 3Y0K logué Bouvet montrait la matrice Antarctique.", "Saisir un QSO a posteriori résout l’exception ClubLog à la date DU CONTACT : une DXpedition entrée des mois après retrouve l’entité qu’elle avait alors.", - "QRZ.com envoie la référence d’île d’un opérateur sur une île, et OpsLog l’ignorait — elle remplit désormais la référence IOTA avant l’enregistrement du QSO." + "QRZ.com envoie la référence d’île d’un opérateur sur une île, et OpsLog l’ignorait — elle remplit désormais la référence IOTA avant l’enregistrement du QSO.", + "Synchro entre PC : fais pointer chaque OpsLog vers un dossier déjà synchronisé et tes contacts te suivent d’une machine à l’autre." ] }, { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 74d3414..b823ae1 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -51,6 +51,7 @@ import { GetUIPref, SetUIPref, GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower, GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile, + GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow, GetRelayAuto, SaveRelayAuto, GetStationDevices, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, @@ -188,6 +189,7 @@ type SectionId = | 'external-services' | 'udp' | 'adifmon' + | 'foldersync' | 'webpublish' | 'lookup' | 'lists-bands' @@ -305,6 +307,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[] { kind: 'item', label: t('sec.cluster'), id: 'cluster' }, { kind: 'item', label: t('sec.udp'), id: 'udp' }, { kind: 'item', label: t('sec.adifmon'), id: 'adifmon' }, + { kind: 'item', label: t('sec.foldersync'), id: 'foldersync' }, { kind: 'item', label: t('sec.webpublish'), id: 'webpublish' }, { kind: 'item', label: t('sec.uscounties'), id: 'uscounties' }, { kind: 'item', label: t('sec.database'), id: 'database' }, @@ -323,6 +326,7 @@ const SECTION_KEY: Partial> = { 'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes', cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp', adifmon: 'sec.adifmon', + foldersync: 'sec.foldersync', webpublish: 'sec.webpublish', uscounties: 'sec.uscounties', awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna', @@ -705,6 +709,120 @@ function ADIFMonitorPanel() { ); } +// FolderSyncPanel: one operator, several PCs, one logbook through a folder they +// already synchronise (Seafile, OneDrive, Dropbox, a NAS share). +// +// The status half is the point of the panel. Every part of this runs on someone +// else's machine and on a sync client OpsLog cannot see, so "it is not working" +// has to be answerable from here: which other PCs have written to the folder, +// when each last did, and whether anything is sitting there unread. +function FolderSyncPanel() { + const { t } = useI18n(); + const [cfg, setCfg] = useState<{ enabled: boolean; folder: string; machine: string }>({ enabled: false, folder: '', machine: '' }); + const [st, setSt] = useState(null); + const [loaded, setLoaded] = useState(false); + const [err, setErr] = useState(''); + const [msg, setMsg] = useState(''); + const [busy, setBusy] = useState(false); + useEffect(() => { + GetFolderSync() + .then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, folder: c.folder ?? '', machine: c.machine ?? '' }); }) + .catch(() => {}) + .finally(() => setLoaded(true)); + }, []); + // Polled while the panel is open: a first setup is verified by watching the + // other PC appear in this list, and that happens on the sync client's clock, + // not on any action taken here. + useEffect(() => { + let alive = true; + const tick = () => GetFolderSyncStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {}); + tick(); + const h = window.setInterval(tick, 3000); + return () => { alive = false; window.clearInterval(h); }; + }, []); + const save = async (next: typeof cfg) => { + setCfg(next); + setErr(''); setMsg(''); + try { + await SaveFolderSync(next as any); + setMsg(t('sync.saved')); + GetFolderSyncStatus().then(setSt).catch(() => {}); + } catch (e: any) { + setErr(String(e?.message ?? e)); + // The switch goes back off rather than sitting on while nothing is + // written: a folder that refused the write test would lose every contact + // in silence, which is the one outcome this feature must never have. + setCfg({ ...next, enabled: false }); + } + }; + const pick = async () => { + try { + const p = await PickFolderSyncFolder(); + if (p) save({ ...cfg, folder: p }); + } catch { /* dialog cancelled */ } + }; + const syncNow = async () => { + setBusy(true); setErr(''); setMsg(''); + try { + const n = await SyncFolderNow(); + setMsg(t('sync.applied').replace('{n}', String(n))); + GetFolderSyncStatus().then(setSt).catch(() => {}); + } catch (e: any) { + setErr(String(e?.message ?? e)); + } finally { setBusy(false); } + }; + const when = (iso: string) => (iso ? new Date(iso).toLocaleString() : t('sync.never')); + return ( +
+

{t('sync.hint')}

+ + +
+ {t('sync.machine')} + setCfg({ ...cfg, machine: e.target.value })} + onBlur={() => save(cfg)} /> + {t('sync.folder')} +
+ {cfg.folder || '—'} + +
+
+ + {err &&

{err}

} + {msg &&

{msg}

} + + +
+
+ {t('sync.thisPc')}: {st?.machine_id || '—'} + {t('sync.lastSync')}: {when(st?.last_sync ?? '')} + {t('sync.sent')}: {st?.sent ?? 0} + {t('sync.received')}: {st?.received ?? 0} +
+ {st?.error &&

{st.error}

} +
+ {(st?.peers ?? []).length === 0 &&

{t('sync.noPeers')}

} + {(st?.peers ?? []).map((p: any) => ( +
+ {p.machine} + {when(p.last_change)} + {p.behind > 0 && {t('sync.behind')}} +
+ ))} +
+
+ +
+ ); +} + // AmpUI mirrors the backend AmpConfig — one configured amplifier. type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number; // Band-follow (ACOM): a SECOND serial port on which OpsLog answers the amp's @@ -6426,6 +6544,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan // panels below go through PanelHost instead — which is what now lets either // kind hold hooks. adifmon: () => , + foldersync: () => , webpublish: () => , relayauto: () => , backup: BackupPanel, diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index d570a74..21fdb21 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -118,6 +118,7 @@ const en: Dict = { 'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster', 'sec.udp': 'Connections', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties', 'sec.webpublish': 'Web publishing', 'wpub.hint': 'Publishes your log as a file for a website: a standalone HTML page or a CSV, written locally and optionally uploaded by FTP. It is refreshed when you log a QSO and, if you set an interval, on a timer.', 'wpub.enable': 'Publish the log to a file', 'wpub.fileSection': 'The file', 'wpub.format': 'Format', 'wpub.formatHtml': 'HTML page', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Output folder', 'wpub.browse': 'Browse…', 'wpub.fileName': 'File name', 'wpub.title': 'Page title', 'wpub.titlePh': 'blank = your callsign', 'wpub.count': 'Last N QSOs', 'wpub.every': 'Refresh every', 'wpub.everyHint': 'minutes — 0 = only when a QSO is logged', 'wpub.columns': 'Columns', 'wpub.columnsCount': '{n} of {total} chosen', 'wpub.columnsPick': 'Choose columns…', 'wpub.columnsSearch': 'Search a field…', 'wpub.removeColumn': 'Click to remove', 'wpub.columnsHint': 'Click to add or remove. The order shown here is the order in the file.', 'wpub.ftpEnable': 'Upload by FTP', 'wpub.ftpHost': 'Server / port', 'wpub.ftpUser': 'User', 'wpub.ftpPassword': 'Password', 'wpub.ftpFolder': 'Remote folder', 'wpub.ftpFileName': 'Remote file name', 'wpub.ftpTls': 'Use TLS (FTPS)', 'wpub.publishNow': 'Publish now', 'wpub.testFtp': 'Test connection', 'wpub.lastRun': 'Last run:', 'sec.adifmon': 'ADIF monitor', + 'sec.foldersync': 'Sync across PCs', 'sync.hint': 'Point every OpsLog at the SAME folder — one your PCs already synchronise (Seafile, OneDrive, Dropbox, a NAS share). Each machine writes what it logs there and reads the others; the databases themselves are never shared.', 'sync.enable': 'Keep my contacts in step across my PCs', 'sync.machine': 'This PC', 'sync.folder': 'Folder', 'sync.choose': 'Choose…', 'sync.state': 'State', 'sync.thisPc': 'This PC', 'sync.lastSync': 'Last check', 'sync.sent': 'Sent', 'sync.received': 'Received', 'sync.never': 'never', 'sync.noPeers': 'No other PC has written to this folder yet.', 'sync.behind': 'new contacts waiting', 'sync.now': 'Synchronise now', 'sync.applied': '{n} change(s) taken from the folder.', 'sync.saved': 'Saved.', 'adifmon.hint': 'Watch external ADIF files and import new QSOs automatically — e.g. fldigi logging RTTY, or N1MM/VarAC. Imported QSOs are enriched, de-duplicated and uploaded to your external services just like a QSO logged here.', 'adifmon.enable': 'Enable ADIF monitor', 'adifmon.empty': 'No file watched yet. Add an ADIF file below.', @@ -555,6 +556,7 @@ const fr: Dict = { 'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster', 'sec.udp': 'Connexions', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US', 'sec.webpublish': 'Publication web', 'wpub.hint': "Publie ton journal dans un fichier destiné à un site web : une page HTML autonome ou un CSV, écrit en local et envoyé par FTP si tu le souhaites. Il est rafraîchi à chaque QSO enregistré et, si tu règles un intervalle, périodiquement.", 'wpub.enable': 'Publier le journal dans un fichier', 'wpub.fileSection': 'Le fichier', 'wpub.format': 'Format', 'wpub.formatHtml': 'Page HTML', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Dossier de sortie', 'wpub.browse': 'Parcourir…', 'wpub.fileName': 'Nom du fichier', 'wpub.title': 'Titre de la page', 'wpub.titlePh': 'vide = ton indicatif', 'wpub.count': 'N derniers QSO', 'wpub.every': 'Rafraîchir toutes les', 'wpub.everyHint': 'minutes — 0 = seulement à chaque QSO', 'wpub.columns': 'Colonnes', 'wpub.columnsCount': '{n} sur {total} choisis', 'wpub.columnsPick': 'Choisir les colonnes…', 'wpub.columnsSearch': 'Chercher un champ…', 'wpub.removeColumn': 'Cliquer pour retirer', 'wpub.columnsHint': 'Clique pour ajouter ou retirer. L ordre affiché ici est celui du fichier.', 'wpub.ftpEnable': 'Envoyer par FTP', 'wpub.ftpHost': 'Serveur / port', 'wpub.ftpUser': 'Utilisateur', 'wpub.ftpPassword': 'Mot de passe', 'wpub.ftpFolder': 'Dossier distant', 'wpub.ftpFileName': 'Nom du fichier distant', 'wpub.ftpTls': 'Utiliser TLS (FTPS)', 'wpub.publishNow': 'Publier maintenant', 'wpub.testFtp': 'Tester la connexion', 'wpub.lastRun': 'Dernière exécution :', 'sec.adifmon': 'Moniteur ADIF', + 'sec.foldersync': 'Synchro entre PC', 'sync.hint': 'Fais pointer chaque OpsLog vers le MÊME dossier — un dossier que tes PC synchronisent déjà (Seafile, OneDrive, Dropbox, un partage NAS). Chaque machine y écrit ce qu’elle enregistre et lit celui des autres ; les bases de données, elles, ne sont jamais partagées.', 'sync.enable': 'Garder mes contacts à jour sur tous mes PC', 'sync.machine': 'Ce PC', 'sync.folder': 'Dossier', 'sync.choose': 'Choisir…', 'sync.state': 'État', 'sync.thisPc': 'Ce PC', 'sync.lastSync': 'Dernière vérification', 'sync.sent': 'Envoyés', 'sync.received': 'Reçus', 'sync.never': 'jamais', 'sync.noPeers': 'Aucun autre PC n’a encore écrit dans ce dossier.', 'sync.behind': 'nouveaux contacts en attente', 'sync.now': 'Synchroniser maintenant', 'sync.applied': '{n} changement(s) repris du dossier.', 'sync.saved': 'Enregistré.', 'adifmon.hint': "Surveille des fichiers ADIF externes et importe les nouveaux QSO automatiquement — ex. fldigi en RTTY, ou N1MM/VarAC. Les QSO importés sont enrichis, dédoublonnés et envoyés à tes services externes comme un QSO loggé ici.", 'adifmon.enable': 'Activer le moniteur ADIF', 'adifmon.empty': 'Aucun fichier surveillé. Ajoute un fichier ADIF ci-dessous.', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 577c880..b8c4f98 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -451,6 +451,10 @@ export function GetFlexBandPower():Promise>; export function GetFlexState():Promise; +export function GetFolderSync():Promise; + +export function GetFolderSyncStatus():Promise; + export function GetGridCacheStatus():Promise; export function GetIcomState():Promise; @@ -775,6 +779,8 @@ export function PickAudioFolder():Promise; export function PickBackupFolder():Promise; +export function PickFolderSyncFolder():Promise; + export function PickOpenDatabase():Promise; export function PickSaveDatabase():Promise; @@ -935,6 +941,8 @@ export function SaveFlexBandAntennas(arg1:Record):Prom export function SaveFlexBandPower(arg1:Record):Promise; +export function SaveFolderSync(arg1:main.FolderSyncConfig):Promise; + export function SaveListsSettings(arg1:main.ListsSettings):Promise; export function SaveLookupSettings(arg1:main.LookupSettings):Promise; @@ -1095,6 +1103,8 @@ export function StopCWDecoder():Promise; export function SwitchCATRig(arg1:number):Promise; +export function SyncFolderNow():Promise; + export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise; export function TailLogFile(arg1:number):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index ffda911..8add157 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -842,6 +842,14 @@ export function GetFlexState() { return window['go']['main']['App']['GetFlexState'](); } +export function GetFolderSync() { + return window['go']['main']['App']['GetFolderSync'](); +} + +export function GetFolderSyncStatus() { + return window['go']['main']['App']['GetFolderSyncStatus'](); +} + export function GetGridCacheStatus() { return window['go']['main']['App']['GetGridCacheStatus'](); } @@ -1490,6 +1498,10 @@ export function PickBackupFolder() { return window['go']['main']['App']['PickBackupFolder'](); } +export function PickFolderSyncFolder() { + return window['go']['main']['App']['PickFolderSyncFolder'](); +} + export function PickOpenDatabase() { return window['go']['main']['App']['PickOpenDatabase'](); } @@ -1810,6 +1822,10 @@ export function SaveFlexBandPower(arg1) { return window['go']['main']['App']['SaveFlexBandPower'](arg1); } +export function SaveFolderSync(arg1) { + return window['go']['main']['App']['SaveFolderSync'](arg1); +} + export function SaveListsSettings(arg1) { return window['go']['main']['App']['SaveListsSettings'](arg1); } @@ -2130,6 +2146,10 @@ export function SwitchCATRig(arg1) { return window['go']['main']['App']['SwitchCATRig'](arg1); } +export function SyncFolderNow() { + return window['go']['main']['App']['SyncFolderNow'](); +} + export function SyncPOTAHunterLog(arg1, arg2) { return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 13448c2..8de7ee9 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2465,6 +2465,82 @@ export namespace main { this.body = source["body"]; } } + export class FolderSyncConfig { + enabled: boolean; + folder: string; + machine: string; + + static createFrom(source: any = {}) { + return new FolderSyncConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.folder = source["folder"]; + this.machine = source["machine"]; + } + } + export class FolderSyncPeer { + machine: string; + last_change: string; + behind: number; + + static createFrom(source: any = {}) { + return new FolderSyncPeer(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.machine = source["machine"]; + this.last_change = source["last_change"]; + this.behind = source["behind"]; + } + } + export class FolderSyncStatus { + enabled: boolean; + folder: string; + machine_id: string; + peers: FolderSyncPeer[]; + last_sync: string; + sent: number; + received: number; + error: string; + + static createFrom(source: any = {}) { + return new FolderSyncStatus(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.folder = source["folder"]; + this.machine_id = source["machine_id"]; + this.peers = this.convertValues(source["peers"], FolderSyncPeer); + this.last_sync = source["last_sync"]; + this.sent = source["sent"]; + this.received = source["received"]; + this.error = source["error"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class GridCacheStatus { enabled: boolean; known: number; diff --git a/syncapply_test.go b/syncapply_test.go new file mode 100644 index 0000000..e1aa344 --- /dev/null +++ b/syncapply_test.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + "time" + + "hamlog/internal/db" + "hamlog/internal/qso" + "hamlog/internal/syncfolder" +) + +// syncTestApp is an App with nothing but a logbook: applySyncRecord touches the +// repository and the log file, and no more. Settings are nil, which is exactly +// the state it must survive anyway — the loop runs before the active profile is +// known. +func syncTestApp(t *testing.T) *App { + t.Helper() + conn, err := db.Open(filepath.Join(t.TempDir(), "log.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return &App{ctx: context.Background(), qso: qso.NewRepo(conn)} +} + +func syncRecord(t *testing.T, op syncfolder.Op, uid string, q qso.QSO) syncfolder.Record { + t.Helper() + rec := syncfolder.Record{V: syncfolder.FormatVersion, Op: op, UID: uid, At: time.Now().UTC(), By: "other"} + if op != syncfolder.OpDelete { + b, err := json.Marshal(q) + if err != nil { + t.Fatalf("marshal: %v", err) + } + rec.Data = b + } + return rec +} + +func countQSOs(t *testing.T, a *App) int64 { + t.Helper() + n, err := a.qso.Count(a.ctx) + if err != nil { + t.Fatalf("count: %v", err) + } + return n +} + +// The ordinary life of a contact made on the other PC: it arrives, it is +// corrected, it is deleted. One row throughout — a sync that inserted a second +// copy on the edit would be worse than no sync at all. +func TestSyncRecordAddThenUpdateThenDelete(t *testing.T) { + a := syncTestApp(t) + when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC) + uid := syncfolder.NewUID() + + if !a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, uid, qso.QSO{ + Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", Name: "Ann", + })) { + t.Fatal("the added contact was not applied") + } + if n := countQSOs(t, a); n != 1 { + t.Fatalf("log holds %d QSOs after an add, want 1", n) + } + // Stamped with the identity from the record — without this every later + // change naming it would look like a contact never seen before. + id, found, err := a.qso.IDBySyncUID(a.ctx, uid) + if err != nil || !found { + t.Fatalf("IDBySyncUID = (%d,%v,%v), want the new row", id, found, err) + } + + if !a.applySyncRecord(syncRecord(t, syncfolder.OpUpdate, uid, qso.QSO{ + Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", Name: "Annette", + })) { + t.Fatal("the correction was not applied") + } + if n := countQSOs(t, a); n != 1 { + t.Fatalf("log holds %d QSOs after an edit, want 1 — the edit was logged as a second contact", n) + } + got, err := a.qso.GetByID(a.ctx, id) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Name != "Annette" { + t.Errorf("name = %q after the correction, want %q", got.Name, "Annette") + } + + if !a.applySyncRecord(syncRecord(t, syncfolder.OpDelete, uid, qso.QSO{})) { + t.Fatal("the tombstone was not applied") + } + if n := countQSOs(t, a); n != 0 { + t.Fatalf("log holds %d QSOs after the deletion, want 0", n) + } + // A tombstone that arrives twice — both peers relayed it, or the file was + // re-read after a restore — must be quiet, not an error and not a change. + if a.applySyncRecord(syncRecord(t, syncfolder.OpDelete, uid, qso.QSO{})) { + t.Error("a repeated tombstone reported a change; the grid would refresh for nothing, for ever") + } +} + +// The case the whole no-backfill decision rests on. +// +// Both PCs already hold the operator's 123 000 contacts — seeded from one +// database or one ADIF — and neither row carries an identity, because nothing +// has touched them since. The day the shack PC corrects a 2019 QSO it stamps an +// identity and sends an update naming it; the laptop has never seen that +// identity. Inserting would give the operator two copies of a contact they +// merely corrected, and would do it for every edit for ever. +func TestSyncRecordAdoptsTheContactAlreadyInTheLog(t *testing.T) { + a := syncTestApp(t) + when := time.Date(2019, 3, 2, 9, 15, 0, 0, time.UTC) + + // The copy that was already here, with no identity. + localID, err := a.qso.Add(a.ctx, qso.QSO{ + Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "SSB", Name: "Ann", + }) + if err != nil { + t.Fatalf("seed: %v", err) + } + + uid := syncfolder.NewUID() // minted on the OTHER machine + if !a.applySyncRecord(syncRecord(t, syncfolder.OpUpdate, uid, qso.QSO{ + Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "SSB", Name: "Annette", QTH: "Bristol", + })) { + t.Fatal("the correction was not applied") + } + if n := countQSOs(t, a); n != 1 { + t.Fatalf("log holds %d QSOs, want 1 — the contact was duplicated instead of recognised", n) + } + got, err := a.qso.GetByID(a.ctx, localID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Name != "Annette" || got.QTH != "Bristol" { + t.Errorf("the row already here was not corrected: name=%q qth=%q", got.Name, got.QTH) + } + // And it now carries the identity, so the NEXT change goes straight to it + // without needing the contact-matching fallback again. + if id, found, _ := a.qso.IDBySyncUID(a.ctx, uid); !found || id != localID { + t.Errorf("IDBySyncUID = (%d,%v), want the row already here (%d)", id, found, localID) + } +} + +// A different contact must NOT be adopted. The matching is deliberately narrow +// — same callsign, same minute, same band, same mode — and this pins that a +// second contact with the same station on another band stays a second contact. +func TestSyncRecordDoesNotAdoptADifferentContact(t *testing.T) { + a := syncTestApp(t) + when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC) + if _, err := a.qso.Add(a.ctx, qso.QSO{Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "CW"}); err != nil { + t.Fatalf("seed: %v", err) + } + if !a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, syncfolder.NewUID(), qso.QSO{ + Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", + })) { + t.Fatal("the contact was not applied") + } + if n := countQSOs(t, a); n != 2 { + t.Fatalf("log holds %d QSOs, want 2 — a contact on another band was swallowed as a duplicate", n) + } +} + +// A record with no callsign is not a contact. It reaches here from a file +// truncated by a sync client mid-upload, or from a future format read +// optimistically, and inserting it would put a blank row in the log. +func TestSyncRecordIgnoresAContactWithNoCallsign(t *testing.T) { + a := syncTestApp(t) + if a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, syncfolder.NewUID(), qso.QSO{Band: "20m", Mode: "CW"})) { + t.Error("a record with no callsign was applied") + } + if n := countQSOs(t, a); n != 0 { + t.Fatalf("log holds %d QSOs, want 0", n) + } +} diff --git a/syncfolder.go b/syncfolder.go new file mode 100644 index 0000000..3813961 --- /dev/null +++ b/syncfolder.go @@ -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 +}