Files
OpsLog/adifwatch.go
T
rouggy 8fc04563e1 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.
2026-07-19 01:35:00 +02:00

252 lines
7.6 KiB
Go

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 <eor>, 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 = "<eor>"
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 <eor>) and returns the new offset (just past that
// last <eor>) 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 <eor>; 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 <eor> (case-insensitive). Any leading file header (up to the
// first record's <eor>) rides along with the first record — LogUDPLoggedADIF
// parses past an <EOH> 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
}