feat: upload qrz.com clublog and lotw manually
This commit is contained in:
@@ -99,6 +99,14 @@ const (
|
|||||||
keyExtClublogAPIKey = "extsvc.clublog.api_key"
|
keyExtClublogAPIKey = "extsvc.clublog.api_key"
|
||||||
keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
|
keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
|
||||||
keyExtClublogUploadMode = "extsvc.clublog.upload_mode"
|
keyExtClublogUploadMode = "extsvc.clublog.upload_mode"
|
||||||
|
|
||||||
|
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
||||||
|
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
|
||||||
|
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
|
||||||
|
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag"
|
||||||
|
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
|
||||||
|
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
|
||||||
|
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
|
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
|
||||||
@@ -442,6 +450,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
BuildADIF: a.buildUploadADIF,
|
BuildADIF: a.buildUploadADIF,
|
||||||
MarkUploaded: a.markExtUploaded,
|
MarkUploaded: a.markExtUploaded,
|
||||||
NotifyError: a.notifyExtError,
|
NotifyError: a.notifyExtError,
|
||||||
|
ShouldUpload: a.extShouldUpload,
|
||||||
Logf: applog.Printf,
|
Logf: applog.Printf,
|
||||||
})
|
})
|
||||||
a.extsvc.SetConfig(a.loadExternalServices())
|
a.extsvc.SetConfig(a.loadExternalServices())
|
||||||
@@ -511,6 +520,15 @@ func (a *App) plannedShutdownSteps() []shutdownStep {
|
|||||||
out = append(out, shutdownStep{ID: "backup", Label: "Backing up database", Status: "pending"})
|
out = append(out, shutdownStep{ID: "backup", Label: "Backing up database", Status: "pending"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if a.extsvc != nil {
|
||||||
|
if n := a.extsvc.PendingCount(); n > 0 {
|
||||||
|
out = append(out, shutdownStep{
|
||||||
|
ID: "extsvc-upload",
|
||||||
|
Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n),
|
||||||
|
Status: "pending",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,6 +551,9 @@ func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
|
|||||||
switch steps[i].ID {
|
switch steps[i].ID {
|
||||||
case "backup":
|
case "backup":
|
||||||
err = a.runBackupForShutdown()
|
err = a.runBackupForShutdown()
|
||||||
|
case "extsvc-upload":
|
||||||
|
n := a.extsvc.FlushOnClose()
|
||||||
|
steps[i].Detail = fmt.Sprintf("%d uploaded", n)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
steps[i].Status = "error"
|
steps[i].Status = "error"
|
||||||
@@ -718,6 +739,10 @@ func (a *App) ComputeStationInfo(callsign, grid string) StationInfoComputed {
|
|||||||
out.Lat = lat
|
out.Lat = lat
|
||||||
out.Lon = lon
|
out.Lon = lon
|
||||||
}
|
}
|
||||||
|
// 3 decimals is ~110 m — plenty for a station/grid coordinate, and keeps
|
||||||
|
// the UI fields tidy.
|
||||||
|
out.Lat = math.Round(out.Lat*1000) / 1000
|
||||||
|
out.Lon = math.Round(out.Lon*1000) / 1000
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1299,7 +1324,10 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
m, err := a.settings.GetMany(a.ctx,
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode,
|
keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode,
|
||||||
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
||||||
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode)
|
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
|
||||||
|
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWKeyPassword,
|
||||||
|
keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
|
||||||
|
keyExtLoTWAutoUpload, keyExtLoTWUploadMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -1324,6 +1352,20 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
out.Clublog.Callsign = p.Callsign
|
out.Clublog.Callsign = p.Callsign
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out.LoTW = extsvc.ServiceConfig{
|
||||||
|
TQSLPath: m[keyExtLoTWTQSLPath],
|
||||||
|
StationLocation: m[keyExtLoTWStationLoc],
|
||||||
|
KeyPassword: m[keyExtLoTWKeyPassword],
|
||||||
|
UploadFlag: m[keyExtLoTWUploadFlag],
|
||||||
|
WriteLog: m[keyExtLoTWWriteLog] == "1",
|
||||||
|
AutoUpload: m[keyExtLoTWAutoUpload] == "1",
|
||||||
|
UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]),
|
||||||
|
}
|
||||||
|
// Default the TQSL path to the standard install location when unset, so
|
||||||
|
// the field is pre-populated if TQSL is present.
|
||||||
|
if out.LoTW.TQSLPath == "" {
|
||||||
|
out.LoTW.TQSLPath = extsvc.DefaultTQSLPath()
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1354,6 +1396,22 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
if cfg.Clublog.AutoUpload {
|
if cfg.Clublog.AutoUpload {
|
||||||
clAuto = "1"
|
clAuto = "1"
|
||||||
}
|
}
|
||||||
|
ltMode := string(extsvc.ModeImmediate)
|
||||||
|
if cfg.LoTW.UploadMode == extsvc.ModeDelayed {
|
||||||
|
ltMode = string(extsvc.ModeDelayed)
|
||||||
|
}
|
||||||
|
ltAuto := "0"
|
||||||
|
if cfg.LoTW.AutoUpload {
|
||||||
|
ltAuto = "1"
|
||||||
|
}
|
||||||
|
ltFlag := strings.ToUpper(strings.TrimSpace(cfg.LoTW.UploadFlag))
|
||||||
|
if ltFlag != "N" && ltFlag != "R" {
|
||||||
|
ltFlag = "R"
|
||||||
|
}
|
||||||
|
ltWriteLog := "0"
|
||||||
|
if cfg.LoTW.WriteLog {
|
||||||
|
ltWriteLog = "1"
|
||||||
|
}
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
||||||
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
|
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
|
||||||
@@ -1366,6 +1424,14 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
keyExtClublogAPIKey: strings.TrimSpace(cfg.Clublog.APIKey),
|
keyExtClublogAPIKey: strings.TrimSpace(cfg.Clublog.APIKey),
|
||||||
keyExtClublogAutoUpload: clAuto,
|
keyExtClublogAutoUpload: clAuto,
|
||||||
keyExtClublogUploadMode: clMode,
|
keyExtClublogUploadMode: clMode,
|
||||||
|
|
||||||
|
keyExtLoTWTQSLPath: strings.TrimSpace(cfg.LoTW.TQSLPath),
|
||||||
|
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
|
||||||
|
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
|
||||||
|
keyExtLoTWUploadFlag: ltFlag,
|
||||||
|
keyExtLoTWWriteLog: ltWriteLog,
|
||||||
|
keyExtLoTWAutoUpload: ltAuto,
|
||||||
|
keyExtLoTWUploadMode: ltMode,
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1389,6 +1455,135 @@ func (a *App) TestClublogUpload() (string, error) {
|
|||||||
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
|
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
||||||
|
|
||||||
|
// uploadColumnFor maps a service id to its QSO sent-status column.
|
||||||
|
func uploadColumnFor(service string) string {
|
||||||
|
switch extsvc.Service(service) {
|
||||||
|
case extsvc.ServiceQRZ:
|
||||||
|
return "qrzcom_qso_upload_status"
|
||||||
|
case extsvc.ServiceClublog:
|
||||||
|
return "clublog_qso_upload_status"
|
||||||
|
case extsvc.ServiceLoTW:
|
||||||
|
return "lotw_sent"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindQSOsForUpload returns QSOs whose sent status for the given service
|
||||||
|
// matches sentStatus ("" = blank). Powers the QSL Manager's Select required.
|
||||||
|
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, error) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return nil, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
col := uploadColumnFor(service)
|
||||||
|
if col == "" {
|
||||||
|
return nil, fmt.Errorf("unknown service %q", service)
|
||||||
|
}
|
||||||
|
return a.qso.ListForUpload(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadQSOsManual uploads the given QSO ids to a service on demand
|
||||||
|
// (regardless of their current sent status — the user picked them). Runs in
|
||||||
|
// the background, emitting "qslmgr:log" lines and a final "qslmgr:done".
|
||||||
|
func (a *App) UploadQSOsManual(service string, ids []int64) error {
|
||||||
|
if a.qso == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
svc := extsvc.Service(service)
|
||||||
|
if uploadColumnFor(service) == "" {
|
||||||
|
return fmt.Errorf("unknown service %q", service)
|
||||||
|
}
|
||||||
|
cfg := a.loadExternalServices()
|
||||||
|
go a.runManualUpload(svc, ids, cfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.ExternalServices) {
|
||||||
|
emit := func(line string) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
uploaded := 0
|
||||||
|
|
||||||
|
if svc == extsvc.ServiceLoTW {
|
||||||
|
emit(fmt.Sprintf("Signing %d QSO(s) with TQSL…", len(ids)))
|
||||||
|
var recs []string
|
||||||
|
for _, id := range ids {
|
||||||
|
if rec, ok := a.buildUploadADIF(id, ""); ok {
|
||||||
|
recs = append(recs, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := extsvc.UploadLoTW(ctx, cfg.LoTW, "", strings.Join(recs, "\n"))
|
||||||
|
if err != nil || !res.OK {
|
||||||
|
msg := res.Message
|
||||||
|
if err != nil {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
emit("LoTW upload failed: " + msg)
|
||||||
|
} else {
|
||||||
|
for _, id := range ids {
|
||||||
|
a.markExtUploaded(svc, id, "")
|
||||||
|
uploaded++
|
||||||
|
}
|
||||||
|
emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, id := range ids {
|
||||||
|
q, gerr := a.qso.GetByID(ctx, id)
|
||||||
|
call := ""
|
||||||
|
if gerr == nil {
|
||||||
|
call = q.Callsign
|
||||||
|
}
|
||||||
|
force := ""
|
||||||
|
if svc == extsvc.ServiceQRZ {
|
||||||
|
force = cfg.QRZ.ForceStationCallsign
|
||||||
|
}
|
||||||
|
rec, ok := a.buildUploadADIF(id, force)
|
||||||
|
if !ok {
|
||||||
|
emit(call + " — skipped (no record)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var res extsvc.UploadResult
|
||||||
|
var err error
|
||||||
|
switch svc {
|
||||||
|
case extsvc.ServiceQRZ:
|
||||||
|
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
||||||
|
case extsvc.ServiceClublog:
|
||||||
|
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
|
||||||
|
}
|
||||||
|
if err == nil && res.OK {
|
||||||
|
a.markExtUploaded(svc, id, "")
|
||||||
|
uploaded++
|
||||||
|
emit(call + " — OK")
|
||||||
|
} else {
|
||||||
|
msg := res.Message
|
||||||
|
if err != nil {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
emit(call + " — FAILED: " + msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTQSLStationLocations returns the Station Locations defined in TQSL,
|
||||||
|
// for the LoTW settings dropdown.
|
||||||
|
func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) {
|
||||||
|
return extsvc.ListStationLocations(extsvc.DefaultStationDataPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoTWUpload validates the LoTW config (TQSL present + station location
|
||||||
|
// exists).
|
||||||
|
func (a *App) TestLoTWUpload() (string, error) {
|
||||||
|
return extsvc.TestLoTW(a.loadExternalServices().LoTW, extsvc.DefaultStationDataPath())
|
||||||
|
}
|
||||||
|
|
||||||
// buildUploadADIF builds a single-record ADIF for QSO id, overriding the
|
// buildUploadADIF builds a single-record ADIF for QSO id, overriding the
|
||||||
// station callsign when forceCall is set (QRZ rejects QSOs whose station
|
// station callsign when forceCall is set (QRZ rejects QSOs whose station
|
||||||
// call differs from the logbook's registered call). ok=false → skip.
|
// call differs from the logbook's registered call). ok=false → skip.
|
||||||
@@ -1406,6 +1601,37 @@ func (a *App) buildUploadADIF(id int64, forceCall string) (string, bool) {
|
|||||||
return adif.SingleRecordADIF(q), true
|
return adif.SingleRecordADIF(q), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// ("N" or "R") — the Log4OM rule that must match the Confirmations default.
|
||||||
|
func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
||||||
|
if a.qso == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
q, err := a.qso.GetByID(a.ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch svc {
|
||||||
|
case extsvc.ServiceQRZ:
|
||||||
|
return !strings.EqualFold(q.QRZComUploadStatus, "Y")
|
||||||
|
case extsvc.ServiceClublog:
|
||||||
|
return !strings.EqualFold(q.ClublogUploadStatus, "Y")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.EqualFold(q.LOTWSent, flag)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// markExtUploaded stamps the per-service upload status on the QSO row and
|
// markExtUploaded stamps the per-service upload status on the QSO row and
|
||||||
// tells the frontend to refresh that row's confirmation columns.
|
// tells the frontend to refresh that row's confirmation columns.
|
||||||
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
||||||
@@ -1423,6 +1649,12 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
|||||||
applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err)
|
applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case extsvc.ServiceLoTW:
|
||||||
|
if a.qso != nil {
|
||||||
|
if err := a.qso.MarkLoTWUploaded(a.ctx, id, date); err != nil {
|
||||||
|
applog.Printf("extsvc: mark lotw uploaded %d: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
|
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { adif as adifModels, lookup as lookupModels, cat as catModels } fro
|
|||||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
|
|
||||||
import { Menubar, type Menu } from '@/components/Menubar';
|
import { Menubar, type Menu } from '@/components/Menubar';
|
||||||
|
import { QSLManagerModal } from '@/components/QSLManagerModal';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
@@ -448,6 +449,7 @@ export default function App() {
|
|||||||
// close so the next plain "Preferences" launch reverts to default.
|
// close so the next plain "Preferences" launch reverts to default.
|
||||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||||
|
const [showQSLManager, setShowQSLManager] = useState(false);
|
||||||
const [deletingAll, setDeletingAll] = useState(false);
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||||
|
|
||||||
@@ -505,6 +507,17 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [filterCallsign, filterBand, filterMode, qsoLimit]);
|
}, [filterCallsign, filterBand, filterMode, qsoLimit]);
|
||||||
|
|
||||||
|
// Refresh the Recent QSOs grid after external-service uploads stamp the
|
||||||
|
// sent status (auto-upload via extsvc:uploaded, or manual QSL Manager via
|
||||||
|
// qslmgr:done). Debounced so a batch of per-QSO events triggers one reload.
|
||||||
|
useEffect(() => {
|
||||||
|
let t: number | undefined;
|
||||||
|
const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
|
||||||
|
const offUploaded = EventsOn('extsvc:uploaded', ping);
|
||||||
|
const offDone = EventsOn('qslmgr:done', ping);
|
||||||
|
return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); };
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
const loadStation = useCallback(async () => {
|
const loadStation = useCallback(async () => {
|
||||||
try { setStation(await GetStationSettings()); } catch {}
|
try { setStation(await GetStationSettings()); } catch {}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -965,6 +978,8 @@ export default function App() {
|
|||||||
{ type: 'item', label: 'Clear filters', action: 'view.clearfilters' },
|
{ type: 'item', label: 'Clear filters', action: 'view.clearfilters' },
|
||||||
]},
|
]},
|
||||||
{ name: 'tools', label: 'Tools', items: [
|
{ name: 'tools', label: 'Tools', items: [
|
||||||
|
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
|
||||||
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' },
|
{ type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' },
|
||||||
{ type: 'item', label: 'DX Cluster', action: 'tools.cluster', disabled: true },
|
{ type: 'item', label: 'DX Cluster', action: 'tools.cluster', disabled: true },
|
||||||
{ type: 'item', label: 'CAT control', action: 'tools.cat', disabled: true },
|
{ type: 'item', label: 'CAT control', action: 'tools.cat', disabled: true },
|
||||||
@@ -989,6 +1004,7 @@ export default function App() {
|
|||||||
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': if (selectedId !== null) askDelete(selectedId); break;
|
||||||
case 'edit.prefs': setShowSettings(true); break;
|
case 'edit.prefs': setShowSettings(true); break;
|
||||||
|
case 'tools.qslmanager': setShowQSLManager(true); break;
|
||||||
case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break;
|
case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break;
|
||||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||||
}
|
}
|
||||||
@@ -1979,6 +1995,8 @@ export default function App() {
|
|||||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); }}
|
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<QSLManagerModal open={showQSLManager} onClose={() => setShowQSLManager(false)} />
|
||||||
|
|
||||||
{deletingQSO && (
|
{deletingQSO && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Delete QSO?"
|
title="Delete QSO?"
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { UploadCloud, Search, Loader2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { FindQSOsForUpload, UploadQSOsManual } from '../../wailsjs/go/main/App';
|
||||||
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
|
||||||
|
type UploadRow = {
|
||||||
|
id: number; qso_date: string; callsign: string;
|
||||||
|
band: string; mode: string; country: string; status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERVICES = [
|
||||||
|
{ v: 'qrz', label: 'QRZ.com' },
|
||||||
|
{ v: 'clublog', label: 'Club Log' },
|
||||||
|
{ v: 'lotw', label: 'LoTW' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sent-status filter values. Empty string = blank/none.
|
||||||
|
const SENT_STATUSES = [
|
||||||
|
{ v: 'R', label: 'Requested' },
|
||||||
|
{ v: 'N', label: 'No' },
|
||||||
|
{ v: 'Q', label: 'Queued' },
|
||||||
|
{ v: 'Y', label: 'Yes (already sent)' },
|
||||||
|
{ v: 'I', label: 'Invalid' },
|
||||||
|
{ v: '_', label: '— blank —' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtDate(iso: string): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const [service, setService] = useState('lotw');
|
||||||
|
const [sent, setSent] = useState('R');
|
||||||
|
const [rows, setRows] = useState<UploadRow[]>([]);
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [logOpen, setLogOpen] = useState(false);
|
||||||
|
const [logLines, setLogLines] = useState<string[]>([]);
|
||||||
|
const [uploadDone, setUploadDone] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
|
||||||
|
const offDone = EventsOn('qslmgr:done', (d: any) => {
|
||||||
|
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} uploaded —`]);
|
||||||
|
setUploadDone(true);
|
||||||
|
});
|
||||||
|
return () => { offLog(); offDone(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedCount = selected.size;
|
||||||
|
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||||
|
|
||||||
|
async function selectRequired() {
|
||||||
|
setSearching(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
||||||
|
const list = (r ?? []) as UploadRow[];
|
||||||
|
setRows(list);
|
||||||
|
setSelected(new Set(list.map((x) => x.id))); // auto-select all found
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e?.message ?? e));
|
||||||
|
setRows([]);
|
||||||
|
setSelected(new Set());
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(id: number) {
|
||||||
|
setSelected((s) => {
|
||||||
|
const n = new Set(s);
|
||||||
|
if (n.has(id)) n.delete(id); else n.add(id);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function toggleAll() {
|
||||||
|
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
setLogLines([]);
|
||||||
|
setUploadDone(false);
|
||||||
|
setLogOpen(true);
|
||||||
|
try {
|
||||||
|
await UploadQSOsManual(service, ids);
|
||||||
|
} catch (e: any) {
|
||||||
|
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
|
||||||
|
setUploadDone(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLog() {
|
||||||
|
setLogOpen(false);
|
||||||
|
// Refresh the list so uploaded QSOs drop out of the current filter.
|
||||||
|
selectRequired();
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-[1000px] w-full max-h-[88vh] grid grid-rows-[auto_auto_1fr_auto] gap-0 p-0">
|
||||||
|
<DialogHeader className="px-4 pt-4">
|
||||||
|
<DialogTitle>QSL Manager</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">Upload logged QSOs to online logbooks.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Search toolbar */}
|
||||||
|
<div className="flex items-end gap-3 px-4 py-3 border-b border-border bg-muted/20">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
||||||
|
<Select value={service} onValueChange={setService}>
|
||||||
|
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
||||||
|
<Select value={sent} onValueChange={setSent}>
|
||||||
|
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching}>
|
||||||
|
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||||
|
Select required
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{rows.length} found · {selectedCount} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results grid */}
|
||||||
|
<div className="overflow-auto px-4 py-2 min-h-[200px]">
|
||||||
|
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||||
|
Pick a service + sent status, then “Select required”.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead className="sticky top-0 bg-card">
|
||||||
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
|
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||||
|
<th className="py-1.5 px-2">Date UTC</th>
|
||||||
|
<th className="py-1.5 px-2">Callsign</th>
|
||||||
|
<th className="py-1.5 px-2">Band</th>
|
||||||
|
<th className="py-1.5 px-2">Mode</th>
|
||||||
|
<th className="py-1.5 px-2">Country</th>
|
||||||
|
<th className="py-1.5 px-2">Sent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
|
||||||
|
onClick={() => toggle(r.id)}
|
||||||
|
>
|
||||||
|
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
|
||||||
|
</td>
|
||||||
|
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
||||||
|
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
||||||
|
<td className="py-1 px-2">{r.band}</td>
|
||||||
|
<td className="py-1 px-2">{r.mode}</td>
|
||||||
|
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
|
||||||
|
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="px-4 py-3 border-t border-border">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
|
||||||
|
<Button size="sm" onClick={upload} disabled={selectedCount === 0}>
|
||||||
|
<UploadCloud className="size-3.5" />
|
||||||
|
Upload {selectedCount} to {serviceLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Upload progress / log window */}
|
||||||
|
<Dialog open={logOpen} onOpenChange={(o) => { if (!o && uploadDone) closeLog(); }}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Uploading to {serviceLabel}</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">Upload progress log.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="max-h-[50vh] overflow-auto rounded-md border border-border bg-muted/30 p-2.5 font-mono text-[11px] space-y-0.5">
|
||||||
|
{logLines.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting…</div>
|
||||||
|
) : logLines.map((l, i) => (
|
||||||
|
<div key={i} className={cn(l.includes('FAILED') || l.includes('failed') ? 'text-rose-700' : l.includes('OK') ? 'text-emerald-700' : 'text-foreground')}>{l}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button size="sm" onClick={closeLog} disabled={!uploadDone}>
|
||||||
|
{uploadDone ? 'Close' : <><Loader2 className="size-3.5 animate-spin" /> Uploading…</>}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||||
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
@@ -154,10 +155,10 @@ const TREE: TreeNode[] = [
|
|||||||
{
|
{
|
||||||
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
|
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
|
||||||
{ kind: 'item', label: 'Station Information', id: 'station' },
|
{ kind: 'item', label: 'Station Information', id: 'station' },
|
||||||
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
|
{ kind: 'item', label: 'Profiles', id: 'profiles' },
|
||||||
{ kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' },
|
{ kind: 'item', label: 'Operating conditions', id: 'operating' },
|
||||||
{ kind: 'item', label: 'Confirmations (QSL / eQSL / LoTW defaults)', id: 'confirmations' },
|
{ kind: 'item', label: 'Confirmations', id: 'confirmations' },
|
||||||
{ kind: 'item', label: 'External services (QRZ.com, Clublog, LoTW…)', id: 'external-services' },
|
{ kind: 'item', label: 'External services', id: 'external-services' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -351,20 +352,27 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
type ExtServiceCfg = {
|
type ExtServiceCfg = {
|
||||||
api_key: string; email: string; password: string; callsign: string;
|
api_key: string; email: string; password: string; callsign: string;
|
||||||
force_station_callsign: string;
|
force_station_callsign: string;
|
||||||
|
tqsl_path: string; station_location: string; key_password: string;
|
||||||
|
upload_flag: string; write_log: boolean;
|
||||||
auto_upload: boolean; upload_mode: string;
|
auto_upload: boolean; upload_mode: string;
|
||||||
};
|
};
|
||||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg };
|
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
|
||||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||||
api_key: '', email: '', password: '', callsign: '',
|
api_key: '', email: '', password: '', callsign: '',
|
||||||
force_station_callsign: '', auto_upload: false, upload_mode: 'immediate',
|
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||||
|
upload_flag: 'R', write_log: false,
|
||||||
|
auto_upload: false, upload_mode: 'immediate',
|
||||||
});
|
});
|
||||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(),
|
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(),
|
||||||
});
|
});
|
||||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [qrzTesting, setQrzTesting] = useState(false);
|
const [qrzTesting, setQrzTesting] = useState(false);
|
||||||
const [clublogTest, setClublogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [clublogTest, setClublogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [clublogTesting, setClublogTesting] = useState(false);
|
const [clublogTesting, setClublogTesting] = useState(false);
|
||||||
|
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
const [lotwTesting, setLotwTesting] = useState(false);
|
||||||
|
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||||
// Active tab in the External Services panel — lifted here because
|
// Active tab in the External Services panel — lifted here because
|
||||||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
||||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw'>('qrz');
|
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw'>('qrz');
|
||||||
@@ -456,6 +464,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
setBackupCfg(b as any);
|
setBackupCfg(b as any);
|
||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
setExtSvc(es as any);
|
setExtSvc(es as any);
|
||||||
|
try {
|
||||||
|
const locs: any = await ListTQSLStationLocations();
|
||||||
|
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||||
|
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(String(e?.message ?? e));
|
setErr(String(e?.message ?? e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -705,12 +717,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Latitude</Label>
|
<Label>Latitude</Label>
|
||||||
<Input type="number" step="0.0001" className="font-mono" value={(p as any).my_lat ?? ''}
|
<Input type="number" step="0.001" className="font-mono" value={(p as any).my_lat ?? ''}
|
||||||
onChange={(e) => updateActive({ my_lat: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
|
onChange={(e) => updateActive({ my_lat: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Longitude</Label>
|
<Label>Longitude</Label>
|
||||||
<Input type="number" step="0.0001" className="font-mono" value={(p as any).my_lon ?? ''}
|
<Input type="number" step="0.001" className="font-mono" value={(p as any).my_lon ?? ''}
|
||||||
onChange={(e) => updateActive({ my_lon: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
|
onChange={(e) => updateActive({ my_lon: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1796,7 +1808,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
{ k: 'hrdlog', label: 'HRDLOG.NET' },
|
{ k: 'hrdlog', label: 'HRDLOG.NET' },
|
||||||
{ k: 'eqsl', label: 'EQSL' },
|
{ k: 'eqsl', label: 'EQSL' },
|
||||||
{ k: 'hamqth', label: 'HAMQTH' },
|
{ k: 'hamqth', label: 'HAMQTH' },
|
||||||
{ k: 'lotw', label: 'LOTW' },
|
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||||
];
|
];
|
||||||
const qrz = extSvc.qrz;
|
const qrz = extSvc.qrz;
|
||||||
const setQrz = (patch: Partial<ExtServiceCfg>) =>
|
const setQrz = (patch: Partial<ExtServiceCfg>) =>
|
||||||
@@ -1834,6 +1846,33 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lotw = extSvc.lotw;
|
||||||
|
const setLotw = (patch: Partial<ExtServiceCfg>) =>
|
||||||
|
setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } }));
|
||||||
|
|
||||||
|
async function refreshLocations() {
|
||||||
|
try {
|
||||||
|
const locs: any = await ListTQSLStationLocations();
|
||||||
|
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||||
|
} catch (e: any) {
|
||||||
|
setLotwTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testLotw() {
|
||||||
|
setLotwTesting(true);
|
||||||
|
setLotwTest(null);
|
||||||
|
try {
|
||||||
|
await SaveExternalServices(extSvc as any);
|
||||||
|
const msg = await TestLoTWUpload();
|
||||||
|
setLotwTest({ ok: true, msg });
|
||||||
|
} catch (e: any) {
|
||||||
|
setLotwTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
setLotwTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -1880,13 +1919,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
|
||||||
QRZ.com discards station calls that differ from the one registered on the logbook.
|
|
||||||
Setting your registered callsign here rewrites <span className="font-mono">STATION_CALLSIGN</span> on
|
|
||||||
upload, so a QSO logged with a <span className="font-mono">/P</span> or <span className="font-mono">/QRP</span> suffix
|
|
||||||
is still accepted. Note this also applies to QSOs made with a country prefix/suffix.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -1906,6 +1938,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="immediate">Immediate</SelectItem>
|
<SelectItem value="immediate">Immediate</SelectItem>
|
||||||
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
||||||
|
<SelectItem value="on_close">On app close (batch)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1950,11 +1983,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
|
||||||
Club Log uploads each QSO in real time using your account email, password and the
|
|
||||||
logbook callsign — no API key needed for QSO upload.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -1974,6 +2002,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="immediate">Immediate</SelectItem>
|
<SelectItem value="immediate">Immediate</SelectItem>
|
||||||
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
||||||
|
<SelectItem value="on_close">On app close (batch)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1990,6 +2019,80 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : extSvcTab === 'lotw' ? (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">TQSL path</Label>
|
||||||
|
<Input
|
||||||
|
value={lotw.tqsl_path}
|
||||||
|
onChange={(e) => setLotw({ tqsl_path: e.target.value })}
|
||||||
|
placeholder="C:\Program Files (x86)\TrustedQSL\tqsl.exe"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">Station location</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={lotw.station_location || '_'} onValueChange={(v) => setLotw({ station_location: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— pick a TQSL location —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{stationLocations.length === 0 && <SelectItem value="_" disabled>No TQSL locations found</SelectItem>}
|
||||||
|
{stationLocations.map((n) => <SelectItem key={n} value={n}>{n}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={refreshLocations} title="Reload locations from TQSL">
|
||||||
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Label className="text-sm">Key password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={lotw.key_password}
|
||||||
|
onChange={(e) => setLotw({ key_password: e.target.value })}
|
||||||
|
placeholder="only if your certificate key has a password"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">Upload flag</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="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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={lotw.auto_upload}
|
||||||
|
onCheckedChange={(c) => setLotw({ auto_upload: !!c, upload_mode: 'on_close' })}
|
||||||
|
/>
|
||||||
|
Automatic upload on application close
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={lotw.write_log}
|
||||||
|
onCheckedChange={(c) => setLotw({ write_log: !!c })}
|
||||||
|
/>
|
||||||
|
Write TQSL diagnostic log (-t)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={testLotw} disabled={lotwTesting}>
|
||||||
|
<UploadCloud className="size-3.5" /> {lotwTesting ? 'Testing…' : 'Test connection'}
|
||||||
|
</Button>
|
||||||
|
{lotwTest && (
|
||||||
|
<span className={cn('text-xs', lotwTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
||||||
|
{lotwTest.msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
||||||
<Construction className="size-10 opacity-30" />
|
<Construction className="size-10 opacity-30" />
|
||||||
|
|||||||
Vendored
+8
@@ -49,6 +49,8 @@ export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profil
|
|||||||
|
|
||||||
export function ExportADIF(arg1:string):Promise<adif.ExportResult>;
|
export function ExportADIF(arg1:string):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
|
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||||
@@ -91,6 +93,8 @@ export function ListProfiles():Promise<Array<profile.Profile>>;
|
|||||||
|
|
||||||
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
@@ -159,6 +163,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
|
|
||||||
|
export function TestLoTWUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
export function TestQRZUpload():Promise<string>;
|
export function TestQRZUpload():Promise<string>;
|
||||||
@@ -167,4 +173,6 @@ export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
|||||||
|
|
||||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||||
|
|
||||||
|
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ export function ExportADIF(arg1) {
|
|||||||
return window['go']['main']['App']['ExportADIF'](arg1);
|
return window['go']['main']['App']['ExportADIF'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FindQSOsForUpload(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetActiveProfile() {
|
export function GetActiveProfile() {
|
||||||
return window['go']['main']['App']['GetActiveProfile']();
|
return window['go']['main']['App']['GetActiveProfile']();
|
||||||
}
|
}
|
||||||
@@ -162,6 +166,10 @@ export function ListQSO(arg1) {
|
|||||||
return window['go']['main']['App']['ListQSO'](arg1);
|
return window['go']['main']['App']['ListQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListTQSLStationLocations() {
|
||||||
|
return window['go']['main']['App']['ListTQSLStationLocations']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListUDPIntegrations() {
|
export function ListUDPIntegrations() {
|
||||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||||
}
|
}
|
||||||
@@ -298,6 +306,10 @@ export function TestClublogUpload() {
|
|||||||
return window['go']['main']['App']['TestClublogUpload']();
|
return window['go']['main']['App']['TestClublogUpload']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestLoTWUpload() {
|
||||||
|
return window['go']['main']['App']['TestLoTWUpload']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestLookupProvider(arg1, arg2, arg3, arg4) {
|
export function TestLookupProvider(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
@@ -314,6 +326,10 @@ export function UpdateQSO(arg1) {
|
|||||||
return window['go']['main']['App']['UpdateQSO'](arg1);
|
return window['go']['main']['App']['UpdateQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function UploadQSOsManual(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkedBefore(arg1, arg2) {
|
export function WorkedBefore(arg1, arg2) {
|
||||||
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,11 @@ export namespace extsvc {
|
|||||||
password: string;
|
password: string;
|
||||||
callsign: string;
|
callsign: string;
|
||||||
force_station_callsign: string;
|
force_station_callsign: string;
|
||||||
|
tqsl_path: string;
|
||||||
|
station_location: string;
|
||||||
|
key_password: string;
|
||||||
|
upload_flag: string;
|
||||||
|
write_log: boolean;
|
||||||
auto_upload: boolean;
|
auto_upload: boolean;
|
||||||
upload_mode: string;
|
upload_mode: string;
|
||||||
|
|
||||||
@@ -205,6 +210,11 @@ export namespace extsvc {
|
|||||||
this.password = source["password"];
|
this.password = source["password"];
|
||||||
this.callsign = source["callsign"];
|
this.callsign = source["callsign"];
|
||||||
this.force_station_callsign = source["force_station_callsign"];
|
this.force_station_callsign = source["force_station_callsign"];
|
||||||
|
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.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"];
|
||||||
}
|
}
|
||||||
@@ -212,6 +222,7 @@ export namespace extsvc {
|
|||||||
export class ExternalServices {
|
export class ExternalServices {
|
||||||
qrz: ServiceConfig;
|
qrz: ServiceConfig;
|
||||||
clublog: ServiceConfig;
|
clublog: ServiceConfig;
|
||||||
|
lotw: ServiceConfig;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ExternalServices(source);
|
return new ExternalServices(source);
|
||||||
@@ -221,6 +232,7 @@ export namespace extsvc {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.qrz = this.convertValues(source["qrz"], ServiceConfig);
|
this.qrz = this.convertValues(source["qrz"], ServiceConfig);
|
||||||
this.clublog = this.convertValues(source["clublog"], ServiceConfig);
|
this.clublog = this.convertValues(source["clublog"], ServiceConfig);
|
||||||
|
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -241,6 +253,25 @@ export namespace extsvc {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class StationLocation {
|
||||||
|
name: string;
|
||||||
|
call: string;
|
||||||
|
grid: string;
|
||||||
|
dxcc: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StationLocation(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.call = source["call"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
this.dxcc = source["dxcc"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,6 +1118,30 @@ export namespace qso {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class UploadRow {
|
||||||
|
id: number;
|
||||||
|
qso_date: string;
|
||||||
|
callsign: string;
|
||||||
|
band: string;
|
||||||
|
mode: string;
|
||||||
|
country: string;
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new UploadRow(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.qso_date = source["qso_date"];
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.country = source["country"];
|
||||||
|
this.status = source["status"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class WorkedEntry {
|
export class WorkedEntry {
|
||||||
id: number;
|
id: number;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type Service string
|
|||||||
const (
|
const (
|
||||||
ServiceQRZ Service = "qrz" // QRZ.com Logbook
|
ServiceQRZ Service = "qrz" // QRZ.com Logbook
|
||||||
ServiceClublog Service = "clublog" // Club Log real-time upload
|
ServiceClublog Service = "clublog" // Club Log real-time upload
|
||||||
// ServiceLoTW to come.
|
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
||||||
)
|
)
|
||||||
|
|
||||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||||
@@ -41,6 +41,10 @@ const (
|
|||||||
ModeImmediate UploadMode = "immediate"
|
ModeImmediate UploadMode = "immediate"
|
||||||
// ModeDelayed waits a random 1–2 minutes before uploading.
|
// ModeDelayed waits a random 1–2 minutes before uploading.
|
||||||
ModeDelayed UploadMode = "delayed"
|
ModeDelayed UploadMode = "delayed"
|
||||||
|
// ModeOnClose queues QSOs and uploads them in one batch when the app
|
||||||
|
// closes. This is the LoTW-friendly mode (ARRL discourages per-QSO
|
||||||
|
// uploads), and it lets the user fix the whole session before sending.
|
||||||
|
ModeOnClose UploadMode = "on_close"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServiceConfig is the per-service user configuration. It's a superset of
|
// ServiceConfig is the per-service user configuration. It's a superset of
|
||||||
@@ -49,6 +53,7 @@ const (
|
|||||||
//
|
//
|
||||||
// QRZ.com → APIKey, ForceStationCallsign
|
// QRZ.com → APIKey, ForceStationCallsign
|
||||||
// Club Log → Email, Password, Callsign, APIKey
|
// Club Log → Email, Password, Callsign, APIKey
|
||||||
|
// LoTW → TQSLPath, StationLocation, KeyPassword (signs+uploads via TQSL)
|
||||||
//
|
//
|
||||||
// AutoUpload + UploadMode are common to all (timing is per-service, so the
|
// AutoUpload + UploadMode are common to all (timing is per-service, so the
|
||||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||||
@@ -58,6 +63,11 @@ type ServiceConfig struct {
|
|||||||
Password string `json:"password"` // Club Log account password
|
Password string `json:"password"` // Club Log account password
|
||||||
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
|
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
|
||||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ
|
ForceStationCallsign string `json:"force_station_callsign"` // QRZ
|
||||||
|
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"
|
||||||
|
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"`
|
||||||
}
|
}
|
||||||
@@ -69,17 +79,29 @@ func (c ServiceConfig) normalised() ServiceConfig {
|
|||||||
c.Email = strings.TrimSpace(c.Email)
|
c.Email = strings.TrimSpace(c.Email)
|
||||||
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
||||||
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
||||||
if c.UploadMode != ModeDelayed {
|
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"
|
||||||
|
}
|
||||||
|
switch c.UploadMode {
|
||||||
|
case ModeDelayed, ModeOnClose:
|
||||||
|
// keep
|
||||||
|
default:
|
||||||
c.UploadMode = ModeImmediate
|
c.UploadMode = ModeImmediate
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExternalServices bundles every service's config for the settings UI.
|
// ExternalServices bundles every service's config for the settings UI.
|
||||||
// LoTW fields will be added as that service lands.
|
|
||||||
type ExternalServices struct {
|
type ExternalServices struct {
|
||||||
QRZ ServiceConfig `json:"qrz"`
|
QRZ ServiceConfig `json:"qrz"`
|
||||||
Clublog ServiceConfig `json:"clublog"`
|
Clublog ServiceConfig `json:"clublog"`
|
||||||
|
LoTW ServiceConfig `json:"lotw"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadResult is the outcome of a single upload attempt.
|
// UploadResult is the outcome of a single upload attempt.
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoTW uploads go through TQSL (ARRL's Trusted QSL signer): there is no
|
||||||
|
// plain HTTP API — every QSO must be signed with the station certificate
|
||||||
|
// before LoTW accepts it. We write the QSO to a temporary ADIF file and run
|
||||||
|
// tqsl in batch mode to sign and upload it in one shot.
|
||||||
|
|
||||||
|
// StationLocation is one TQSL "Station Location" the user has defined. These
|
||||||
|
// pair a callsign with a certificate + grid/zones; the upload picks one by
|
||||||
|
// name (the -l flag).
|
||||||
|
type StationLocation struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Call string `json:"call"`
|
||||||
|
Grid string `json:"grid"`
|
||||||
|
DXCC int `json:"dxcc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// stationDataFile mirrors TQSL's station_data XML.
|
||||||
|
type stationDataFile struct {
|
||||||
|
XMLName xml.Name `xml:"StationDataFile"`
|
||||||
|
Stations []struct {
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Call string `xml:"CALL"`
|
||||||
|
Grid string `xml:"GRIDSQUARE"`
|
||||||
|
DXCC int `xml:"DXCC"`
|
||||||
|
} `xml:"StationData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStationLocations parses TQSL's station_data file and returns the
|
||||||
|
// defined locations. Used to populate the Station Location dropdown — the
|
||||||
|
// same file Log4OM reads.
|
||||||
|
func ListStationLocations(stationDataPath string) ([]StationLocation, error) {
|
||||||
|
data, err := os.ReadFile(stationDataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read station_data: %w", err)
|
||||||
|
}
|
||||||
|
var f stationDataFile
|
||||||
|
if err := xml.Unmarshal(data, &f); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse station_data: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]StationLocation, 0, len(f.Stations))
|
||||||
|
for _, s := range f.Stations {
|
||||||
|
out = append(out, StationLocation{Name: s.Name, Call: s.Call, Grid: s.Grid, DXCC: s.DXCC})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTQSLPath returns the usual tqsl.exe install path on Windows, or ""
|
||||||
|
// if not found.
|
||||||
|
func DefaultTQSLPath() string {
|
||||||
|
for _, p := range []string{
|
||||||
|
`C:\Program Files (x86)\TrustedQSL\tqsl.exe`,
|
||||||
|
`C:\Program Files\TrustedQSL\tqsl.exe`,
|
||||||
|
} {
|
||||||
|
if fileExists(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStationDataPath returns TQSL's station_data location (%APPDATA%\
|
||||||
|
// TrustedQSL\station_data on Windows), or "" if APPDATA isn't set.
|
||||||
|
func DefaultStationDataPath() string {
|
||||||
|
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||||
|
return filepath.Join(appData, "TrustedQSL", "station_data")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(p string) bool {
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadLoTW signs and uploads one ADIF record via TQSL. tempDir is where the
|
||||||
|
// temporary .adi is written (falls back to the OS temp dir). Returns OK when
|
||||||
|
// LoTW accepts the QSO or reports it as a duplicate (already uploaded).
|
||||||
|
//
|
||||||
|
// TQSL command:
|
||||||
|
//
|
||||||
|
// tqsl -d -x -a all -l "<location>" -u [-p <keypass>] <file.adi>
|
||||||
|
//
|
||||||
|
// Exit codes (TQSL): 0 = uploaded; 8 = nothing new (all duplicates/out of
|
||||||
|
// range); 9 = some uploaded, some skipped; anything else = failure.
|
||||||
|
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
|
||||||
|
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
||||||
|
loc := strings.TrimSpace(cfg.StationLocation)
|
||||||
|
switch {
|
||||||
|
case tqsl == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("lotw: TQSL path not set")
|
||||||
|
case !fileExists(tqsl):
|
||||||
|
return UploadResult{}, fmt.Errorf("lotw: tqsl.exe not found at %q", tqsl)
|
||||||
|
case loc == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("lotw: station location not set")
|
||||||
|
case strings.TrimSpace(adifRecord) == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("lotw: empty adif record")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the QSO to a temp ADIF file (minimal header keeps strict TQSL
|
||||||
|
// happy). Cleaned up after upload.
|
||||||
|
if strings.TrimSpace(tempDir) == "" {
|
||||||
|
tempDir = os.TempDir()
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp(tempDir, "opslog-lotw-*.adi")
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("lotw: create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := f.Name()
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
if _, err := f.WriteString("OpsLog LoTW upload\n<PROGRAMID:6>OpsLog <EOH>\n" + adifRecord + "\n"); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return UploadResult{}, fmt.Errorf("lotw: write temp file: %w", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
args := []string{"-d", "-x", "-a", "all", "-l", loc, "-u"}
|
||||||
|
if pwd := strings.TrimSpace(cfg.KeyPassword); pwd != "" {
|
||||||
|
args = append(args, "-p", pwd)
|
||||||
|
}
|
||||||
|
if cfg.WriteLog {
|
||||||
|
// -t writes a TQSL diagnostic log; drop it next to the temp ADIF.
|
||||||
|
args = append(args, "-t", filepath.Join(tempDir, "opslog-tqsl.log"))
|
||||||
|
}
|
||||||
|
args = append(args, tmpPath)
|
||||||
|
|
||||||
|
// TQSL launches a child process and contacts LoTW — give it generous
|
||||||
|
// time, independent of any short caller deadline.
|
||||||
|
runCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(runCtx, tqsl, args...)
|
||||||
|
out, runErr := cmd.CombinedOutput()
|
||||||
|
msg := strings.TrimSpace(string(out))
|
||||||
|
|
||||||
|
code := 0
|
||||||
|
if runErr != nil {
|
||||||
|
if ee, ok := runErr.(*exec.ExitError); ok {
|
||||||
|
code = ee.ExitCode()
|
||||||
|
} else {
|
||||||
|
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch code {
|
||||||
|
case 0, 9:
|
||||||
|
return UploadResult{OK: true, Message: "uploaded to LoTW"}, nil
|
||||||
|
case 8:
|
||||||
|
return UploadResult{OK: true, Message: "already uploaded (duplicate)"}, nil
|
||||||
|
default:
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("tqsl exit code %d", code)
|
||||||
|
}
|
||||||
|
return UploadResult{OK: false, Message: msg}, fmt.Errorf("lotw: tqsl failed (code %d): %s", code, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoTW validates the LoTW config: tqsl present and the chosen station
|
||||||
|
// location exists in station_data.
|
||||||
|
func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) {
|
||||||
|
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
||||||
|
loc := strings.TrimSpace(cfg.StationLocation)
|
||||||
|
if tqsl == "" || !fileExists(tqsl) {
|
||||||
|
return "", fmt.Errorf("lotw: tqsl.exe not found (set the TQSL path)")
|
||||||
|
}
|
||||||
|
if loc == "" {
|
||||||
|
return "", fmt.Errorf("lotw: pick a station location")
|
||||||
|
}
|
||||||
|
locs, err := ListStationLocations(stationDataPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("lotw: can't read station locations: %w", err)
|
||||||
|
}
|
||||||
|
for _, l := range locs {
|
||||||
|
if strings.EqualFold(l.Name, loc) {
|
||||||
|
return fmt.Sprintf("Ready — TQSL found, location %q (%s)", l.Name, l.Call), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("lotw: station location %q not found in TQSL", loc)
|
||||||
|
}
|
||||||
+144
-15
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -26,6 +27,12 @@ type Deps struct {
|
|||||||
// NotifyError surfaces a failed upload (logging + optional UI event).
|
// NotifyError surfaces a failed upload (logging + optional UI event).
|
||||||
NotifyError func(svc Service, id int64, err error)
|
NotifyError func(svc Service, id int64, err error)
|
||||||
|
|
||||||
|
// ShouldUpload reports whether a QSO is eligible for upload to this
|
||||||
|
// 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 ("N" or "R"), à la Log4OM. Returning false skips the QSO.
|
||||||
|
ShouldUpload func(svc Service, id int64) bool
|
||||||
|
|
||||||
// Logf is an optional diagnostic logger.
|
// Logf is an optional diagnostic logger.
|
||||||
Logf func(format string, args ...any)
|
Logf func(format string, args ...any)
|
||||||
}
|
}
|
||||||
@@ -36,9 +43,10 @@ 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 {
|
||||||
@@ -49,7 +57,8 @@ 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{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,13 +100,30 @@ func (m *Manager) OnQSOLogged(id int64) {
|
|||||||
|
|
||||||
// QRZ.com
|
// QRZ.com
|
||||||
if qrz := cfg.QRZ; qrz.AutoUpload && qrz.APIKey != "" {
|
if qrz := cfg.QRZ; qrz.AutoUpload && qrz.APIKey != "" {
|
||||||
m.scheduleUpload(ServiceQRZ, id, qrz)
|
m.route(ServiceQRZ, id, qrz)
|
||||||
}
|
}
|
||||||
// Club Log — email + password + callsign are enough (no API key).
|
// Club Log — email + password + callsign are enough (no API key).
|
||||||
if cl := cfg.Clublog; cl.AutoUpload && cl.Email != "" && cl.Password != "" {
|
if cl := cfg.Clublog; cl.AutoUpload && cl.Email != "" && cl.Password != "" {
|
||||||
m.scheduleUpload(ServiceClublog, id, cl)
|
m.route(ServiceClublog, id, cl)
|
||||||
}
|
}
|
||||||
// LoTW will be added here.
|
// LoTW — needs TQSL + a station location.
|
||||||
|
if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" {
|
||||||
|
m.route(ServiceLoTW, id, lt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// route sends a logged QSO down the configured timing path: queue it for the
|
||||||
|
// 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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.scheduleUpload(svc, id, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
|
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
|
||||||
@@ -111,10 +137,104 @@ func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
|||||||
go m.upload(svc, id, cfg)
|
go m.upload(svc, id, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// upload performs the actual push. It builds a fresh, lifecycle-independent
|
// PendingCount returns how many QSOs are queued for on-close upload across
|
||||||
// context so a delayed upload still completes even if it fires close to
|
// all services. The shutdown sequence uses it to decide whether to show the
|
||||||
// shutdown.
|
// upload step.
|
||||||
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
|
func (m *Manager) PendingCount() int {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
n := 0
|
||||||
|
for _, ids := range m.pending {
|
||||||
|
n += len(ids)
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
func (m *Manager) FlushOnClose() int {
|
||||||
|
m.mu.Lock()
|
||||||
|
pending := m.pending
|
||||||
|
m.pending = map[Service][]int64{}
|
||||||
|
cfg := m.cfg
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
uploaded := 0
|
||||||
|
for svc, ids := range pending {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if m.upload(svc, id, sc) {
|
||||||
|
uploaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uploaded
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushLoTWBatch signs+uploads all queued LoTW QSOs in one TQSL run, then
|
||||||
|
// stamps each as uploaded on success.
|
||||||
|
func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
|
||||||
|
var records []string
|
||||||
|
var kept []int64
|
||||||
|
for _, id := range ids {
|
||||||
|
// Skip QSOs not eligible (sent status doesn't match Upload flag).
|
||||||
|
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rec, ok := m.deps.BuildADIF(id, ""); ok {
|
||||||
|
records = append(records, rec)
|
||||||
|
kept = append(kept, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
res, err := UploadLoTW(context.Background(), cfg, "", strings.Join(records, "\n"))
|
||||||
|
if err != nil || !res.OK {
|
||||||
|
if err == nil {
|
||||||
|
err = errFromResult(res)
|
||||||
|
}
|
||||||
|
m.logf("extsvc: lotw batch upload (%d QSOs) failed: %v", len(kept), err)
|
||||||
|
if m.deps.NotifyError != nil {
|
||||||
|
m.deps.NotifyError(ServiceLoTW, 0, err)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
m.logf("extsvc: lotw batch upload OK (%d QSOs)", len(kept))
|
||||||
|
if m.deps.MarkUploaded != nil {
|
||||||
|
for _, id := range kept {
|
||||||
|
m.deps.MarkUploaded(ServiceLoTW, id, res.LogID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(kept)
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload performs the actual push and returns true on success. It builds a
|
||||||
|
// fresh, lifecycle-independent context so a delayed upload still completes
|
||||||
|
// even if it fires close to shutdown.
|
||||||
|
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
||||||
|
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
|
||||||
|
// match the configured Upload flag).
|
||||||
|
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
|
||||||
|
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -126,7 +246,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
|
|||||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
||||||
case ServiceClublog:
|
case ServiceClublog:
|
||||||
@@ -135,11 +255,19 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
|
|||||||
record, ok := m.deps.BuildADIF(id, "")
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
if !ok {
|
if !ok {
|
||||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||||||
|
case ServiceLoTW:
|
||||||
|
// LoTW signs the QSO's own station call via TQSL — no override.
|
||||||
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
|
if !ok {
|
||||||
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
res, err = UploadLoTW(ctx, cfg, "", record)
|
||||||
default:
|
default:
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil || !res.OK {
|
if err != nil || !res.OK {
|
||||||
@@ -150,11 +278,12 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
|
|||||||
if m.deps.NotifyError != nil {
|
if m.deps.NotifyError != nil {
|
||||||
m.deps.NotifyError(svc, id, err)
|
m.deps.NotifyError(svc, id, err)
|
||||||
}
|
}
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
||||||
if m.deps.MarkUploaded != nil {
|
if m.deps.MarkUploaded != nil {
|
||||||
m.deps.MarkUploaded(svc, id, res.LogID)
|
m.deps.MarkUploaded(svc, id, res.LogID)
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -156,8 +157,14 @@ func normalizeNames(r *Result) {
|
|||||||
r.Name = titleCase(r.Name)
|
r.Name = titleCase(r.Name)
|
||||||
r.QTH = titleCase(r.QTH)
|
r.QTH = titleCase(r.QTH)
|
||||||
r.Address = titleCase(r.Address)
|
r.Address = titleCase(r.Address)
|
||||||
|
// 3 decimals (~110 m) is plenty for a contact's coordinates and keeps
|
||||||
|
// the displayed/exported value tidy.
|
||||||
|
r.Lat = round3(r.Lat)
|
||||||
|
r.Lon = round3(r.Lon)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func round3(f float64) float64 { return math.Round(f*1000) / 1000 }
|
||||||
|
|
||||||
// titleCase lowercases the whole string then capitalises the first letter of
|
// titleCase lowercases the whole string then capitalises the first letter of
|
||||||
// each word. Word boundaries are any non-alphanumeric rune (space, hyphen,
|
// each word. Word boundaries are any non-alphanumeric rune (space, hyphen,
|
||||||
// apostrophe, slash…), so "vetraz-monthoux" → "Vetraz-Monthoux" and
|
// apostrophe, slash…), so "vetraz-monthoux" → "Vetraz-Monthoux" and
|
||||||
|
|||||||
@@ -323,6 +323,51 @@ func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) {
|
|||||||
return scanQSO(row)
|
return scanQSO(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadRow is a lightweight QSO projection for the QSL Manager grid.
|
||||||
|
type UploadRow struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
QSODate string `json:"qso_date"` // ISO UTC; the UI formats it
|
||||||
|
Callsign string `json:"callsign"`
|
||||||
|
Band string `json:"band"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Status string `json:"status"` // the matched per-service sent status
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadStatusCols whitelists the per-service sent-status columns the QSL
|
||||||
|
// Manager may filter on (guards the dynamic column name in the query).
|
||||||
|
var uploadStatusCols = map[string]bool{
|
||||||
|
"lotw_sent": true,
|
||||||
|
"qrzcom_qso_upload_status": true,
|
||||||
|
"clublog_qso_upload_status": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForUpload returns QSOs whose per-service sent-status column equals
|
||||||
|
// value ("" matches blank/NULL). Used by the QSL Manager's "Select required".
|
||||||
|
func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) {
|
||||||
|
if !uploadStatusCols[column] {
|
||||||
|
return nil, fmt.Errorf("invalid upload column %q", column)
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT id, qso_date, callsign, COALESCE(band,''), COALESCE(mode,''),
|
||||||
|
COALESCE(country,''), COALESCE(`+column+`,'')
|
||||||
|
FROM qso WHERE COALESCE(`+column+`,'') = ?
|
||||||
|
ORDER BY qso_date DESC`, value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list for upload: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []UploadRow
|
||||||
|
for rows.Next() {
|
||||||
|
var u UploadRow
|
||||||
|
if err := rows.Scan(&u.ID, &u.QSODate, &u.Callsign, &u.Band, &u.Mode, &u.Country, &u.Status); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -351,6 +396,19 @@ func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkLoTWUploaded stamps LOTW_QSL_SENT=Y and the sent date after a
|
||||||
|
// successful TQSL upload. date is an ADIF YYYYMMDD string.
|
||||||
|
func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET lotw_sent = 'Y', lotw_sent_date = ?,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
|
||||||
|
date, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark lotw uploaded %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||||
if q.ID == 0 {
|
if q.ID == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user