This commit is contained in:
2026-05-26 00:56:08 +02:00
parent 7ace2cc602
commit 7e518ddba3
10 changed files with 51169 additions and 51 deletions
+85 -9
View File
@@ -13,18 +13,24 @@ import (
// ImportResult summarises an ADIF import for the UI.
type ImportResult struct {
Total int `json:"total"` // records found in the file
Imported int `json:"imported"` // successfully inserted
Skipped int `json:"skipped"` // dropped (missing required fields, etc.)
Errors []string `json:"errors"` // up to maxErrors error messages
Total int `json:"total"` // records found in the file
Imported int `json:"imported"` // successfully inserted
Skipped int `json:"skipped"` // dropped (missing required fields)
Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO
DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
Errors []string `json:"errors"` // up to maxErrors error messages
}
const maxErrors = 50
const (
maxErrors = 50
maxDuplicateSamples = 200 // cap memory + JSON payload size
)
// Importer streams an ADI file into a QSO repository.
type Importer struct {
Repo *qso.Repo
BatchSize int // 0 → 500
Repo *qso.Repo
BatchSize int // 0 → 500
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
}
// ImportFile opens the file at path and imports it into the repo.
@@ -47,6 +53,16 @@ func (im *Importer) Import(ctx context.Context, r interface {
res := ImportResult{}
batch := make([]qso.QSO, 0, im.BatchSize)
// One upfront query for every existing dedup key — cheaper than N
// per-record EXISTS calls. The same map gets new keys appended as we
// import so duplicates inside the file are caught too. Loaded
// unconditionally so the "duplicates" count is meaningful even when
// SkipDuplicates is off (the user still wants to know how many).
seen, err := im.Repo.ExistingDedupeKeys(ctx)
if err != nil {
return res, fmt.Errorf("load dedupe keys: %w", err)
}
flush := func() error {
if len(batch) == 0 {
return nil
@@ -57,7 +73,7 @@ func (im *Importer) Import(ctx context.Context, r interface {
return err
}
err := Parse(r, func(rec Record) error {
err = Parse(r, func(rec Record) error {
res.Total++
q, ok := recordToQSO(rec)
if !ok {
@@ -68,6 +84,24 @@ func (im *Importer) Import(ctx context.Context, r interface {
}
return nil
}
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
if _, dup := seen[key]; dup {
res.Duplicates++
if len(res.DuplicateSamples) < maxDuplicateSamples {
res.DuplicateSamples = append(res.DuplicateSamples,
fmt.Sprintf("%s · %s · %s · %s",
q.Callsign,
q.QSODate.UTC().Format("2006-01-02 15:04"),
q.Band, q.Mode))
}
if im.SkipDuplicates {
return nil
}
// Fall through: insert anyway. Don't add to seen[] — keeps
// the duplicate count meaningful if the same row appears 3x.
} else {
seen[key] = struct{}{}
}
batch = append(batch, q)
if len(batch) >= im.BatchSize {
return flush()
@@ -137,10 +171,21 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
}
band := strings.ToLower(strings.TrimSpace(rec["band"]))
mode := strings.ToUpper(strings.TrimSpace(rec["mode"]))
submode := strings.ToUpper(strings.TrimSpace(rec["submode"]))
date := parseDateTime(rec["qso_date"], rec["time_on"])
if date.IsZero() || band == "" || mode == "" {
return qso.QSO{}, false
}
// ADIF promotes specific digital flavours into the SUBMODE field with
// a generic parent in MODE (e.g. MODE=MFSK SUBMODE=FT4). Loggers
// uniformly display the submode in that case — promote it so all
// downstream code (entry strip, worked-before, exports) sees "FT4"
// rather than "MFSK". SSB+USB/LSB and RTTY+JOH stay on the parent
// because the parent IS the displayed mode there.
if submode != "" && submodeSubsumesParent(submode) {
mode = submode
submode = "" // redundant once promoted
}
q := qso.QSO{
Callsign: call,
@@ -149,7 +194,7 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
Band: band,
BandRX: strings.ToLower(rec["band_rx"]),
Mode: mode,
Submode: strings.ToUpper(rec["submode"]),
Submode: submode,
}
if hz, ok := parseFreqHz(rec["freq"]); ok {
q.FreqHz = &hz
@@ -362,3 +407,34 @@ func parseFloat(s string) (float64, bool) {
}
return v, true
}
// promotableSubmodes are SUBMODE values that uniquely identify the mode
// every logger displays — the ADIF parent (MFSK, DATA, PSK, JT…) is
// generic and unhelpful to show. SSB's USB/LSB and RTTY's JOH are NOT
// here because operators want to see "SSB" / "RTTY", not the sideband
// or RTTY variant.
var promotableSubmodes = map[string]bool{
// MFSK family (modern FT/Q65/JS8/MSK144 live here)
"FT2": true, "FT4": true, "FT8": true, "JS8": true, "MSK144": true, "ISCAT": true,
"Q65": true, "FST4": true, "FST4W": true,
"MFSK16": true, "MFSK32": true, "MFSK64": true, "MFSK128": true,
"OLIVIA": true,
// JT family (some loggers still parent these under DATA)
"JT65": true, "JT9": true, "JT4": true, "JT6M": true, "JT44": true, "T10": true,
// PSK family
"PSK31": true, "PSK63": true, "PSK125": true, "PSK250": true, "PSK500": true,
"QPSK31": true, "QPSK63": true, "QPSK125": true, "QPSK250": true, "QPSK500": true,
// THOR / DOMINO / HELL
"THOR4": true, "THOR8": true, "THOR16": true, "THOR32": true,
"DOMINOF": true, "DOMINOEX": true,
"HELL80": true, "FMHELL": true,
// DIGITALVOICE variants
"FREEDV": true,
// VARA family — Log4OM parents these under DYNAMIC. VarAC chat uses
// "VARA HF" (with the space) so we match both spellings.
"VARA": true, "VARA HF": true, "VARA FM": true, "VARAC": true,
}
func submodeSubsumesParent(submode string) bool {
return promotableSubmodes[submode]
}
+33
View File
@@ -807,6 +807,39 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
return n, err
}
// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
// used by the ADIF importer to skip records that would re-create the
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute
// precision so two loggers that wrote a few seconds apart still match.
//
// On a 25k-row table this returns ~25k strings (~2MB RAM) in one pass —
// far cheaper than N exists-queries during the import loop.
func (r *Repo) ExistingDedupeKeys(ctx context.Context) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode
FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 1024)
for rows.Next() {
var call, when, band, mode string
if err := rows.Scan(&call, &when, &band, &mode); err != nil {
return nil, err
}
out[DedupeKey(call, when, band, mode)] = struct{}{}
}
return out, rows.Err()
}
// DedupeKey is the canonical dedupe identity for a QSO. Exposed so the
// importer can compute the key from in-flight records and check against
// the same map ExistingDedupeKeys returns.
func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode)
}
// scanner is what both *sql.Row and *sql.Rows satisfy for our needs.
type scanner interface {
Scan(dest ...any) error
+68
View File
@@ -0,0 +1,68 @@
// Package pst sends commands to PstRotator over its UDP listener.
//
// PstRotator (Codrut Buda YO3DMU) exposes a simple text/XML protocol on
// a configurable UDP port (default 12000 on localhost). Each command is a
// single fire-and-forget datagram — no handshake, no response. This keeps
// us connectionless and means a misconfigured port silently no-ops rather
// than hanging the UI. Run the matching "Test" action to confirm the link.
package pst
import (
"fmt"
"net"
"time"
)
// Client is a stateless UDP sender. Safe to construct cheaply per call —
// the underlying socket only lives for the length of one Write.
type Client struct {
Host string // hostname or IP of the PstRotator host (usually "127.0.0.1")
Port int // UDP port (PstRotator default = 12000)
}
// New returns a Client with sane defaults applied for empty fields.
func New(host string, port int) *Client {
if host == "" {
host = "127.0.0.1"
}
if port <= 0 || port > 65535 {
port = 12000
}
return &Client{Host: host, Port: port}
}
// GoTo points the antenna at azimuth (0-359°). If hasElevation is true
// and el >= 0 the elevation field is included too (VHF/satellite setups);
// otherwise PstRotator just turns in azimuth.
func (c *Client) GoTo(az int, hasElevation bool, el int) error {
az = ((az % 360) + 360) % 360 // normalise to [0,360)
if hasElevation && el >= 0 && el <= 180 {
return c.send(fmt.Sprintf("<PST><AZIMUTH>%d</AZIMUTH><ELEVATION>%d</ELEVATION></PST>", az, el))
}
return c.send(fmt.Sprintf("<PST><AZIMUTH>%d</AZIMUTH></PST>", az))
}
// Stop interrupts any in-progress rotation.
func (c *Client) Stop() error {
return c.send("<PST><STOP>1</STOP></PST>")
}
// Park sends the rotator to its parked position (configured inside
// PstRotator itself — we just trigger it).
func (c *Client) Park() error {
return c.send("<PST><PARK>1</PARK></PST>")
}
func (c *Client) send(payload string) error {
addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
conn, err := net.DialTimeout("udp", addr, 2*time.Second)
if err != nil {
return fmt.Errorf("dial PstRotator at %s: %w", addr, err)
}
defer conn.Close()
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
if _, err := conn.Write([]byte(payload)); err != nil {
return fmt.Errorf("send to PstRotator: %w", err)
}
return nil
}