Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40e95e6a16 | |||
| cc0f9ffc64 | |||
| e1f1ab4922 | |||
| b6d991b799 | |||
| 59f1775fcd |
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go:*)",
|
||||
"Bash(gofmt:*)",
|
||||
"Bash(/c/Users/legre/go/bin/wails:*)",
|
||||
"Bash(wails:*)",
|
||||
"Bash(npm:*)",
|
||||
"Bash(npx:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(tail:*)",
|
||||
"Bash(awk:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(sort:*)",
|
||||
"Bash(uniq:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(for f in *)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(curl -sI --max-time 10 https://raw.githubusercontent.com/google/fonts/main/ofl/archivoblack/ArchivoBlack-Regular.ttf)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,125 @@
|
||||
# README
|
||||
# OpsLog
|
||||
|
||||
## About
|
||||
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
||||
(OmniRig **and** native FlexRadio/SmartSDR), DX cluster, awards tracking, maps,
|
||||
QSL management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
||||
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
|
||||
optional **shared MySQL** for the logbook so several operators can run one log.
|
||||
|
||||
This is the official Wails Svelte-TS template.
|
||||
Developed by **F4BPO**.
|
||||
|
||||
## Live Development
|
||||
---
|
||||
|
||||
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
|
||||
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
|
||||
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
|
||||
to this in your browser, and you can call your Go code from devtools.
|
||||
## Building / developing
|
||||
|
||||
## Building
|
||||
- **Dev:** `wails dev` (Vite hot-reload; Go methods reachable at http://localhost:34115).
|
||||
- **Build:** `wails build` (use the project's wails v2.11 — `~/go/bin/wails.exe`).
|
||||
- **Regenerate Go↔TS bindings** after changing exported `App` methods:
|
||||
`wails generate module`.
|
||||
- **Release:** `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task* →
|
||||
*Release OpsLog*) — bumps the version, pushes source to Gitea, builds the exe
|
||||
and publishes it to Gitea + GitHub releases.
|
||||
|
||||
To build a redistributable, production mode package, use `wails build`.
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
||||
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
|
||||
**flag** is shown large next to the RST fields.
|
||||
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
||||
and the QRZ.com tab.
|
||||
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||
with `/MM` `/AM` and call-area (`/8`, `/W6`) handling, plus ClubLog DXpedition
|
||||
date overrides.
|
||||
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||
from cty/QRZ/ClubLog, bulk send to QSL services.
|
||||
- **Profiles:** every setting is per-profile; each profile can point its logbook
|
||||
at the local SQLite file or a **shared MySQL** database (multi-operator).
|
||||
|
||||
## Maps & antenna
|
||||
|
||||
- **Main view = two configurable panes** (per profile, Settings → General →
|
||||
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
||||
worked-before grid, or the **FlexRadio controls**.
|
||||
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
||||
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by PstRotator.
|
||||
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
|
||||
direction is shown in green and the **mechanical boom** in grey, on both the
|
||||
compass and the map, so you never lose track of where the antenna points.
|
||||
|
||||
## DX Cluster
|
||||
|
||||
- Multiple cluster servers with auto-reconnect, a master for commands.
|
||||
- **Filter sidebar** (callsign search, hide-worked, group duplicates, band /
|
||||
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
||||
pane, with a show/hide toggle.
|
||||
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
||||
rig, and a multi-band **Band Map** (panadapter-style strips).
|
||||
|
||||
## CAT control
|
||||
|
||||
- **OmniRig** backend (Rig 1/2, hot-swap), and a native **FlexRadio (SmartSDR)**
|
||||
backend over the radio's TCP API — real-time slice freq/mode/split, auto
|
||||
reconnect, UDP discovery, and **panadapter spots** (cluster spots pushed to the
|
||||
Flex display, click → fill the call).
|
||||
- Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred
|
||||
from the frequency.
|
||||
|
||||
### FlexRadio control tab (SmartSDR-style)
|
||||
|
||||
Shown only when the CAT backend is a FlexRadio:
|
||||
|
||||
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
|
||||
VOX (+ level + delay), monitor (+ level), mic gain.
|
||||
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
|
||||
- **Antenna tuner (ATU):** tune / bypass / memories.
|
||||
- **Amplifier:** PowerGenius XL operate/standby + fault.
|
||||
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
||||
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||
|
||||
## Keyers & audio
|
||||
|
||||
- **WinKeyer** CW keyer (macros, F-key macros, auto-call repeat).
|
||||
- **Digital Voice Keyer** (DVK) message playback.
|
||||
- **QSO audio recording** (SSB/DAX) archived per QSO; disabled for CW (no DAX
|
||||
audio in CW).
|
||||
|
||||
## QSL & awards
|
||||
|
||||
- **Awards engine:** built-in + custom award definitions (shared **globally**
|
||||
across profiles), worked/confirmed/validated by band & mode, OR rules and
|
||||
manual reference assignment, live reference detection on call entry, and a
|
||||
**Rescan** that re-pulls the logbook (picks up fresh LoTW/QRZ confirmations).
|
||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
||||
and **confirmation download** (which auto-refreshes the award stats).
|
||||
- **QSL Card Designer** (see below).
|
||||
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
|
||||
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
||||
password, or a Microsoft app password.)
|
||||
|
||||
## Multi-operator live status (special events)
|
||||
|
||||
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
|
||||
Settings → General → *Publish live operator status*. Each OpsLog instance
|
||||
heartbeats its current activity (operator call, band, frequency, mode) into a
|
||||
`live_status` table every ~15 s. A small PHP renderer
|
||||
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
|
||||
own web server reads that table and produces a live page/image you can embed on
|
||||
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
|
||||
only writes to the DB — it is not a web server.
|
||||
|
||||
## Other
|
||||
|
||||
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||
OpsLog startup, skipping any already running.
|
||||
- **Update check** at startup with a toast (toggleable).
|
||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||
|
||||
---
|
||||
|
||||
## QSL Card Designer
|
||||
|
||||
@@ -40,3 +146,13 @@ Fonts: Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura (all
|
||||
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
|
||||
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
|
||||
commonly-worked DXCC entities.
|
||||
|
||||
---
|
||||
|
||||
## Data & storage
|
||||
|
||||
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
|
||||
award lists, QSL templates) always lives in the local SQLite file under
|
||||
`data/` — instant even when the logbook is on a far-away MySQL.
|
||||
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
|
||||
file or a per-profile shared **MySQL** database.
|
||||
|
||||
@@ -196,7 +196,8 @@ const (
|
||||
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
|
||||
keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
|
||||
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
|
||||
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag"
|
||||
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags)
|
||||
keyExtLoTWUploadFlags = "extsvc.lotw.upload_flags" // CSV set of lotw_sent values to upload (e.g. "N,R")
|
||||
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
|
||||
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
|
||||
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
|
||||
@@ -523,8 +524,22 @@ func (a *App) startup(ctx context.Context) {
|
||||
// Windows reinstall. It lives OUTSIDE the DB since we must know the path
|
||||
// before opening it.
|
||||
if custom := readDBPointer(dataDir); custom != "" {
|
||||
a.dbPath = custom
|
||||
usingDefault = false
|
||||
// Portability guard: a pointer that is merely ANOTHER folder's default DB
|
||||
// location ("…/<other>/data/opslog.db") means the portable folder was
|
||||
// renamed or copied — its config.json still points at the original. Ignore
|
||||
// it and use THIS folder's own data (and clear the stale pointer so it
|
||||
// stops happening). A genuine custom location — another drive, a different
|
||||
// filename — is NOT default-style, so it's still honoured.
|
||||
stale := strings.EqualFold(filepath.Base(custom), "opslog.db") &&
|
||||
strings.EqualFold(filepath.Base(filepath.Dir(custom)), "data") &&
|
||||
!strings.EqualFold(filepath.Clean(filepath.Dir(custom)), filepath.Clean(dataDir))
|
||||
if stale {
|
||||
fmt.Printf("OpsLog: ignoring stale DB pointer %q (folder moved) — using %s\n", custom, a.dbPath)
|
||||
_ = writeDBPointer(dataDir, "")
|
||||
} else {
|
||||
a.dbPath = custom
|
||||
usingDefault = false
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
||||
a.startupErr = "cannot create db folder: " + err.Error()
|
||||
@@ -710,12 +725,13 @@ func (a *App) startup(ctx context.Context) {
|
||||
// from settings and host callbacks to build ADIF, stamp the upload
|
||||
// status and surface errors to the UI.
|
||||
a.extsvc = extsvc.NewManager(extsvc.Deps{
|
||||
BuildADIF: a.buildUploadADIF,
|
||||
MarkUploaded: a.markExtUploaded,
|
||||
NotifyError: a.notifyExtError,
|
||||
ShouldUpload: a.extShouldUpload,
|
||||
StationCallOf: a.stationCallOf,
|
||||
Logf: applog.Printf,
|
||||
BuildADIF: a.buildUploadADIF,
|
||||
MarkUploaded: a.markExtUploaded,
|
||||
NotifyError: a.notifyExtError,
|
||||
ShouldUpload: a.extShouldUpload,
|
||||
StationCallOf: a.stationCallOf,
|
||||
CloseUploadIDs: a.closeUploadIDs,
|
||||
Logf: applog.Printf,
|
||||
})
|
||||
a.extsvc.SetConfig(a.loadExternalServices())
|
||||
|
||||
@@ -841,7 +857,7 @@ func (a *App) plannedShutdownSteps() []shutdownStep {
|
||||
}
|
||||
}
|
||||
if a.extsvc != nil {
|
||||
if n := a.extsvc.PendingCount(); n > 0 {
|
||||
if n := a.extsvc.CloseUploadCount(); n > 0 {
|
||||
out = append(out, shutdownStep{
|
||||
ID: "extsvc-upload",
|
||||
Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n),
|
||||
@@ -3174,6 +3190,15 @@ func (a *App) DeleteQSO(id int64) error {
|
||||
return a.qso.Delete(a.ctx, id)
|
||||
}
|
||||
|
||||
// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
|
||||
// number actually deleted.
|
||||
func (a *App) DeleteQSOs(ids []int64) (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.DeleteMany(a.ctx, ids)
|
||||
}
|
||||
|
||||
// QSLBulkUpdate carries the paper-QSL fields to apply to a selection. An empty
|
||||
// string leaves that field unchanged (so you can set only "received = Y + date"
|
||||
// without touching the sent side).
|
||||
@@ -4799,6 +4824,31 @@ func (a *App) getManyScoped(prefix string, keys ...string) (map[string]string, e
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseUploadFlags resolves the LoTW "treat as unsent" status set: prefer the
|
||||
// CSV (new multi-select), fall back to the legacy single flag, and default to
|
||||
// N+R when nothing is configured (covers an imported ADIF still marked unsent).
|
||||
func parseUploadFlags(csv, legacy string) []string {
|
||||
add := func(dst []string, seen map[string]bool, raw string) []string {
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
f := strings.ToUpper(strings.TrimSpace(p))
|
||||
if (f == "N" || f == "R") && !seen[f] {
|
||||
seen[f] = true
|
||||
dst = append(dst, f)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := add(nil, seen, csv)
|
||||
if len(out) == 0 {
|
||||
out = add(out, seen, legacy)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{"N", "R"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
var out extsvc.ExternalServices
|
||||
if a.settings == nil {
|
||||
@@ -4815,7 +4865,7 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
||||
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
|
||||
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword,
|
||||
keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
|
||||
keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
|
||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||
keyExtLoTWUsername, keyExtLoTWWebPassword)
|
||||
if err != nil {
|
||||
@@ -4847,12 +4897,15 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
StationLocation: m[keyExtLoTWStationLoc],
|
||||
ForceStationCallsign: m[keyExtLoTWForceCall],
|
||||
KeyPassword: m[keyExtLoTWKeyPassword],
|
||||
UploadFlag: m[keyExtLoTWUploadFlag],
|
||||
UploadFlags: parseUploadFlags(m[keyExtLoTWUploadFlags], m[keyExtLoTWUploadFlag]),
|
||||
WriteLog: m[keyExtLoTWWriteLog] == "1",
|
||||
Username: m[keyExtLoTWUsername],
|
||||
Password: m[keyExtLoTWWebPassword],
|
||||
AutoUpload: m[keyExtLoTWAutoUpload] == "1",
|
||||
UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]),
|
||||
// LoTW only ever uploads as an on-close batch (ARRL discourages per-QSO
|
||||
// uploads), so the UI offers no other timing. Force it here so configs
|
||||
// saved by older builds — which stored "immediate" — still batch at close.
|
||||
UploadMode: extsvc.ModeOnClose,
|
||||
}
|
||||
// Default the TQSL path to the standard install location when unset, so
|
||||
// the field is pre-populated if TQSL is present.
|
||||
@@ -4873,34 +4926,35 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
mode := string(extsvc.ModeImmediate)
|
||||
if cfg.QRZ.UploadMode == extsvc.ModeDelayed {
|
||||
mode = string(extsvc.ModeDelayed)
|
||||
// Preserve the chosen upload timing — including "on_close", which the LoTW
|
||||
// batch flush at shutdown depends on. (A previous version collapsed anything
|
||||
// that wasn't "delayed" to "immediate", silently disabling on-close upload.)
|
||||
modeOf := func(m extsvc.UploadMode) string {
|
||||
switch m {
|
||||
case extsvc.ModeDelayed:
|
||||
return string(extsvc.ModeDelayed)
|
||||
case extsvc.ModeOnClose:
|
||||
return string(extsvc.ModeOnClose)
|
||||
default:
|
||||
return string(extsvc.ModeImmediate)
|
||||
}
|
||||
}
|
||||
mode := modeOf(cfg.QRZ.UploadMode)
|
||||
auto := "0"
|
||||
if cfg.QRZ.AutoUpload {
|
||||
auto = "1"
|
||||
}
|
||||
clMode := string(extsvc.ModeImmediate)
|
||||
if cfg.Clublog.UploadMode == extsvc.ModeDelayed {
|
||||
clMode = string(extsvc.ModeDelayed)
|
||||
}
|
||||
clMode := modeOf(cfg.Clublog.UploadMode)
|
||||
clAuto := "0"
|
||||
if cfg.Clublog.AutoUpload {
|
||||
clAuto = "1"
|
||||
}
|
||||
ltMode := string(extsvc.ModeImmediate)
|
||||
if cfg.LoTW.UploadMode == extsvc.ModeDelayed {
|
||||
ltMode = string(extsvc.ModeDelayed)
|
||||
}
|
||||
ltMode := modeOf(cfg.LoTW.UploadMode)
|
||||
ltAuto := "0"
|
||||
if cfg.LoTW.AutoUpload {
|
||||
ltAuto = "1"
|
||||
}
|
||||
ltFlag := strings.ToUpper(strings.TrimSpace(cfg.LoTW.UploadFlag))
|
||||
if ltFlag != "N" && ltFlag != "R" {
|
||||
ltFlag = "R"
|
||||
}
|
||||
ltFlags := strings.Join(parseUploadFlags(strings.Join(cfg.LoTW.UploadFlags, ","), ""), ",")
|
||||
ltWriteLog := "0"
|
||||
if cfg.LoTW.WriteLog {
|
||||
ltWriteLog = "1"
|
||||
@@ -4923,7 +4977,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
|
||||
keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)),
|
||||
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
|
||||
keyExtLoTWUploadFlag: ltFlag,
|
||||
keyExtLoTWUploadFlags: ltFlags,
|
||||
keyExtLoTWWriteLog: ltWriteLog,
|
||||
keyExtLoTWAutoUpload: ltAuto,
|
||||
keyExtLoTWUploadMode: ltMode,
|
||||
@@ -5189,12 +5243,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
switch svc {
|
||||
case extsvc.ServiceLoTW:
|
||||
sinceDate := resolveSince(keyExtLoTWLastDownload)
|
||||
if sinceDate != "" {
|
||||
emit("Downloading LoTW confirmations received since " + sinceDate + "…")
|
||||
} else {
|
||||
emit("Downloading all LoTW confirmations…")
|
||||
ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW)
|
||||
callLabel := ownCall
|
||||
if callLabel == "" {
|
||||
callLabel = "all callsigns"
|
||||
}
|
||||
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate)
|
||||
if sinceDate != "" {
|
||||
emit(fmt.Sprintf("Downloading LoTW confirmations for %s received since %s…", callLabel, sinceDate))
|
||||
} else {
|
||||
emit(fmt.Sprintf("Downloading all LoTW confirmations for %s…", callLabel))
|
||||
}
|
||||
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall)
|
||||
if err != nil {
|
||||
emit("Download failed: " + err.Error())
|
||||
done(matched, total)
|
||||
@@ -5659,6 +5718,84 @@ func (a *App) stationCallOf(id int64) string {
|
||||
return strings.ToUpper(strings.TrimSpace(q.StationCallsign))
|
||||
}
|
||||
|
||||
// uploadOwnerCall returns the callsign OpsLog signs/uploads/downloads as for a
|
||||
// service in the active profile: the configured Force/owner callsign, else the
|
||||
// active profile's callsign. "" when nothing is known (don't scope by call).
|
||||
func (a *App) uploadOwnerCall(svc extsvc.Service) string {
|
||||
cfg := a.loadExternalServices()
|
||||
owner := ""
|
||||
switch svc {
|
||||
case extsvc.ServiceLoTW:
|
||||
owner = cfg.LoTW.ForceStationCallsign
|
||||
case extsvc.ServiceQRZ:
|
||||
owner = cfg.QRZ.ForceStationCallsign
|
||||
case extsvc.ServiceClublog:
|
||||
owner = cfg.Clublog.Callsign
|
||||
}
|
||||
owner = strings.ToUpper(strings.TrimSpace(owner))
|
||||
if owner == "" && a.profiles != nil {
|
||||
if p, perr := a.profiles.Active(a.ctx); perr == nil {
|
||||
owner = strings.ToUpper(strings.TrimSpace(p.Callsign))
|
||||
}
|
||||
}
|
||||
return owner
|
||||
}
|
||||
|
||||
// UploadCallsign exposes uploadOwnerCall to the UI so the QSL Manager can show
|
||||
// which of the operator's callsigns a download/upload targets in this profile.
|
||||
func (a *App) UploadCallsign(service string) string {
|
||||
return a.uploadOwnerCall(extsvc.Service(service))
|
||||
}
|
||||
|
||||
// closeUploadIDs returns the QSO ids to upload to a service at app close,
|
||||
// scanning the whole logbook: LoTW matches the configured sent-status set
|
||||
// (N/R), QRZ/Club Log return anything not yet "Y". This is what lets an
|
||||
// imported ADIF (old QSOs still unsent) flush on close.
|
||||
func (a *App) closeUploadIDs(svc extsvc.Service) []int64 {
|
||||
if a.qso == nil {
|
||||
return nil
|
||||
}
|
||||
col := uploadColumnFor(string(svc))
|
||||
if col == "" {
|
||||
return nil
|
||||
}
|
||||
// owner is the callsign this logbook signs/uploads as. Each external
|
||||
// logbook belongs to ONE call, so in a mixed-call DB (F4BPO, F4BPO/P, TM2Q)
|
||||
// we must only sweep the QSOs that belong to it — otherwise TM2Q QSOs would
|
||||
// be signed under the F4BPO certificate, or pushed to the wrong QRZ/Club Log
|
||||
// logbook.
|
||||
var statuses []string
|
||||
if svc == extsvc.ServiceLoTW {
|
||||
statuses = a.loadExternalServices().LoTW.UploadFlags
|
||||
if len(statuses) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
owner := a.uploadOwnerCall(svc)
|
||||
|
||||
cands, err := a.qso.ListUploadCandidates(a.ctx, col, statuses)
|
||||
if err != nil {
|
||||
applog.Printf("extsvc: close-upload candidate scan for %s failed: %v", svc, err)
|
||||
return nil
|
||||
}
|
||||
out := make([]int64, 0, len(cands))
|
||||
skipped := 0
|
||||
for _, c := range cands {
|
||||
// Keep QSOs that belong to this logbook's call. A blank STATION_CALLSIGN
|
||||
// is assumed to be ours (it gets signed/labelled as owner on upload),
|
||||
// mirroring the per-QSO guard in extsvc.upload.
|
||||
if owner == "" || c.StationCallsign == "" || extsvc.SameBaseCall(c.StationCallsign, owner) {
|
||||
out = append(out, c.ID)
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
if skipped > 0 {
|
||||
applog.Printf("extsvc: %s close-upload skipped %d QSO(s) not matching logbook callsign %q", svc, skipped, owner)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extShouldUpload reports whether a QSO is eligible for upload to a service,
|
||||
// based on its sent status. QRZ/Club Log upload anything not yet "Y"; LoTW
|
||||
// uploads only QSOs whose lotw_sent matches the configured Upload flag
|
||||
@@ -5685,15 +5822,12 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
||||
}
|
||||
return true
|
||||
case extsvc.ServiceLoTW:
|
||||
flag := "R"
|
||||
if a.settings != nil {
|
||||
if m, e := a.settings.GetMany(a.ctx, keyExtLoTWUploadFlag); e == nil {
|
||||
if v := strings.ToUpper(strings.TrimSpace(m[keyExtLoTWUploadFlag])); v == "N" || v == "R" {
|
||||
flag = v
|
||||
}
|
||||
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
||||
if strings.EqualFold(q.LOTWSent, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.EqualFold(q.LOTWSent, flag)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+40
-28
@@ -7,7 +7,7 @@ import {
|
||||
import {
|
||||
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
||||
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||
GetStartupStatus, CheckForUpdate,
|
||||
@@ -701,8 +701,10 @@ export default function App() {
|
||||
|
||||
// === Modals ===
|
||||
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
||||
const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null);
|
||||
// QSOs queued for the delete confirm (1 or many — multi-row selection).
|
||||
const [deletingIds, setDeletingIds] = useState<number[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||
@@ -1584,8 +1586,8 @@ export default function App() {
|
||||
} catch (err: any) { setError(String(err?.message ?? err)); }
|
||||
}
|
||||
function onModalDelete(id: number) {
|
||||
const q = editingQSO; setEditingQSO(null);
|
||||
if (q) setDeletingQSO(q); else askDelete(id);
|
||||
setEditingQSO(null);
|
||||
setDeletingIds([id]);
|
||||
}
|
||||
|
||||
// Bulk grid actions (right-click menu). Recompute country/zones from
|
||||
@@ -1654,20 +1656,25 @@ export default function App() {
|
||||
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
function askDelete(id: number) {
|
||||
const q = qsos.find((x) => x.id === id);
|
||||
if (q) setDeletingQSO(q);
|
||||
function askDelete(id: number) { setDeletingIds([id]); }
|
||||
// Delete the whole multi-row selection (Edit menu / Delete key).
|
||||
function askDeleteSelected() {
|
||||
if (selectedIds.length > 0) setDeletingIds(selectedIds);
|
||||
else if (selectedId != null) setDeletingIds([selectedId]);
|
||||
}
|
||||
async function confirmDelete() {
|
||||
if (!deletingQSO) return;
|
||||
if (deletingIds.length === 0) return;
|
||||
const ids = deletingIds;
|
||||
try {
|
||||
await DeleteQSO(deletingQSO.id);
|
||||
if (selectedId === deletingQSO.id) setSelectedId(null);
|
||||
setDeletingQSO(null);
|
||||
if (ids.length === 1) await DeleteQSO(ids[0]);
|
||||
else await DeleteQSOs(ids as any);
|
||||
setDeletingIds([]);
|
||||
setSelectedId(null);
|
||||
setSelectedIds([]);
|
||||
await refresh();
|
||||
} catch (err: any) {
|
||||
setError(String(err?.message ?? err));
|
||||
setDeletingQSO(null);
|
||||
setDeletingIds([]);
|
||||
}
|
||||
}
|
||||
async function confirmDeleteAll() {
|
||||
@@ -1878,7 +1885,7 @@ export default function App() {
|
||||
]},
|
||||
{ name: 'edit', label: 'Edit', items: [
|
||||
{ type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
|
||||
{ type: 'item', label: 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
|
||||
{ type: 'item', label: selectedIds.length > 1 ? `Delete ${selectedIds.length} selected QSOs` : 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' },
|
||||
]},
|
||||
@@ -1901,7 +1908,7 @@ export default function App() {
|
||||
{ name: 'help', label: 'Help', items: [
|
||||
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
||||
]},
|
||||
], [total, selectedId, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]);
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
@@ -1911,7 +1918,7 @@ export default function App() {
|
||||
case 'view.refresh': refresh(); break;
|
||||
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
||||
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
||||
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
|
||||
case 'edit.delete': askDeleteSelected(); break;
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||
@@ -2008,14 +2015,14 @@ export default function App() {
|
||||
}
|
||||
if (typing) return;
|
||||
if (selectedId !== null) {
|
||||
if (e.key === 'Delete') { e.preventDefault(); askDelete(selectedId); return; }
|
||||
if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; }
|
||||
if (e.key === 'Enter') { e.preventDefault(); openEdit(selectedId); return; }
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId, refresh]);
|
||||
}, [selectedId, selectedIds, refresh]);
|
||||
|
||||
// ── Entry-field blocks ─────────────────────────────────────────────────
|
||||
// Each field is defined once here, then composed into either the compact
|
||||
@@ -3213,7 +3220,7 @@ export default function App() {
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onExportSelected={exportSelectedADIF}
|
||||
onExportFiltered={exportFilteredADIF}
|
||||
onRowSelected={(id) => setSelectedId(id)}
|
||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||
/>
|
||||
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -3592,16 +3599,21 @@ export default function App() {
|
||||
onOpenDesigner={() => setQslDesignerOpen(true)}
|
||||
/>
|
||||
|
||||
{deletingQSO && (
|
||||
<ConfirmDialog
|
||||
title="Delete QSO?"
|
||||
message={`This will permanently delete the QSO with ${deletingQSO.callsign} on ${fmtDateUTC(deletingQSO.qso_date)} (${deletingQSO.band} ${deletingQSO.mode}). This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeletingQSO(null)}
|
||||
/>
|
||||
)}
|
||||
{deletingIds.length > 0 && (() => {
|
||||
const single = deletingIds.length === 1 ? qsos.find((x) => x.id === deletingIds[0]) : null;
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title={deletingIds.length > 1 ? `Delete ${deletingIds.length} QSOs?` : 'Delete QSO?'}
|
||||
message={single
|
||||
? `This will permanently delete the QSO with ${single.callsign} on ${fmtDateUTC(single.qso_date)} (${single.band} ${single.mode}). This cannot be undone.`
|
||||
: `This will permanently delete ${deletingIds.length} selected QSO${deletingIds.length > 1 ? 's' : ''}. This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeletingIds([])}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{showDeleteAll && (
|
||||
<ConfirmDialog
|
||||
title="Delete ALL QSOs?"
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL } from '../../wailsjs/go/main/App';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
|
||||
@@ -77,6 +77,14 @@ export function fmtQslDate(s?: string): string {
|
||||
// and download confirmations, while the rest of the app stays usable.
|
||||
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||
const [service, setService] = useState('lotw');
|
||||
// The callsign this profile signs/uploads/downloads as for the selected
|
||||
// service (Force station callsign, else the profile call). Shown so the user
|
||||
// knows WHICH of their calls a download/upload targets in a mixed-call log.
|
||||
const [uploadCall, setUploadCall] = useState('');
|
||||
useEffect(() => {
|
||||
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
||||
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||
}, [service]);
|
||||
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||
const [potaErr, setPotaErr] = useState('');
|
||||
@@ -251,6 +259,17 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{uploadCall && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
|
||||
<span
|
||||
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
|
||||
title="Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)"
|
||||
>
|
||||
{uploadCall}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{service === 'pota' ? (
|
||||
<>
|
||||
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
||||
|
||||
@@ -46,7 +46,7 @@ type Props = {
|
||||
rows: QSOForm[];
|
||||
total: number;
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onRowSelected?: (id: number | null) => void;
|
||||
onRowSelected?: (ids: number[]) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
@@ -277,8 +277,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
}
|
||||
function onSelectionChanged() {
|
||||
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
|
||||
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
|
||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||
}
|
||||
|
||||
// ── Column picker (visibility) ──
|
||||
|
||||
@@ -749,14 +749,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
api_key: string; email: string; username: string; password: string; callsign: string;
|
||||
force_station_callsign: string;
|
||||
tqsl_path: string; station_location: string; key_password: string;
|
||||
upload_flag: string; write_log: boolean;
|
||||
upload_flags: string[]; write_log: boolean;
|
||||
auto_upload: boolean; upload_mode: string;
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', email: '', username: '', password: '', callsign: '',
|
||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||
upload_flag: 'R', write_log: false,
|
||||
upload_flags: ['N', 'R'], write_log: false,
|
||||
auto_upload: false, upload_mode: 'immediate',
|
||||
});
|
||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||
@@ -2892,17 +2892,32 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
placeholder="only if your certificate key has a password"
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label className="text-sm">Upload flag</Label>
|
||||
<Label className="text-sm">Consider as unsent</Label>
|
||||
<div>
|
||||
<Select value={lotw.upload_flag || 'R'} onValueChange={(v) => setLotw({ upload_flag: v })}>
|
||||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="N">Upload when LoTW sent = No</SelectItem>
|
||||
<SelectItem value="R">Upload when LoTW sent = Requested</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-4">
|
||||
{(['N', 'R'] as const).map((f) => {
|
||||
const flags = lotw.upload_flags ?? [];
|
||||
const checked = flags.includes(f);
|
||||
return (
|
||||
<label key={f} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => {
|
||||
const next = c
|
||||
? Array.from(new Set([...flags, f]))
|
||||
: flags.filter((x) => x !== f);
|
||||
setLotw({ upload_flags: next });
|
||||
}}
|
||||
/>
|
||||
{f === 'N' ? 'No (N)' : 'Requested (R)'}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
Must match your default LoTW <em>sent</em> status in Confirmations, or new QSOs won't be picked up.
|
||||
At app close, every QSO whose LoTW <em>sent</em> status is one of these is signed and
|
||||
uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become
|
||||
<em> Y</em> and won't be re-sent. Must include your default <em>sent</em> status from Confirmations.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.11.2';
|
||||
export const APP_VERSION = '0.11.3';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+4
@@ -87,6 +87,8 @@ export function DeleteProfile(arg1:number):Promise<void>;
|
||||
|
||||
export function DeleteQSO(arg1:number):Promise<void>;
|
||||
|
||||
export function DeleteQSOs(arg1:Array<number>):Promise<number>;
|
||||
|
||||
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
||||
|
||||
export function DisconnectAllClusters():Promise<void>;
|
||||
@@ -499,6 +501,8 @@ export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
|
||||
|
||||
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
||||
|
||||
export function UploadCallsign(arg1:string):Promise<string>;
|
||||
|
||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||
|
||||
export function WinkeyerBackspace():Promise<void>;
|
||||
|
||||
@@ -146,6 +146,10 @@ export function DeleteQSO(arg1) {
|
||||
return window['go']['main']['App']['DeleteQSO'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteQSOs(arg1) {
|
||||
return window['go']['main']['App']['DeleteQSOs'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteUDPIntegration(arg1) {
|
||||
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
|
||||
}
|
||||
@@ -970,6 +974,10 @@ export function UpdateQSOsFromQRZ(arg1) {
|
||||
return window['go']['main']['App']['UpdateQSOsFromQRZ'](arg1);
|
||||
}
|
||||
|
||||
export function UploadCallsign(arg1) {
|
||||
return window['go']['main']['App']['UploadCallsign'](arg1);
|
||||
}
|
||||
|
||||
export function UploadQSOsManual(arg1, arg2) {
|
||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -673,7 +673,7 @@ export namespace extsvc {
|
||||
tqsl_path: string;
|
||||
station_location: string;
|
||||
key_password: string;
|
||||
upload_flag: string;
|
||||
upload_flags: string[];
|
||||
write_log: boolean;
|
||||
auto_upload: boolean;
|
||||
upload_mode: string;
|
||||
@@ -693,7 +693,7 @@ export namespace extsvc {
|
||||
this.tqsl_path = source["tqsl_path"];
|
||||
this.station_location = source["station_location"];
|
||||
this.key_password = source["key_password"];
|
||||
this.upload_flag = source["upload_flag"];
|
||||
this.upload_flags = source["upload_flags"];
|
||||
this.write_log = source["write_log"];
|
||||
this.auto_upload = source["auto_upload"];
|
||||
this.upload_mode = source["upload_mode"];
|
||||
|
||||
@@ -69,7 +69,7 @@ type ServiceConfig struct {
|
||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||
UploadFlag string `json:"upload_flag"` // LoTW: sent status that means "ready to upload" — "N" or "R"
|
||||
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
|
||||
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
||||
AutoUpload bool `json:"auto_upload"`
|
||||
UploadMode UploadMode `json:"upload_mode"`
|
||||
@@ -84,13 +84,19 @@ func (c ServiceConfig) normalised() ServiceConfig {
|
||||
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
||||
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
||||
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
||||
// Upload flag is the LoTW sent-status that marks a QSO ready to upload.
|
||||
// Only "N" (no) and "R" (requested) are valid; default to "R".
|
||||
if uf := strings.ToUpper(strings.TrimSpace(c.UploadFlag)); uf == "N" || uf == "R" {
|
||||
c.UploadFlag = uf
|
||||
} else {
|
||||
c.UploadFlag = "R"
|
||||
// Upload flags are the LoTW sent-statuses that mark a QSO ready to upload.
|
||||
// Only "N" (no) and "R" (requested) are valid; clean + dedupe, no default
|
||||
// here (the caller injects N+R when nothing is configured).
|
||||
var flags []string
|
||||
seen := map[string]bool{}
|
||||
for _, f := range c.UploadFlags {
|
||||
f = strings.ToUpper(strings.TrimSpace(f))
|
||||
if (f == "N" || f == "R") && !seen[f] {
|
||||
seen[f] = true
|
||||
flags = append(flags, f)
|
||||
}
|
||||
}
|
||||
c.UploadFlags = flags
|
||||
switch c.UploadMode {
|
||||
case ModeDelayed, ModeOnClose:
|
||||
// keep
|
||||
|
||||
@@ -21,8 +21,11 @@ const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
||||
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
|
||||
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
|
||||
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
|
||||
// returned — used for incremental "Last download" updates.
|
||||
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
|
||||
// returned — used for incremental "Last download" updates. When ownCall is
|
||||
// non-empty, only confirmations for that station callsign are returned (an
|
||||
// LoTW account holds every call you operate — F4BPO, F4BPO/P, TM2Q — so this
|
||||
// scopes the pull to the active profile's call).
|
||||
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since, ownCall string) (string, error) {
|
||||
user := strings.TrimSpace(cfg.Username)
|
||||
if user == "" || cfg.Password == "" {
|
||||
return "", fmt.Errorf("lotw: website login (username/password) not set")
|
||||
@@ -33,6 +36,9 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
|
||||
q.Set("qso_query", "1")
|
||||
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
|
||||
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
|
||||
if c := strings.TrimSpace(ownCall); c != "" {
|
||||
q.Set("qso_owncall", c) // restrict to this station callsign
|
||||
}
|
||||
if s := strings.TrimSpace(since); s != "" {
|
||||
q.Set("qso_qslsince", s)
|
||||
}
|
||||
|
||||
+65
-38
@@ -33,6 +33,11 @@ func sameBaseCall(a, b string) bool {
|
||||
return baseCall(a) == baseCall(b)
|
||||
}
|
||||
|
||||
// SameBaseCall is the exported form of sameBaseCall, so the host app can apply
|
||||
// the same "same operator?" rule when filtering an on-close upload batch by the
|
||||
// active logbook's callsign.
|
||||
func SameBaseCall(a, b string) bool { return sameBaseCall(a, b) }
|
||||
|
||||
// Deps are the host-app callbacks the Manager needs. Keeping them as
|
||||
// function fields decouples extsvc from the qso/adif/settings packages and
|
||||
// keeps the upload-scheduling logic testable.
|
||||
@@ -62,6 +67,13 @@ type Deps struct {
|
||||
// option would otherwise silently relabel it). "" → no station call known.
|
||||
StationCallOf func(id int64) string
|
||||
|
||||
// CloseUploadIDs returns the QSO ids to upload for a service when the app
|
||||
// closes — scanning the WHOLE logbook, not just this session: LoTW returns
|
||||
// rows whose lotw_sent matches the configured status set; QRZ/Club Log
|
||||
// return anything not yet "Y". This is what makes an imported ADIF (old
|
||||
// QSOs still marked unsent) upload on close. nil → nothing to do.
|
||||
CloseUploadIDs func(svc Service) []int64
|
||||
|
||||
// Logf is an optional diagnostic logger.
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
@@ -72,10 +84,9 @@ type Deps struct {
|
||||
type Manager struct {
|
||||
deps Deps
|
||||
|
||||
mu sync.Mutex
|
||||
cfg ExternalServices
|
||||
rnd *rand.Rand
|
||||
pending map[Service][]int64 // QSO ids queued for ModeOnClose upload
|
||||
mu sync.Mutex
|
||||
cfg ExternalServices
|
||||
rnd *rand.Rand
|
||||
}
|
||||
|
||||
func NewManager(deps Deps) *Manager {
|
||||
@@ -86,8 +97,7 @@ func NewManager(deps Deps) *Manager {
|
||||
deps: deps,
|
||||
// Seeded from the clock; the delay only needs to be unpredictable
|
||||
// enough to spread bursts, not cryptographically random.
|
||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
pending: map[Service][]int64{},
|
||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +113,8 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cfg.QRZ = cfg.QRZ.normalised()
|
||||
cfg.Clublog = cfg.Clublog.normalised()
|
||||
cfg.LoTW = cfg.LoTW.normalised()
|
||||
m.cfg = cfg
|
||||
}
|
||||
|
||||
@@ -145,11 +157,9 @@ func (m *Manager) OnQSOLogged(id int64) {
|
||||
// app-close batch, or schedule an immediate / delayed upload.
|
||||
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
||||
if cfg.UploadMode == ModeOnClose {
|
||||
m.mu.Lock()
|
||||
m.pending[svc] = append(m.pending[svc], id)
|
||||
n := len(m.pending[svc])
|
||||
m.mu.Unlock()
|
||||
m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n)
|
||||
// Nothing to queue: on-close upload sweeps the whole logbook from the
|
||||
// database at shutdown (see FlushOnClose), so this QSO is picked up by
|
||||
// its sent-status then — no in-memory tracking needed.
|
||||
return
|
||||
}
|
||||
m.scheduleUpload(svc, id, cfg)
|
||||
@@ -166,47 +176,64 @@ func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||
go m.upload(svc, id, cfg)
|
||||
}
|
||||
|
||||
// PendingCount returns how many QSOs are queued for on-close upload across
|
||||
// all services. The shutdown sequence uses it to decide whether to show the
|
||||
// upload step.
|
||||
func (m *Manager) PendingCount() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// onCloseServices returns the services configured for on-close auto-upload,
|
||||
// with the minimum credentials to actually run.
|
||||
func (m *Manager) onCloseServices() []Service {
|
||||
cfg := m.Config()
|
||||
var out []Service
|
||||
if q := cfg.QRZ; q.AutoUpload && q.UploadMode == ModeOnClose && q.APIKey != "" {
|
||||
out = append(out, ServiceQRZ)
|
||||
}
|
||||
if c := cfg.Clublog; c.AutoUpload && c.UploadMode == ModeOnClose && c.Email != "" && c.Password != "" {
|
||||
out = append(out, ServiceClublog)
|
||||
}
|
||||
if l := cfg.LoTW; l.AutoUpload && l.UploadMode == ModeOnClose && l.TQSLPath != "" && l.StationLocation != "" {
|
||||
out = append(out, ServiceLoTW)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CloseUploadCount returns how many QSOs across the whole logbook would be
|
||||
// uploaded at app close (sum over every on-close service). The shutdown
|
||||
// sequence uses it to decide whether to show the upload step and its label.
|
||||
func (m *Manager) CloseUploadCount() int {
|
||||
if m.deps.CloseUploadIDs == nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, ids := range m.pending {
|
||||
n += len(ids)
|
||||
for _, svc := range m.onCloseServices() {
|
||||
n += len(m.deps.CloseUploadIDs(svc))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// FlushOnClose uploads every queued QSO. Called from the shutdown sequence.
|
||||
// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a
|
||||
// single TQSL batch. Returns the number of QSOs uploaded successfully.
|
||||
// FlushOnClose uploads every QSO due for an on-close push, scanning the whole
|
||||
// logbook (not just this session). Called from the shutdown sequence. QRZ/Club
|
||||
// Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a single TQSL
|
||||
// batch. Returns the number of QSOs uploaded successfully.
|
||||
func (m *Manager) FlushOnClose() int {
|
||||
m.mu.Lock()
|
||||
pending := m.pending
|
||||
m.pending = map[Service][]int64{}
|
||||
cfg := m.cfg
|
||||
m.mu.Unlock()
|
||||
|
||||
if m.deps.CloseUploadIDs == nil {
|
||||
return 0
|
||||
}
|
||||
cfg := m.Config()
|
||||
uploaded := 0
|
||||
for svc, ids := range pending {
|
||||
for _, svc := range m.onCloseServices() {
|
||||
ids := m.deps.CloseUploadIDs(svc)
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
switch svc {
|
||||
case ServiceLoTW:
|
||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||
default:
|
||||
var sc ServiceConfig
|
||||
switch svc {
|
||||
case ServiceQRZ:
|
||||
sc = cfg.QRZ
|
||||
case ServiceClublog:
|
||||
sc = cfg.Clublog
|
||||
}
|
||||
case ServiceQRZ:
|
||||
for _, id := range ids {
|
||||
if m.upload(svc, id, sc) {
|
||||
if m.upload(svc, id, cfg.QRZ) {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
case ServiceClublog:
|
||||
for _, id := range ids {
|
||||
if m.upload(svc, id, cfg.Clublog) {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,6 +519,56 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UploadCandidate is a QSO eligible for an on-close upload: its id plus its
|
||||
// STATION_CALLSIGN, so the caller can keep only the rows that belong to the
|
||||
// active logbook's callsign (a mixed-call DB — F4BPO, F4BPO/P, TM2Q — must not
|
||||
// all be signed under one cert).
|
||||
type UploadCandidate struct {
|
||||
ID int64
|
||||
StationCallsign string
|
||||
}
|
||||
|
||||
// ListUploadCandidates returns QSOs eligible for an on-close upload to a
|
||||
// service, scanning the whole logbook. For LoTW (column "lotw_sent"), statuses
|
||||
// is the set of sent-status values to treat as "to send" (e.g. N, R); rows
|
||||
// already "Y" are excluded. For QRZ/Club Log, statuses is ignored and anything
|
||||
// whose upload status isn't yet "Y" qualifies.
|
||||
func (r *Repo) ListUploadCandidates(ctx context.Context, column string, statuses []string) ([]UploadCandidate, error) {
|
||||
if !uploadStatusCols[column] {
|
||||
return nil, fmt.Errorf("invalid upload column %q", column)
|
||||
}
|
||||
var where string
|
||||
var args []any
|
||||
if column == "lotw_sent" {
|
||||
if len(statuses) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ph := make([]string, len(statuses))
|
||||
for i, s := range statuses {
|
||||
ph[i] = "?"
|
||||
args = append(args, strings.ToUpper(strings.TrimSpace(s)))
|
||||
}
|
||||
where = "UPPER(COALESCE(lotw_sent,'')) IN (" + strings.Join(ph, ",") + ")"
|
||||
} else {
|
||||
where = "UPPER(COALESCE(" + column + ",'')) <> 'Y'"
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, COALESCE(station_callsign,'') FROM qso WHERE `+where+` ORDER BY qso_date`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list upload candidates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []UploadCandidate
|
||||
for rows.Next() {
|
||||
var c UploadCandidate
|
||||
if err := rows.Scan(&c.ID, &c.StationCallsign); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkQRZUploaded stamps QRZCOM_QSO_UPLOAD_STATUS=Y and the upload date on
|
||||
// a QSO after a successful push to the QRZ.com logbook. date is an ADIF
|
||||
// YYYYMMDD string. Only the two QRZ columns are touched — no full-row
|
||||
@@ -651,6 +701,25 @@ func (r *Repo) DeleteAll(ctx context.Context) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// DeleteMany removes several QSOs in one statement. Returns the number deleted.
|
||||
func (r *Repo) DeleteMany(ctx context.Context, ids []int64) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
ph[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id IN (`+strings.Join(ph, ",")+`)`, args...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete qsos: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Delete removes a QSO by id.
|
||||
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id)
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.11.2"
|
||||
appVersion = "0.11.3"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user