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.
252 lines
7.6 KiB
Go
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
|
|
}
|