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
|
## Building / developing
|
||||||
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
|
- **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
|
## 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
|
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
|
||||||
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
|
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
|
||||||
commonly-worked DXCC entities.
|
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"
|
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)
|
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"
|
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"
|
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
|
||||||
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
|
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
|
||||||
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
|
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
|
// Windows reinstall. It lives OUTSIDE the DB since we must know the path
|
||||||
// before opening it.
|
// before opening it.
|
||||||
if custom := readDBPointer(dataDir); custom != "" {
|
if custom := readDBPointer(dataDir); custom != "" {
|
||||||
a.dbPath = custom
|
// Portability guard: a pointer that is merely ANOTHER folder's default DB
|
||||||
usingDefault = false
|
// 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 {
|
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
||||||
a.startupErr = "cannot create db folder: " + err.Error()
|
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
|
// from settings and host callbacks to build ADIF, stamp the upload
|
||||||
// status and surface errors to the UI.
|
// status and surface errors to the UI.
|
||||||
a.extsvc = extsvc.NewManager(extsvc.Deps{
|
a.extsvc = extsvc.NewManager(extsvc.Deps{
|
||||||
BuildADIF: a.buildUploadADIF,
|
BuildADIF: a.buildUploadADIF,
|
||||||
MarkUploaded: a.markExtUploaded,
|
MarkUploaded: a.markExtUploaded,
|
||||||
NotifyError: a.notifyExtError,
|
NotifyError: a.notifyExtError,
|
||||||
ShouldUpload: a.extShouldUpload,
|
ShouldUpload: a.extShouldUpload,
|
||||||
StationCallOf: a.stationCallOf,
|
StationCallOf: a.stationCallOf,
|
||||||
Logf: applog.Printf,
|
CloseUploadIDs: a.closeUploadIDs,
|
||||||
|
Logf: applog.Printf,
|
||||||
})
|
})
|
||||||
a.extsvc.SetConfig(a.loadExternalServices())
|
a.extsvc.SetConfig(a.loadExternalServices())
|
||||||
|
|
||||||
@@ -841,7 +857,7 @@ func (a *App) plannedShutdownSteps() []shutdownStep {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
if n := a.extsvc.PendingCount(); n > 0 {
|
if n := a.extsvc.CloseUploadCount(); n > 0 {
|
||||||
out = append(out, shutdownStep{
|
out = append(out, shutdownStep{
|
||||||
ID: "extsvc-upload",
|
ID: "extsvc-upload",
|
||||||
Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n),
|
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)
|
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
|
// 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"
|
// string leaves that field unchanged (so you can set only "received = Y + date"
|
||||||
// without touching the sent side).
|
// without touching the sent side).
|
||||||
@@ -4799,6 +4824,31 @@ func (a *App) getManyScoped(prefix string, keys ...string) (map[string]string, e
|
|||||||
return out, nil
|
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 {
|
func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||||
var out extsvc.ExternalServices
|
var out extsvc.ExternalServices
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
@@ -4815,7 +4865,7 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
||||||
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
|
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
|
||||||
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword,
|
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword,
|
||||||
keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
|
keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
|
||||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||||
keyExtLoTWUsername, keyExtLoTWWebPassword)
|
keyExtLoTWUsername, keyExtLoTWWebPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -4847,12 +4897,15 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
StationLocation: m[keyExtLoTWStationLoc],
|
StationLocation: m[keyExtLoTWStationLoc],
|
||||||
ForceStationCallsign: m[keyExtLoTWForceCall],
|
ForceStationCallsign: m[keyExtLoTWForceCall],
|
||||||
KeyPassword: m[keyExtLoTWKeyPassword],
|
KeyPassword: m[keyExtLoTWKeyPassword],
|
||||||
UploadFlag: m[keyExtLoTWUploadFlag],
|
UploadFlags: parseUploadFlags(m[keyExtLoTWUploadFlags], m[keyExtLoTWUploadFlag]),
|
||||||
WriteLog: m[keyExtLoTWWriteLog] == "1",
|
WriteLog: m[keyExtLoTWWriteLog] == "1",
|
||||||
Username: m[keyExtLoTWUsername],
|
Username: m[keyExtLoTWUsername],
|
||||||
Password: m[keyExtLoTWWebPassword],
|
Password: m[keyExtLoTWWebPassword],
|
||||||
AutoUpload: m[keyExtLoTWAutoUpload] == "1",
|
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
|
// Default the TQSL path to the standard install location when unset, so
|
||||||
// the field is pre-populated if TQSL is present.
|
// 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 {
|
if a.settings == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
mode := string(extsvc.ModeImmediate)
|
// Preserve the chosen upload timing — including "on_close", which the LoTW
|
||||||
if cfg.QRZ.UploadMode == extsvc.ModeDelayed {
|
// batch flush at shutdown depends on. (A previous version collapsed anything
|
||||||
mode = string(extsvc.ModeDelayed)
|
// 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"
|
auto := "0"
|
||||||
if cfg.QRZ.AutoUpload {
|
if cfg.QRZ.AutoUpload {
|
||||||
auto = "1"
|
auto = "1"
|
||||||
}
|
}
|
||||||
clMode := string(extsvc.ModeImmediate)
|
clMode := modeOf(cfg.Clublog.UploadMode)
|
||||||
if cfg.Clublog.UploadMode == extsvc.ModeDelayed {
|
|
||||||
clMode = string(extsvc.ModeDelayed)
|
|
||||||
}
|
|
||||||
clAuto := "0"
|
clAuto := "0"
|
||||||
if cfg.Clublog.AutoUpload {
|
if cfg.Clublog.AutoUpload {
|
||||||
clAuto = "1"
|
clAuto = "1"
|
||||||
}
|
}
|
||||||
ltMode := string(extsvc.ModeImmediate)
|
ltMode := modeOf(cfg.LoTW.UploadMode)
|
||||||
if cfg.LoTW.UploadMode == extsvc.ModeDelayed {
|
|
||||||
ltMode = string(extsvc.ModeDelayed)
|
|
||||||
}
|
|
||||||
ltAuto := "0"
|
ltAuto := "0"
|
||||||
if cfg.LoTW.AutoUpload {
|
if cfg.LoTW.AutoUpload {
|
||||||
ltAuto = "1"
|
ltAuto = "1"
|
||||||
}
|
}
|
||||||
ltFlag := strings.ToUpper(strings.TrimSpace(cfg.LoTW.UploadFlag))
|
ltFlags := strings.Join(parseUploadFlags(strings.Join(cfg.LoTW.UploadFlags, ","), ""), ",")
|
||||||
if ltFlag != "N" && ltFlag != "R" {
|
|
||||||
ltFlag = "R"
|
|
||||||
}
|
|
||||||
ltWriteLog := "0"
|
ltWriteLog := "0"
|
||||||
if cfg.LoTW.WriteLog {
|
if cfg.LoTW.WriteLog {
|
||||||
ltWriteLog = "1"
|
ltWriteLog = "1"
|
||||||
@@ -4923,7 +4977,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
|
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
|
||||||
keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)),
|
keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)),
|
||||||
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
|
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
|
||||||
keyExtLoTWUploadFlag: ltFlag,
|
keyExtLoTWUploadFlags: ltFlags,
|
||||||
keyExtLoTWWriteLog: ltWriteLog,
|
keyExtLoTWWriteLog: ltWriteLog,
|
||||||
keyExtLoTWAutoUpload: ltAuto,
|
keyExtLoTWAutoUpload: ltAuto,
|
||||||
keyExtLoTWUploadMode: ltMode,
|
keyExtLoTWUploadMode: ltMode,
|
||||||
@@ -5189,12 +5243,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
switch svc {
|
switch svc {
|
||||||
case extsvc.ServiceLoTW:
|
case extsvc.ServiceLoTW:
|
||||||
sinceDate := resolveSince(keyExtLoTWLastDownload)
|
sinceDate := resolveSince(keyExtLoTWLastDownload)
|
||||||
if sinceDate != "" {
|
ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW)
|
||||||
emit("Downloading LoTW confirmations received since " + sinceDate + "…")
|
callLabel := ownCall
|
||||||
} else {
|
if callLabel == "" {
|
||||||
emit("Downloading all LoTW confirmations…")
|
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 {
|
if err != nil {
|
||||||
emit("Download failed: " + err.Error())
|
emit("Download failed: " + err.Error())
|
||||||
done(matched, total)
|
done(matched, total)
|
||||||
@@ -5659,6 +5718,84 @@ func (a *App) stationCallOf(id int64) string {
|
|||||||
return strings.ToUpper(strings.TrimSpace(q.StationCallsign))
|
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,
|
// 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
|
// 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
|
// 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
|
return true
|
||||||
case extsvc.ServiceLoTW:
|
case extsvc.ServiceLoTW:
|
||||||
flag := "R"
|
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
||||||
if a.settings != nil {
|
if strings.EqualFold(q.LOTWSent, f) {
|
||||||
if m, e := a.settings.GetMany(a.ctx, keyExtLoTWUploadFlag); e == nil {
|
return true
|
||||||
if v := strings.ToUpper(strings.TrimSpace(m[keyExtLoTWUploadFlag])); v == "N" || v == "R" {
|
|
||||||
flag = v
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return strings.EqualFold(q.LOTWSent, flag)
|
return false
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-28
@@ -7,7 +7,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
||||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
GetStartupStatus, CheckForUpdate,
|
GetStartupStatus, CheckForUpdate,
|
||||||
@@ -701,8 +701,10 @@ export default function App() {
|
|||||||
|
|
||||||
// === Modals ===
|
// === Modals ===
|
||||||
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
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 [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
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)); }
|
} catch (err: any) { setError(String(err?.message ?? err)); }
|
||||||
}
|
}
|
||||||
function onModalDelete(id: number) {
|
function onModalDelete(id: number) {
|
||||||
const q = editingQSO; setEditingQSO(null);
|
setEditingQSO(null);
|
||||||
if (q) setDeletingQSO(q); else askDelete(id);
|
setDeletingIds([id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk grid actions (right-click menu). Recompute country/zones from
|
// 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}`);
|
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
function askDelete(id: number) {
|
function askDelete(id: number) { setDeletingIds([id]); }
|
||||||
const q = qsos.find((x) => x.id === id);
|
// Delete the whole multi-row selection (Edit menu / Delete key).
|
||||||
if (q) setDeletingQSO(q);
|
function askDeleteSelected() {
|
||||||
|
if (selectedIds.length > 0) setDeletingIds(selectedIds);
|
||||||
|
else if (selectedId != null) setDeletingIds([selectedId]);
|
||||||
}
|
}
|
||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
if (!deletingQSO) return;
|
if (deletingIds.length === 0) return;
|
||||||
|
const ids = deletingIds;
|
||||||
try {
|
try {
|
||||||
await DeleteQSO(deletingQSO.id);
|
if (ids.length === 1) await DeleteQSO(ids[0]);
|
||||||
if (selectedId === deletingQSO.id) setSelectedId(null);
|
else await DeleteQSOs(ids as any);
|
||||||
setDeletingQSO(null);
|
setDeletingIds([]);
|
||||||
|
setSelectedId(null);
|
||||||
|
setSelectedIds([]);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(String(err?.message ?? err));
|
setError(String(err?.message ?? err));
|
||||||
setDeletingQSO(null);
|
setDeletingIds([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function confirmDeleteAll() {
|
async function confirmDeleteAll() {
|
||||||
@@ -1878,7 +1885,7 @@ export default function App() {
|
|||||||
]},
|
]},
|
||||||
{ name: 'edit', label: 'Edit', items: [
|
{ name: 'edit', label: 'Edit', items: [
|
||||||
{ type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
|
{ 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: 'separator' },
|
||||||
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' },
|
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' },
|
||||||
]},
|
]},
|
||||||
@@ -1901,7 +1908,7 @@ export default function App() {
|
|||||||
{ name: 'help', label: 'Help', items: [
|
{ name: 'help', label: 'Help', items: [
|
||||||
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
{ 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) {
|
function handleMenu(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -1911,7 +1918,7 @@ export default function App() {
|
|||||||
case 'view.refresh': refresh(); break;
|
case 'view.refresh': refresh(); break;
|
||||||
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
||||||
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); 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 'edit.prefs': setShowSettings(true); break;
|
||||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
@@ -2008,14 +2015,14 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
if (typing) return;
|
if (typing) return;
|
||||||
if (selectedId !== null) {
|
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; }
|
if (e.key === 'Enter') { e.preventDefault(); openEdit(selectedId); return; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedId, refresh]);
|
}, [selectedId, selectedIds, refresh]);
|
||||||
|
|
||||||
// ── Entry-field blocks ─────────────────────────────────────────────────
|
// ── Entry-field blocks ─────────────────────────────────────────────────
|
||||||
// Each field is defined once here, then composed into either the compact
|
// 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)}
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
onExportSelected={exportSelectedADIF}
|
onExportSelected={exportSelectedADIF}
|
||||||
onExportFiltered={exportFilteredADIF}
|
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="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">
|
<div className="flex items-center gap-3">
|
||||||
@@ -3592,16 +3599,21 @@ export default function App() {
|
|||||||
onOpenDesigner={() => setQslDesignerOpen(true)}
|
onOpenDesigner={() => setQslDesignerOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{deletingQSO && (
|
{deletingIds.length > 0 && (() => {
|
||||||
<ConfirmDialog
|
const single = deletingIds.length === 1 ? qsos.find((x) => x.id === deletingIds[0]) : null;
|
||||||
title="Delete QSO?"
|
return (
|
||||||
message={`This will permanently delete the QSO with ${deletingQSO.callsign} on ${fmtDateUTC(deletingQSO.qso_date)} (${deletingQSO.band} ${deletingQSO.mode}). This cannot be undone.`}
|
<ConfirmDialog
|
||||||
confirmLabel="Delete"
|
title={deletingIds.length > 1 ? `Delete ${deletingIds.length} QSOs?` : 'Delete QSO?'}
|
||||||
danger
|
message={single
|
||||||
onConfirm={confirmDelete}
|
? `This will permanently delete the QSO with ${single.callsign} on ${fmtDateUTC(single.qso_date)} (${single.band} ${single.mode}). This cannot be undone.`
|
||||||
onCancel={() => setDeletingQSO(null)}
|
: `This will permanently delete ${deletingIds.length} selected QSO${deletingIds.length > 1 ? 's' : ''}. This cannot be undone.`}
|
||||||
/>
|
confirmLabel="Delete"
|
||||||
)}
|
danger
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setDeletingIds([])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{showDeleteAll && (
|
{showDeleteAll && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Delete ALL QSOs?"
|
title="Delete ALL QSOs?"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
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.
|
// and download confirmations, while the rest of the app stays usable.
|
||||||
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||||
const [service, setService] = useState('lotw');
|
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 [potaSyncing, setPotaSyncing] = useState(false);
|
||||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||||
const [potaErr, setPotaErr] = useState('');
|
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>
|
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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' ? (
|
{service === 'pota' ? (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ type Props = {
|
|||||||
rows: QSOForm[];
|
rows: QSOForm[];
|
||||||
total: number;
|
total: number;
|
||||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||||
onRowSelected?: (id: number | null) => void;
|
onRowSelected?: (ids: number[]) => void;
|
||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (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);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
}
|
}
|
||||||
function onSelectionChanged() {
|
function onSelectionChanged() {
|
||||||
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
|
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||||
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
|
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Column picker (visibility) ──
|
// ── 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;
|
api_key: string; email: string; username: string; password: string; callsign: string;
|
||||||
force_station_callsign: string;
|
force_station_callsign: string;
|
||||||
tqsl_path: string; station_location: string; key_password: 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;
|
auto_upload: boolean; upload_mode: string;
|
||||||
};
|
};
|
||||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
|
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
|
||||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||||
api_key: '', email: '', username: '', password: '', callsign: '',
|
api_key: '', email: '', username: '', password: '', callsign: '',
|
||||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
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',
|
auto_upload: false, upload_mode: 'immediate',
|
||||||
});
|
});
|
||||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
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"
|
placeholder="only if your certificate key has a password"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">Upload flag</Label>
|
<Label className="text-sm">Consider as unsent</Label>
|
||||||
<div>
|
<div>
|
||||||
<Select value={lotw.upload_flag || 'R'} onValueChange={(v) => setLotw({ upload_flag: v })}>
|
<div className="flex items-center gap-4">
|
||||||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
{(['N', 'R'] as const).map((f) => {
|
||||||
<SelectContent>
|
const flags = lotw.upload_flags ?? [];
|
||||||
<SelectItem value="N">Upload when LoTW sent = No</SelectItem>
|
const checked = flags.includes(f);
|
||||||
<SelectItem value="R">Upload when LoTW sent = Requested</SelectItem>
|
return (
|
||||||
</SelectContent>
|
<label key={f} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
</Select>
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// 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).
|
// 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.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
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 DeleteQSO(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function DeleteQSOs(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DisconnectAllClusters():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 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 UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
export function WinkeyerBackspace():Promise<void>;
|
export function WinkeyerBackspace():Promise<void>;
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ export function DeleteQSO(arg1) {
|
|||||||
return window['go']['main']['App']['DeleteQSO'](arg1);
|
return window['go']['main']['App']['DeleteQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DeleteQSOs(arg1) {
|
||||||
|
return window['go']['main']['App']['DeleteQSOs'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DeleteUDPIntegration(arg1) {
|
export function DeleteUDPIntegration(arg1) {
|
||||||
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
|
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
|
||||||
}
|
}
|
||||||
@@ -970,6 +974,10 @@ export function UpdateQSOsFromQRZ(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function UploadQSOsManual(arg1, arg2) {
|
||||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -673,7 +673,7 @@ export namespace extsvc {
|
|||||||
tqsl_path: string;
|
tqsl_path: string;
|
||||||
station_location: string;
|
station_location: string;
|
||||||
key_password: string;
|
key_password: string;
|
||||||
upload_flag: string;
|
upload_flags: string[];
|
||||||
write_log: boolean;
|
write_log: boolean;
|
||||||
auto_upload: boolean;
|
auto_upload: boolean;
|
||||||
upload_mode: string;
|
upload_mode: string;
|
||||||
@@ -693,7 +693,7 @@ export namespace extsvc {
|
|||||||
this.tqsl_path = source["tqsl_path"];
|
this.tqsl_path = source["tqsl_path"];
|
||||||
this.station_location = source["station_location"];
|
this.station_location = source["station_location"];
|
||||||
this.key_password = source["key_password"];
|
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.write_log = source["write_log"];
|
||||||
this.auto_upload = source["auto_upload"];
|
this.auto_upload = source["auto_upload"];
|
||||||
this.upload_mode = source["upload_mode"];
|
this.upload_mode = source["upload_mode"];
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ type ServiceConfig struct {
|
|||||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
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
|
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
||||||
AutoUpload bool `json:"auto_upload"`
|
AutoUpload bool `json:"auto_upload"`
|
||||||
UploadMode UploadMode `json:"upload_mode"`
|
UploadMode UploadMode `json:"upload_mode"`
|
||||||
@@ -84,13 +84,19 @@ func (c ServiceConfig) normalised() ServiceConfig {
|
|||||||
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
||||||
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
||||||
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
||||||
// Upload flag is the LoTW sent-status that marks a QSO ready to upload.
|
// Upload flags are the LoTW sent-statuses that mark a QSO ready to upload.
|
||||||
// Only "N" (no) and "R" (requested) are valid; default to "R".
|
// Only "N" (no) and "R" (requested) are valid; clean + dedupe, no default
|
||||||
if uf := strings.ToUpper(strings.TrimSpace(c.UploadFlag)); uf == "N" || uf == "R" {
|
// here (the caller injects N+R when nothing is configured).
|
||||||
c.UploadFlag = uf
|
var flags []string
|
||||||
} else {
|
seen := map[string]bool{}
|
||||||
c.UploadFlag = "R"
|
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 {
|
switch c.UploadMode {
|
||||||
case ModeDelayed, ModeOnClose:
|
case ModeDelayed, ModeOnClose:
|
||||||
// keep
|
// keep
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
|||||||
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
|
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
|
||||||
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
|
// 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
|
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
|
||||||
// returned — used for incremental "Last download" updates.
|
// returned — used for incremental "Last download" updates. When ownCall is
|
||||||
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
|
// 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)
|
user := strings.TrimSpace(cfg.Username)
|
||||||
if user == "" || cfg.Password == "" {
|
if user == "" || cfg.Password == "" {
|
||||||
return "", fmt.Errorf("lotw: website login (username/password) not set")
|
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_query", "1")
|
||||||
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
|
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
|
||||||
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
|
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 != "" {
|
if s := strings.TrimSpace(since); s != "" {
|
||||||
q.Set("qso_qslsince", s)
|
q.Set("qso_qslsince", s)
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-38
@@ -33,6 +33,11 @@ func sameBaseCall(a, b string) bool {
|
|||||||
return baseCall(a) == baseCall(b)
|
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
|
// Deps are the host-app callbacks the Manager needs. Keeping them as
|
||||||
// function fields decouples extsvc from the qso/adif/settings packages and
|
// function fields decouples extsvc from the qso/adif/settings packages and
|
||||||
// keeps the upload-scheduling logic testable.
|
// keeps the upload-scheduling logic testable.
|
||||||
@@ -62,6 +67,13 @@ type Deps struct {
|
|||||||
// option would otherwise silently relabel it). "" → no station call known.
|
// option would otherwise silently relabel it). "" → no station call known.
|
||||||
StationCallOf func(id int64) string
|
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 is an optional diagnostic logger.
|
||||||
Logf func(format string, args ...any)
|
Logf func(format string, args ...any)
|
||||||
}
|
}
|
||||||
@@ -72,10 +84,9 @@ type Deps struct {
|
|||||||
type Manager struct {
|
type Manager struct {
|
||||||
deps Deps
|
deps Deps
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cfg ExternalServices
|
cfg ExternalServices
|
||||||
rnd *rand.Rand
|
rnd *rand.Rand
|
||||||
pending map[Service][]int64 // QSO ids queued for ModeOnClose upload
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(deps Deps) *Manager {
|
func NewManager(deps Deps) *Manager {
|
||||||
@@ -86,8 +97,7 @@ func NewManager(deps Deps) *Manager {
|
|||||||
deps: deps,
|
deps: deps,
|
||||||
// Seeded from the clock; the delay only needs to be unpredictable
|
// Seeded from the clock; the delay only needs to be unpredictable
|
||||||
// enough to spread bursts, not cryptographically random.
|
// enough to spread bursts, not cryptographically random.
|
||||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||||
pending: map[Service][]int64{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +113,8 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
cfg.QRZ = cfg.QRZ.normalised()
|
cfg.QRZ = cfg.QRZ.normalised()
|
||||||
|
cfg.Clublog = cfg.Clublog.normalised()
|
||||||
|
cfg.LoTW = cfg.LoTW.normalised()
|
||||||
m.cfg = cfg
|
m.cfg = cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,11 +157,9 @@ func (m *Manager) OnQSOLogged(id int64) {
|
|||||||
// app-close batch, or schedule an immediate / delayed upload.
|
// app-close batch, or schedule an immediate / delayed upload.
|
||||||
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
||||||
if cfg.UploadMode == ModeOnClose {
|
if cfg.UploadMode == ModeOnClose {
|
||||||
m.mu.Lock()
|
// Nothing to queue: on-close upload sweeps the whole logbook from the
|
||||||
m.pending[svc] = append(m.pending[svc], id)
|
// database at shutdown (see FlushOnClose), so this QSO is picked up by
|
||||||
n := len(m.pending[svc])
|
// its sent-status then — no in-memory tracking needed.
|
||||||
m.mu.Unlock()
|
|
||||||
m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.scheduleUpload(svc, id, cfg)
|
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)
|
go m.upload(svc, id, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PendingCount returns how many QSOs are queued for on-close upload across
|
// onCloseServices returns the services configured for on-close auto-upload,
|
||||||
// all services. The shutdown sequence uses it to decide whether to show the
|
// with the minimum credentials to actually run.
|
||||||
// upload step.
|
func (m *Manager) onCloseServices() []Service {
|
||||||
func (m *Manager) PendingCount() int {
|
cfg := m.Config()
|
||||||
m.mu.Lock()
|
var out []Service
|
||||||
defer m.mu.Unlock()
|
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
|
n := 0
|
||||||
for _, ids := range m.pending {
|
for _, svc := range m.onCloseServices() {
|
||||||
n += len(ids)
|
n += len(m.deps.CloseUploadIDs(svc))
|
||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlushOnClose uploads every queued QSO. Called from the shutdown sequence.
|
// FlushOnClose uploads every QSO due for an on-close push, scanning the whole
|
||||||
// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a
|
// logbook (not just this session). Called from the shutdown sequence. QRZ/Club
|
||||||
// single TQSL batch. Returns the number of QSOs uploaded successfully.
|
// 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 {
|
func (m *Manager) FlushOnClose() int {
|
||||||
m.mu.Lock()
|
if m.deps.CloseUploadIDs == nil {
|
||||||
pending := m.pending
|
return 0
|
||||||
m.pending = map[Service][]int64{}
|
}
|
||||||
cfg := m.cfg
|
cfg := m.Config()
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
uploaded := 0
|
uploaded := 0
|
||||||
for svc, ids := range pending {
|
for _, svc := range m.onCloseServices() {
|
||||||
|
ids := m.deps.CloseUploadIDs(svc)
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch svc {
|
switch svc {
|
||||||
case ServiceLoTW:
|
case ServiceLoTW:
|
||||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||||
default:
|
case ServiceQRZ:
|
||||||
var sc ServiceConfig
|
|
||||||
switch svc {
|
|
||||||
case ServiceQRZ:
|
|
||||||
sc = cfg.QRZ
|
|
||||||
case ServiceClublog:
|
|
||||||
sc = cfg.Clublog
|
|
||||||
}
|
|
||||||
for _, id := range ids {
|
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++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -519,6 +519,56 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
|
|||||||
return out, rows.Err()
|
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
|
// 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
|
// 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
|
// 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
|
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.
|
// Delete removes a QSO by id.
|
||||||
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||||
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id)
|
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id)
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// 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
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user