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:
+251
@@ -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 <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
|
||||||
|
}
|
||||||
@@ -483,6 +483,7 @@ type App struct {
|
|||||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||||
pttMu sync.Mutex
|
pttMu sync.Mutex
|
||||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
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
|
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
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)
|
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.logDb = logbookConn
|
||||||
a.qso = qso.NewRepo(logbookConn)
|
a.qso = qso.NewRepo(logbookConn)
|
||||||
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
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
|
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
||||||
// run downloads it from country-files.com in the background so startup
|
// run downloads it from country-files.com in the background so startup
|
||||||
|
|||||||
+10
-1
@@ -1913,7 +1913,16 @@ export default function App() {
|
|||||||
else setError('UDP auto-log: ' + msg);
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||||
|
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -169,6 +170,7 @@ type SectionId =
|
|||||||
| 'confirmations'
|
| 'confirmations'
|
||||||
| 'external-services'
|
| 'external-services'
|
||||||
| 'udp'
|
| 'udp'
|
||||||
|
| 'adifmon'
|
||||||
| 'lookup'
|
| 'lookup'
|
||||||
| 'lists-bands'
|
| 'lists-bands'
|
||||||
| 'lists-modes'
|
| '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.cluster'), id: 'cluster' },
|
||||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
{ 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.uscounties'), id: 'uscounties' },
|
||||||
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
{ kind: 'item', label: t('sec.database'), id: 'database' },
|
||||||
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' },
|
{ 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',
|
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',
|
'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',
|
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||||
|
adifmon: 'sec.adifmon',
|
||||||
uscounties: 'sec.uscounties',
|
uscounties: 'sec.uscounties',
|
||||||
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
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',
|
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',
|
database: 'Database',
|
||||||
autostart: 'Autostart',
|
autostart: 'Autostart',
|
||||||
udp: 'UDP integrations',
|
udp: 'UDP integrations',
|
||||||
|
adifmon: 'ADIF monitor',
|
||||||
awards: 'Awards',
|
awards: 'Awards',
|
||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'Rotator',
|
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
|
// 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
|
// panes show, independently: the great-circle map, the locator street map, the
|
||||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
// 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,
|
'lists-modes': ModesPanel,
|
||||||
cluster: ClusterPanel,
|
cluster: ClusterPanel,
|
||||||
udp: UDPIntegrationsPanelWrapper,
|
udp: UDPIntegrationsPanelWrapper,
|
||||||
|
adifmon: ADIFMonitorPanel,
|
||||||
backup: BackupPanel,
|
backup: BackupPanel,
|
||||||
database: DatabasePanel,
|
database: DatabasePanel,
|
||||||
uscounties: USCountiesPanel,
|
uscounties: USCountiesPanel,
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ const en: Dict = {
|
|||||||
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
|
'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.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.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.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.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.',
|
'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.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.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.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.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.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.",
|
'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.",
|
||||||
|
|||||||
Vendored
+6
@@ -278,6 +278,8 @@ export function FlexSetXITFreq(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexTune(arg1:boolean):Promise<void>;
|
export function FlexTune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
export function GetAlertEmailTo():Promise<string>;
|
export function GetAlertEmailTo():Promise<string>;
|
||||||
@@ -600,6 +602,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
|||||||
|
|
||||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function PickADIFMonitorFile():Promise<string>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
|
|
||||||
export function PickBackupFolder():Promise<string>;
|
export function PickBackupFolder():Promise<string>;
|
||||||
@@ -690,6 +694,8 @@ export function RunBackupNow():Promise<string>;
|
|||||||
|
|
||||||
export function SaveADIFFile():Promise<string>;
|
export function SaveADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
|
||||||
|
|
||||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||||
|
|||||||
@@ -514,6 +514,10 @@ export function FlexTune(arg1) {
|
|||||||
return window['go']['main']['App']['FlexTune'](arg1);
|
return window['go']['main']['App']['FlexTune'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetADIFMonitor() {
|
||||||
|
return window['go']['main']['App']['GetADIFMonitor']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetActiveProfile() {
|
export function GetActiveProfile() {
|
||||||
return window['go']['main']['App']['GetActiveProfile']();
|
return window['go']['main']['App']['GetActiveProfile']();
|
||||||
}
|
}
|
||||||
@@ -1158,6 +1162,10 @@ export function PGXLSetFanMode(arg1) {
|
|||||||
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PickADIFMonitorFile() {
|
||||||
|
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||||
|
}
|
||||||
|
|
||||||
export function PickAudioFolder() {
|
export function PickAudioFolder() {
|
||||||
return window['go']['main']['App']['PickAudioFolder']();
|
return window['go']['main']['App']['PickAudioFolder']();
|
||||||
}
|
}
|
||||||
@@ -1338,6 +1346,10 @@ export function SaveADIFFile() {
|
|||||||
return window['go']['main']['App']['SaveADIFFile']();
|
return window['go']['main']['App']['SaveADIFFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveADIFMonitor(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveADIFMonitor'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAlertRule(arg1) {
|
export function SaveAlertRule(arg1) {
|
||||||
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
return window['go']['main']['App']['SaveAlertRule'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1296,6 +1296,55 @@ export namespace lotwusers {
|
|||||||
|
|
||||||
export namespace main {
|
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 {
|
export class AntGeniusSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user