feat: ADIF monitor — auto-import QSOs from watched external ADIF files

Watches a configurable list of external ADIF files (fldigi RTTY logbook,
N1MM, VarAC…) and imports newly appended QSOs automatically. Each record
goes through the existing UDP-log path, so it gets full enrichment, ±2-minute
dedup (shared udpLogMu, can't race the UDP auto-log) and automatic upload to
the configured external services — no per-file toggles like Log4OM.

A newly added file starts at its current size (offset -1 sentinel → size on
first scan), so the QSOs already in it are NOT bulk-imported; only contacts
logged after it was added come in. Reads only up to the last complete <eor>
so a half-written record waits. Handles truncation/rotation (re-reads from 0,
dedup protects) and persists per-file offsets without clobbering a concurrent
UI edit of the file list.

New Settings → ADIF monitor section: master enable + add/remove/toggle files.
Backend emits adifmon:imported → the grid refreshes and a toast reports the
count. i18n EN + FR.
This commit is contained in:
2026-07-19 01:35:00 +02:00
parent 19993bafc1
commit 8fc04563e1
8 changed files with 405 additions and 1 deletions
+10 -1
View File
@@ -1913,7 +1913,16 @@ export default function App() {
else setError('UDP auto-log: ' + msg);
}
});
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
// ADIF monitor imported new QSOs (backend file watcher) → refresh the grid
// and show how many, from which file.
const unsubAdifMon = EventsOn('adifmon:imported', async (p: any) => {
const n = Number(p?.count ?? 0);
if (n <= 0) return;
await refresh();
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
showToast(`ADIF: ${n} QSO imported${file ? ` from ${file}` : ''}`);
});
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+61
View File
@@ -42,6 +42,7 @@ import {
ComputeStationInfo,
GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -169,6 +170,7 @@ type SectionId =
| 'confirmations'
| 'external-services'
| 'udp'
| 'adifmon'
| 'lookup'
| 'lists-bands'
| 'lists-modes'
@@ -225,6 +227,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.uscounties'), id: 'uscounties' },
{ kind: 'item', label: t('sec.database'), id: 'database' },
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
@@ -241,6 +244,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
'external-services': 'sec.external', 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',
uscounties: 'sec.uscounties',
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
@@ -261,6 +265,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
database: 'Database',
autostart: 'Autostart',
udp: 'UDP integrations',
adifmon: 'ADIF monitor',
awards: 'Awards',
cat: 'CAT interface',
rotator: 'Rotator',
@@ -576,6 +581,61 @@ function LiveStatusToggle() {
);
}
// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
// contact that arrives is imported and uploaded automatically like any log entry.
type ADIFWatchFileUI = { path: string; enabled: boolean; offset: number };
type ADIFMonitorCfgUI = { enabled: boolean; files: ADIFWatchFileUI[] };
function ADIFMonitorPanel() {
const { t } = useI18n();
const [cfg, setCfg] = useState<ADIFMonitorCfgUI>({ enabled: false, files: [] });
const [loaded, setLoaded] = useState(false);
useEffect(() => {
GetADIFMonitor()
.then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, files: (c.files ?? []) as ADIFWatchFileUI[] }); })
.catch(() => {})
.finally(() => setLoaded(true));
}, []);
// Offsets are managed backend-side; SaveADIFMonitor ignores the ones we send and
// keeps each existing file's read position (a new file starts at end-of-file).
const persist = (next: ADIFMonitorCfgUI) => { setCfg(next); SaveADIFMonitor(next as any).catch(() => {}); };
const addFile = async () => {
try {
const p = await PickADIFMonitorFile();
if (!p || cfg.files.some((f) => f.path === p)) return;
persist({ ...cfg, files: [...cfg.files, { path: p, enabled: true, offset: -1 }] });
} catch { /* dialog cancelled */ }
};
return (
<div className="space-y-4 max-w-2xl">
<p className="text-xs text-muted-foreground">{t('adifmon.hint')}</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => persist({ ...cfg, enabled: !!c })} />
{t('adifmon.enable')}
</label>
<div className="space-y-1.5">
{cfg.files.length === 0 && <p className="text-xs text-muted-foreground italic">{t('adifmon.empty')}</p>}
{cfg.files.map((f, i) => (
<div key={i} className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-2 py-1.5">
<Checkbox checked={f.enabled}
onCheckedChange={(c) => persist({ ...cfg, files: cfg.files.map((x, idx) => idx === i ? { ...x, enabled: !!c } : x) })} />
<span className="flex-1 font-mono text-xs truncate" title={f.path}>{f.path}</span>
<button type="button" title={t('adifmon.remove')}
className="text-muted-foreground hover:text-destructive shrink-0"
onClick={() => persist({ ...cfg, files: cfg.files.filter((_, idx) => idx !== i) })}>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
<Button variant="outline" size="sm" onClick={addFile}>
<FolderOpen className="size-3.5 mr-1" /> {t('adifmon.add')}
</Button>
<p className="text-[11px] text-muted-foreground">{t('adifmon.note')}</p>
</div>
);
}
// MainViewPanes lets the operator choose what the Main tab's left and right
// panes show, independently: the great-circle map, the locator street map, the
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
@@ -4451,6 +4511,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
'lists-modes': ModesPanel,
cluster: ClusterPanel,
udp: UDPIntegrationsPanelWrapper,
adifmon: ADIFMonitorPanel,
backup: BackupPanel,
database: DatabasePanel,
uscounties: USCountiesPanel,
+14
View File
@@ -102,6 +102,13 @@ const en: Dict = {
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties',
'sec.adifmon': 'ADIF monitor',
'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.',
'adifmon.add': 'Add ADIF file…',
'adifmon.remove': 'Stop watching this file',
'adifmon.note': 'A newly added file starts from its current end — QSOs already in it are NOT imported, only contacts logged after you add it.',
'uscty.title': 'US Counties (USA-CA)',
'uscty.intro': 'Resolve a US callsign to its county and grid offline, from the FCC ULS licence database. This powers the US Counties award and county hunting — including on CW/SSB, where a spot carries only a callsign.',
'uscty.needDownload': 'County resolution requires downloading the FCC database first (about 150 MB, stored locally). Nothing is resolved until you download it.',
@@ -393,6 +400,13 @@ const fr: Dict = {
'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US',
'sec.adifmon': 'Moniteur ADIF',
'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.',
'adifmon.add': 'Ajouter un fichier ADIF…',
'adifmon.remove': 'Ne plus surveiller ce fichier',
'adifmon.note': "Un fichier ajouté démarre à sa fin actuelle — les QSO déjà présents ne sont PAS importés, seulement les contacts loggés après l'ajout.",
'uscty.title': 'Comtés US (USA-CA)',
'uscty.intro': "Résout un indicatif US en comté et locator, hors-ligne, depuis la base de licences FCC ULS. Ça alimente le diplôme Comtés US et la chasse aux comtés — même en CW/SSB, où le spot ne porte qu'un indicatif.",
'uscty.needDownload': "La résolution des comtés nécessite d'abord de télécharger la base FCC (environ 150 Mo, stockée en local). Rien n'est résolu tant que tu ne l'as pas téléchargée.",