diff --git a/adifwatch.go b/adifwatch.go new file mode 100644 index 0000000..6829a89 --- /dev/null +++ b/adifwatch.go @@ -0,0 +1,251 @@ +package main + +// ADIF monitor: watches a configurable list of external ADIF files (fldigi's +// RTTY logbook, N1MM, VarAC…) and imports newly appended QSOs into OpsLog as if +// they had been logged here — same enrichment, dedup and automatic upload to +// external services (QRZ, Club Log…). Deliberately simpler than Log4OM's monitor: +// no per-file "upload" / "delete after load" toggles — importing + auto-upload is +// just what happens. +// +// Design notes: +// - A newly added file starts at its CURRENT size (Offset = -1 sentinel → set to +// size on first scan) so the QSOs already in it are NOT bulk-imported; only +// contacts appended AFTER you add the file come in. +// - Reads only up to the last complete , so a half-written record waits for +// the next poll instead of importing a truncated QSO. +// - Each record is fed through LogUDPLoggedADIF, which already does the full +// enrichment + ±2-minute dedup (shared udpLogMu, so it can't race the UDP +// auto-log) + automatic external-service upload. + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "time" + + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" + + "hamlog/internal/applog" +) + +const keyADIFMonitor = "adifmon.config" + +// ADIFWatchFile is one monitored ADIF file. +type ADIFWatchFile struct { + Path string `json:"path"` + Enabled bool `json:"enabled"` + // Offset is the number of bytes already consumed. -1 means "not yet + // initialised": the first scan sets it to the file's current size so existing + // history is skipped. + Offset int64 `json:"offset"` +} + +// ADIFMonitorConfig is the whole monitor setup: a master switch + the file list. +type ADIFMonitorConfig struct { + Enabled bool `json:"enabled"` + Files []ADIFWatchFile `json:"files"` +} + +const eorTag = "" + +func (a *App) loadADIFMonitorLocked() ADIFMonitorConfig { + var cfg ADIFMonitorConfig + if a.settings == nil { + return cfg + } + s, _ := a.settings.GetGlobal(a.ctx, keyADIFMonitor) + if strings.TrimSpace(s) != "" { + _ = json.Unmarshal([]byte(s), &cfg) + } + return cfg +} + +func (a *App) saveADIFMonitorLocked(cfg ADIFMonitorConfig) { + b, _ := json.Marshal(cfg) + a.setSettingGlobal(keyADIFMonitor, string(b)) +} + +// GetADIFMonitor returns the monitor configuration for the settings UI. +func (a *App) GetADIFMonitor() ADIFMonitorConfig { + a.adifMonMu.Lock() + defer a.adifMonMu.Unlock() + return a.loadADIFMonitorLocked() +} + +// SaveADIFMonitor persists the monitor configuration. A file the user just added +// gets Offset = -1 so its existing content is skipped (only QSOs logged AFTER it +// was added import); a file already present keeps its current read position. +func (a *App) SaveADIFMonitor(cfg ADIFMonitorConfig) error { + a.adifMonMu.Lock() + defer a.adifMonMu.Unlock() + old := a.loadADIFMonitorLocked() + oldOff := make(map[string]int64, len(old.Files)) + for _, f := range old.Files { + oldOff[strings.TrimSpace(f.Path)] = f.Offset + } + for i := range cfg.Files { + cfg.Files[i].Path = strings.TrimSpace(cfg.Files[i].Path) + if off, ok := oldOff[cfg.Files[i].Path]; ok { + cfg.Files[i].Offset = off // keep the read position of an existing file + } else { + cfg.Files[i].Offset = -1 // new file → skip its existing history + } + } + a.saveADIFMonitorLocked(cfg) + return nil +} + +// PickADIFMonitorFile opens a file dialog to choose an ADIF file to monitor. +func (a *App) PickADIFMonitorFile() (string, error) { + if a.ctx == nil { + return "", fmt.Errorf("no app context") + } + return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{ + Title: "Choose an ADIF file to monitor", + Filters: []wruntime.FileFilter{ + {DisplayName: "ADIF (*.adi;*.adif)", Pattern: "*.adi;*.adif"}, + {DisplayName: "All files (*.*)", Pattern: "*.*"}, + }, + }) +} + +// adifMonitorLoop polls the enabled ADIF files every few seconds and imports any +// newly appended QSOs. Runs for the app's lifetime on its own goroutine. +func (a *App) adifMonitorLoop() { + tick := time.NewTicker(5 * time.Second) + defer tick.Stop() + for range tick.C { + if a.ctx == nil || a.qso == nil { + continue + } + a.scanADIFMonitors() + } +} + +// scanADIFMonitors walks the enabled files once, importing new records and +// persisting advanced offsets. +func (a *App) scanADIFMonitors() { + a.adifMonMu.Lock() + cfg := a.loadADIFMonitorLocked() + a.adifMonMu.Unlock() + if !cfg.Enabled || len(cfg.Files) == 0 { + return + } + + // path → new offset, for the files we advanced this pass. + advanced := map[string]int64{} + for i := range cfg.Files { + f := &cfg.Files[i] + if !f.Enabled || strings.TrimSpace(f.Path) == "" { + continue + } + fi, err := os.Stat(f.Path) + if err != nil { + continue // not present (yet) — try again next tick + } + size := fi.Size() + if f.Offset < 0 { + // First sight of this file → skip whatever history it already holds. + advanced[f.Path] = size + continue + } + if size < f.Offset { + f.Offset = 0 // truncated / rotated → re-read from the start (dedup protects us) + } + if size == f.Offset { + continue // nothing new + } + newOff, n := a.importADIFAppend(f.Path, f.Offset, size) + if newOff != f.Offset { + advanced[f.Path] = newOff + } + if n > 0 { + applog.Printf("adif monitor: imported %d QSO(s) from %s", n, f.Path) + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "adifmon:imported", map[string]any{"file": f.Path, "count": n}) + } + } + } + + if len(advanced) == 0 { + return + } + // Persist the advanced offsets WITHOUT clobbering a concurrent UI save of the + // file list: re-read the stored config and only patch offsets for paths that + // still exist there. + a.adifMonMu.Lock() + cur := a.loadADIFMonitorLocked() + for i := range cur.Files { + if off, ok := advanced[strings.TrimSpace(cur.Files[i].Path)]; ok { + cur.Files[i].Offset = off + } + } + a.saveADIFMonitorLocked(cur) + a.adifMonMu.Unlock() +} + +// importADIFAppend reads bytes [from,to) of an ADIF file, imports every COMPLETE +// record found (up to the last ) and returns the new offset (just past that +// last ) plus how many QSOs were actually imported (duplicates excluded). +func (a *App) importADIFAppend(path string, from, to int64) (int64, int) { + fh, err := os.Open(path) + if err != nil { + return from, 0 + } + defer fh.Close() + if _, err := fh.Seek(from, io.SeekStart); err != nil { + return from, 0 + } + buf := make([]byte, to-from) + nRead, err := io.ReadFull(fh, buf) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return from, 0 + } + buf = buf[:nRead] + + // Only consume up to the last complete ; a half-written trailing record + // waits for the next poll. + lower := bytes.ToLower(buf) + last := bytes.LastIndex(lower, []byte(eorTag)) + if last < 0 { + return from, 0 // no complete record yet + } + end := last + len(eorTag) + chunk := buf[:end] + + count := 0 + for _, rec := range splitADIFRecords(chunk) { + if strings.TrimSpace(rec) == "" { + continue + } + if _, err := a.LogUDPLoggedADIF(rec); err == nil { + count++ + } + // A duplicate (already in the log within ±2 min) returns an error and is + // simply not counted — expected when the same QSO is also logged in OpsLog. + } + return from + int64(end), count +} + +// splitADIFRecords cuts an ADIF byte slice into individual record texts, each +// ending at its (case-insensitive). Any leading file header (up to the +// first record's ) rides along with the first record — LogUDPLoggedADIF +// parses past an header fine, and prepends one when there is none. +func splitADIFRecords(b []byte) []string { + lower := bytes.ToLower(b) + var out []string + start := 0 + for { + rel := bytes.Index(lower[start:], []byte(eorTag)) + if rel < 0 { + break + } + end := start + rel + len(eorTag) + out = append(out, string(b[start:end])) + start = end + } + return out +} diff --git a/app.go b/app.go index bb0ea04..091c4b2 100644 --- a/app.go +++ b/app.go @@ -483,6 +483,7 @@ type App struct { dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends pttMu sync.Mutex 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) pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission) @@ -808,6 +809,7 @@ func (a *App) startup(ctx context.Context) { a.logDb = logbookConn a.qso = qso.NewRepo(logbookConn) go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks + go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs // cty.dat for offline DXCC / country resolution. Cached on disk; first // run downloads it from country-files.com in the background so startup diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2606009..ebf3e75 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 }, []); diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index b3ac9bf..f942a47 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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> = { 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> = { 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({ 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 ( +
+

{t('adifmon.hint')}

+ +
+ {cfg.files.length === 0 &&

{t('adifmon.empty')}

} + {cfg.files.map((f, i) => ( +
+ persist({ ...cfg, files: cfg.files.map((x, idx) => idx === i ? { ...x, enabled: !!c } : x) })} /> + {f.path} + +
+ ))} +
+ +

{t('adifmon.note')}

+
+ ); +} + // 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, diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index a2279b3..0865ac2 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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.", diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 2f13437..8621712 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -278,6 +278,8 @@ export function FlexSetXITFreq(arg1:number):Promise; export function FlexTune(arg1:boolean):Promise; +export function GetADIFMonitor():Promise; + export function GetActiveProfile():Promise; export function GetAlertEmailTo():Promise; @@ -600,6 +602,8 @@ export function OperatingDefaultForBand(arg1:string):Promise; +export function PickADIFMonitorFile():Promise; + export function PickAudioFolder():Promise; export function PickBackupFolder():Promise; @@ -690,6 +694,8 @@ export function RunBackupNow():Promise; export function SaveADIFFile():Promise; +export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise; + export function SaveAlertRule(arg1:alerts.Rule):Promise; export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index f420045..a08fa43 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -514,6 +514,10 @@ export function FlexTune(arg1) { return window['go']['main']['App']['FlexTune'](arg1); } +export function GetADIFMonitor() { + return window['go']['main']['App']['GetADIFMonitor'](); +} + export function GetActiveProfile() { return window['go']['main']['App']['GetActiveProfile'](); } @@ -1158,6 +1162,10 @@ export function PGXLSetFanMode(arg1) { return window['go']['main']['App']['PGXLSetFanMode'](arg1); } +export function PickADIFMonitorFile() { + return window['go']['main']['App']['PickADIFMonitorFile'](); +} + export function PickAudioFolder() { return window['go']['main']['App']['PickAudioFolder'](); } @@ -1338,6 +1346,10 @@ export function SaveADIFFile() { return window['go']['main']['App']['SaveADIFFile'](); } +export function SaveADIFMonitor(arg1) { + return window['go']['main']['App']['SaveADIFMonitor'](arg1); +} + export function SaveAlertRule(arg1) { return window['go']['main']['App']['SaveAlertRule'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 11d9614..87ea8d6 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1296,6 +1296,55 @@ export namespace lotwusers { export namespace main { + export class ADIFWatchFile { + path: string; + enabled: boolean; + offset: number; + + static createFrom(source: any = {}) { + return new ADIFWatchFile(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.path = source["path"]; + this.enabled = source["enabled"]; + this.offset = source["offset"]; + } + } + export class ADIFMonitorConfig { + enabled: boolean; + files: ADIFWatchFile[]; + + static createFrom(source: any = {}) { + return new ADIFMonitorConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.files = this.convertValues(source["files"], ADIFWatchFile); + } + + 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 AntGeniusSettings { enabled: boolean; host: string;