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 }