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:
2026-08-17 01:34:00 +02:00
parent eab11db766
commit 91b21a4a36
9 changed files with 979 additions and 2 deletions
+18
View File
@@ -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)
}
+4 -2
View File
@@ -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 CONTACTS 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 lentité que le QSO enregistre, pas une recalculée depuis lindicatif — un 3Y0K logué Bouvet montrait la matrice Antarctique.",
"Saisir un QSO a posteriori résout lexception ClubLog à la date DU CONTACT : une DXpedition entrée des mois après retrouve lentité quelle avait alors.",
"QRZ.com envoie la référence d’île dun opérateur sur une île, et OpsLog lignorait — elle remplit désormais la référence IOTA avant lenregistrement du QSO."
"QRZ.com envoie la référence d’île dun opérateur sur une île, et OpsLog lignorait — elle remplit désormais la référence IOTA avant lenregistrement du QSO.",
"Synchro entre PC : fais pointer chaque OpsLog vers un dossier déjà synchronisé et tes contacts te suivent dune machine à lautre."
]
},
{
+119
View File
@@ -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<Record<SectionId, string>> = {
'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<any>(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 (
<div className="space-y-4 max-w-2xl">
<p className="text-xs text-muted-foreground">{t('sync.hint')}</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
{t('sync.enable')}
</label>
<div className="grid grid-cols-[130px_1fr] items-center gap-2">
<span className="text-sm">{t('sync.machine')}</span>
<Input value={cfg.machine} placeholder="shack" className="h-8"
onChange={(e) => setCfg({ ...cfg, machine: e.target.value })}
onBlur={() => save(cfg)} />
<span className="text-sm">{t('sync.folder')}</span>
<div className="flex items-center gap-2 min-w-0">
<span className="flex-1 font-mono text-xs truncate" title={cfg.folder}>{cfg.folder || '—'}</span>
<Button variant="outline" size="sm" onClick={pick}><FolderOpen className="size-3.5 mr-1" /> {t('sync.choose')}</Button>
</div>
</div>
{err && <p className="text-xs text-destructive">{err}</p>}
{msg && <p className="text-xs text-success">{msg}</p>}
<SectionHeader title={t('sync.state')} />
<div className="rounded-md border border-border bg-muted/20 p-3 space-y-2 text-xs">
<div className="flex flex-wrap gap-x-6 gap-y-1 text-muted-foreground">
<span>{t('sync.thisPc')}: <span className="font-mono text-foreground">{st?.machine_id || '—'}</span></span>
<span>{t('sync.lastSync')}: <span className="text-foreground">{when(st?.last_sync ?? '')}</span></span>
<span>{t('sync.sent')}: <span className="text-foreground">{st?.sent ?? 0}</span></span>
<span>{t('sync.received')}: <span className="text-foreground">{st?.received ?? 0}</span></span>
</div>
{st?.error && <p className="text-destructive">{st.error}</p>}
<div className="space-y-1">
{(st?.peers ?? []).length === 0 && <p className="italic text-muted-foreground">{t('sync.noPeers')}</p>}
{(st?.peers ?? []).map((p: any) => (
<div key={p.machine} className="flex items-center gap-2">
<span className="font-mono truncate">{p.machine}</span>
<span className="text-muted-foreground">{when(p.last_change)}</span>
{p.behind > 0 && <span className="text-warning">{t('sync.behind')}</span>}
</div>
))}
</div>
</div>
<Button variant="outline" size="sm" onClick={syncNow} disabled={busy || !cfg.enabled}>
{busy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
{t('sync.now')}
</Button>
</div>
);
}
// 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: () => <ADIFMonitorPanel />,
foldersync: () => <FolderSyncPanel />,
webpublish: () => <WebPublishPanel />,
relayauto: () => <RelayAutoPanel />,
backup: BackupPanel,
+2
View File
@@ -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 quelle 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 na 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.',
+10
View File
@@ -451,6 +451,10 @@ export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
export function GetFlexState():Promise<cat.FlexTXState>;
export function GetFolderSync():Promise<main.FolderSyncConfig>;
export function GetFolderSyncStatus():Promise<main.FolderSyncStatus>;
export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
export function GetIcomState():Promise<cat.IcomTXState>;
@@ -775,6 +779,8 @@ export function PickAudioFolder():Promise<string>;
export function PickBackupFolder():Promise<string>;
export function PickFolderSyncFolder():Promise<string>;
export function PickOpenDatabase():Promise<string>;
export function PickSaveDatabase():Promise<string>;
@@ -935,6 +941,8 @@ export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Prom
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
export function SaveFolderSync(arg1:main.FolderSyncConfig):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
@@ -1095,6 +1103,8 @@ export function StopCWDecoder():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncFolderNow():Promise<number>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
export function TailLogFile(arg1:number):Promise<string>;
+20
View File
@@ -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);
}
+76
View File
@@ -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;
+176
View File
@@ -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)
}
}
+554
View File
@@ -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
}