Compare commits

...
5 Commits
Author SHA1 Message Date
rouggy 629bd8d84f feat(confirmations): a HamQTH default, and the pending status stops meaning 'sent'
HamQTH joins the Confirmations page with a sent side only — the site
publishes no confirmation feed, so there is nothing to receive — and it
defaults to R, the same 'still to upload' the other online services get.

That default could not have worked as written. HamQTH and HAMLOG.online
keep their sent state in an ADIF extra, and eligibility blocked on the
key being PRESENT — so an operator setting the natural R default would
have stamped every new QSO 'already gone' and silently disabled the very
auto-upload the default arms. Eligibility now reads the VALUE: the ADIF
pending statuses mean pending, anything else means sent. Guard-tested
both ways.
2026-08-31 16:48:11 +02:00
rouggy 7152d11007 feat(cluster): county and prefix get their chase switches, and the grid one tells the whole truth
Chase POTA already withdrew its badge, filter chip and column when
unchecked; US counties and new prefixes now have the same switch, and
unchecking Chase new grids finally withdraws the NEW GRID badge too (the
backend stopped learning squares but the display kept shouting the ones
it knew — the setting is mirrored to localStorage so the display layer
can gate synchronously). All withdrawal happens at the display layer:
the facts keep being computed, so re-ticking a box needs no rescan.
2026-08-31 16:41:13 +02:00
rouggy a03d907128 fix(lotw): MY_CNTY must not sink a non-US station's upload
TQSL validates MY_CNTY against the ADIF secondary-subdivision list,
which is the US county enumeration — 'XX,County' with a two-letter
state. A Canadian profile produced 'ONTARIO,Kawartha' (the export joined
MY_STATE onto the county wholesale) and TQSL refused the whole record.

Two layers: adifCounty only prefixes a two-letter state, so exports stop
manufacturing the invalid shape; and UploadLoTW scrubs any MY_CNTY that
is not the US shape before signing — MY_STATE and MY_GRIDSQUARE already
locate the station for LoTW, and the US form survives for the county
hunters. Table-tested.
2026-08-31 15:56:34 +02:00
rouggy 91569e12f4 feat(hamqth): QSO upload to the HamQTH logbook — the 8th external service
qso_realtime.php with the account's callbook credentials (which the
external-services config falls back to when its own are blank), one ADIF
record per QSO, prg=OpsLog. HTTP status IS the answer: 200 saved, 400
rejected (a duplicate counts as delivered, like HRDLog's insert 0), 403
credentials. Sent-state lives in APP_OPSLOG_HAMQTH_SENT extras like
HAMLOG.online — ADIF names no HamQTH field. Auto-upload on log, on-close
batch, right-click Send to, QSL Manager backlog, and a Test button that
authenticates against the callbook login, which cannot touch the log.

Fixes a real mis-route on the way: manual 'Send to HAMLOG.online' had no
branch in runManualUpload and fell through to QRZ.com — the selection was
uploaded to the wrong service with the QRZ key. Both extras-stamped
services now have their own branch.
2026-08-31 14:34:49 +02:00
rouggy 68f0d68980 fix(restart): the self-relaunch waits for its parent's mutex
A database switch relaunches OpsLog, and the child regularly won the
race against the old instance's teardown — so following the app's own
instructions produced 'OpsLog is already running'. The relaunch now
passes --relaunch, which gets the same 20-second mutex patience the
post-update restart has always had. Opens the 0.27.6 block.
2026-08-31 14:19:30 +02:00
20 changed files with 549 additions and 30 deletions
+124 -7
View File
@@ -361,6 +361,7 @@ const (
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status" keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status" keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed" keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
keyQSLDefaultHamqthStatus = "qsl.hamqth_status"
keyQSLDefaultHamlogStatus = "qsl.hamlog_status" keyQSLDefaultHamlogStatus = "qsl.hamlog_status"
keyQSLDefaultHamlogCfm = "qsl.hamlog_confirmed" keyQSLDefaultHamlogCfm = "qsl.hamlog_confirmed"
@@ -401,6 +402,12 @@ const (
keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload" keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode" keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode"
keyExtHamqthUsername = "extsvc.hamqth.username"
keyExtHamqthPassword = "extsvc.hamqth.password"
keyExtHamqthCallsign = "extsvc.hamqth.callsign"
keyExtHamqthAutoUpload = "extsvc.hamqth.auto_upload"
keyExtHamqthUploadMode = "extsvc.hamqth.upload_mode"
keyExtHamlogAPIKey = "extsvc.hamlog.api_key" keyExtHamlogAPIKey = "extsvc.hamlog.api_key"
keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload" keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload"
keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode" keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode"
@@ -450,6 +457,9 @@ type QSLDefaults struct {
// at "N" here exactly as they do for Club Log. // at "N" here exactly as they do for Club Log.
HamlogStatus string `json:"hamlog_status"` HamlogStatus string `json:"hamlog_status"`
HamlogCfm string `json:"hamlog_confirmed"` HamlogCfm string `json:"hamlog_confirmed"`
// HamQTH, same extras story — and SENT only: the site publishes no
// confirmation feed, so there is no received side to default.
HamqthStatus string `json:"hamqth_status"`
} }
// CATSettings is the user-tweakable rig-control configuration. Stored as // CATSettings is the user-tweakable rig-control configuration. Stored as
@@ -2851,7 +2861,9 @@ func (a *App) RestartApp() error {
if err != nil { if err != nil {
return fmt.Errorf("locate executable: %w", err) return fmt.Errorf("locate executable: %w", err)
} }
cmd := exec.Command(exe) // --relaunch: the child waits for OUR mutex instead of declaring us a
// duplicate — this instance is quitting, just not always fast enough.
cmd := exec.Command(exe, "--relaunch")
cmd.Dir = filepath.Dir(exe) cmd.Dir = filepath.Dir(exe)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch OpsLog: %w", err) return fmt.Errorf("relaunch OpsLog: %w", err)
@@ -10941,6 +10953,7 @@ func defaultQSLDefaults() QSLDefaults {
EQSLSent: "R", EQSLRcvd: "N", EQSLSent: "R", EQSLRcvd: "N",
LOTWSent: "R", LOTWRcvd: "N", LOTWSent: "R", LOTWRcvd: "N",
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R", ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
HamqthStatus: "R",
QRZComStatus: "R", QRZComCfm: "N", QRZComStatus: "R", QRZComCfm: "N",
} }
} }
@@ -10962,6 +10975,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus, keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus,
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm, keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm, keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
keyQSLDefaultHamqthStatus,
) )
if err != nil { if err != nil {
return out, err return out, err
@@ -10978,6 +10992,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out.QRZComStatus = m[keyQSLDefaultQRZComStatus] out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
out.QRZComCfm = m[keyQSLDefaultQRZComCfm] out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
out.HamlogStatus = m[keyQSLDefaultHamlogStatus] out.HamlogStatus = m[keyQSLDefaultHamlogStatus]
out.HamqthStatus = m[keyQSLDefaultHamqthStatus]
out.HamlogCfm = m[keyQSLDefaultHamlogCfm] out.HamlogCfm = m[keyQSLDefaultHamlogCfm]
return out, nil return out, nil
} }
@@ -11002,6 +11017,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)), keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)), keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
keyQSLDefaultHamlogStatus: strings.ToUpper(strings.TrimSpace(d.HamlogStatus)), keyQSLDefaultHamlogStatus: strings.ToUpper(strings.TrimSpace(d.HamlogStatus)),
keyQSLDefaultHamqthStatus: strings.ToUpper(strings.TrimSpace(d.HamqthStatus)),
keyQSLDefaultHamlogCfm: strings.ToUpper(strings.TrimSpace(d.HamlogCfm)), keyQSLDefaultHamlogCfm: strings.ToUpper(strings.TrimSpace(d.HamlogCfm)),
} { } {
if err := a.settings.Set(a.ctx, scope+k, v); err != nil { if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
@@ -11058,6 +11074,7 @@ func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
// string field, and these two are map entries. Only set when the QSO does not // string field, and these two are map entries. Only set when the QSO does not
// already carry them, which is the same rule fill() applies. // already carry them, which is the same rule fill() applies.
setExtraDefault(q, hamlogSentKey, d.HamlogStatus) setExtraDefault(q, hamlogSentKey, d.HamlogStatus)
setExtraDefault(q, hamqthSentKey, d.HamqthStatus)
setExtraDefault(q, award.HamlogQSLKey, d.HamlogCfm) setExtraDefault(q, award.HamlogQSLKey, d.HamlogCfm)
} }
@@ -11175,7 +11192,9 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode, keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID, keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote, keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote,
keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode) keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode,
keyExtHamqthUsername, keyExtHamqthPassword, keyExtHamqthCallsign,
keyExtHamqthAutoUpload, keyExtHamqthUploadMode)
if err != nil { if err != nil {
return out return out
} }
@@ -11259,6 +11278,21 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
AutoUpload: m[keyExtHamlogAutoUpload] == "1", AutoUpload: m[keyExtHamlogAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]), UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]),
} }
out.HamQTH = extsvc.ServiceConfig{
Username: m[keyExtHamqthUsername],
Password: m[keyExtHamqthPassword],
Callsign: m[keyExtHamqthCallsign],
AutoUpload: m[keyExtHamqthAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtHamqthUploadMode]),
}
// The callbook lookup already knows these credentials; blanks fall back to
// them so an operator who set up the lookup years ago is one checkbox away.
if out.HamQTH.Username == "" && out.HamQTH.Password == "" {
u, _ := a.settings.Get(a.ctx, keyHQUser)
p, _ := a.settings.Get(a.ctx, keyHQPassword)
out.HamQTH.Username = strings.TrimSpace(u)
out.HamQTH.Password = p
}
return out return out
} }
@@ -11333,6 +11367,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
if hamMode == string(extsvc.ModeOnClose) { if hamMode == string(extsvc.ModeOnClose) {
hamMode = string(extsvc.ModeImmediate) hamMode = string(extsvc.ModeImmediate)
} }
hqMode := modeOf(cfg.HamQTH.UploadMode)
hamAuto := "0" hamAuto := "0"
if cfg.Hamlog.AutoUpload { if cfg.Hamlog.AutoUpload {
hamAuto = "1" hamAuto = "1"
@@ -11388,6 +11423,11 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey), keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey),
keyExtHamlogAutoUpload: hamAuto, keyExtHamlogAutoUpload: hamAuto,
keyExtHamlogUploadMode: hamMode, keyExtHamlogUploadMode: hamMode,
keyExtHamqthUsername: strings.TrimSpace(cfg.HamQTH.Username),
keyExtHamqthPassword: cfg.HamQTH.Password,
keyExtHamqthCallsign: strings.ToUpper(strings.TrimSpace(cfg.HamQTH.Callsign)),
keyExtHamqthAutoUpload: boolStr(cfg.HamQTH.AutoUpload),
keyExtHamqthUploadMode: hqMode,
} { } {
if err := a.settings.Set(a.ctx, scope+k, v); err != nil { if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
return err return err
@@ -11416,6 +11456,13 @@ func (a *App) TestClublogUpload() (string, error) {
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog) return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
} }
// TestHamQTHUpload checks the HamQTH credentials against the callbook login —
// authenticated, and unable to touch the log.
func (a *App) TestHamQTHUpload() (string, error) {
cfg := a.loadExternalServices().HamQTH
return extsvc.TestHamQTH(a.ctx, nil, cfg)
}
// TestHRDLogUpload validates that the HRDLog credentials are complete. // TestHRDLogUpload validates that the HRDLog credentials are complete.
func (a *App) TestHRDLogUpload() (string, error) { func (a *App) TestHRDLogUpload() (string, error) {
return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog) return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog)
@@ -11475,6 +11522,9 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
if extsvc.Service(service) == extsvc.ServiceHamlog { if extsvc.Service(service) == extsvc.ServiceHamlog {
return a.qso.ListMissingExtra(a.ctx, hamlogSentKey) return a.qso.ListMissingExtra(a.ctx, hamlogSentKey)
} }
if extsvc.Service(service) == extsvc.ServiceHamQTH {
return a.qso.ListMissingExtra(a.ctx, hamqthSentKey)
}
col := uploadColumnFor(service) col := uploadColumnFor(service)
if col == "" { if col == "" {
return nil, fmt.Errorf("unknown service %q", service) return nil, fmt.Errorf("unknown service %q", service)
@@ -11490,7 +11540,7 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
return fmt.Errorf("db not initialized") return fmt.Errorf("db not initialized")
} }
svc := extsvc.Service(service) svc := extsvc.Service(service)
if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog { if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog && svc != extsvc.ServiceHamQTH {
return fmt.Errorf("unknown service %q", service) return fmt.Errorf("unknown service %q", service)
} }
cfg := a.loadExternalServices() cfg := a.loadExternalServices()
@@ -11714,6 +11764,43 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
} }
flush() flush()
} }
} else if svc == extsvc.ServiceHamlog || svc == extsvc.ServiceHamQTH {
// One record per request, extras-stamped services. Hamlog used to fall
// through to the QRZ branch below and got uploaded to the WRONG service
// with the QRZ key — the context menu offered what the loop never handled.
for i, id := range ids {
if i > 0 {
time.Sleep(manualUploadPace)
}
q, gerr := a.qso.GetByID(ctx, id)
call := ""
if gerr == nil {
call = q.Callsign
}
rec, ok := a.buildUploadADIF(id, "")
if !ok {
emit(call + " — skipped (no record)")
continue
}
var res extsvc.UploadResult
var err error
if svc == extsvc.ServiceHamlog {
res, err = extsvc.UploadHamlog(ctx, nil, cfg.Hamlog, rec)
} else {
res, err = extsvc.UploadHamQTH(ctx, nil, cfg.HamQTH, rec)
}
if err == nil && res.OK {
a.markExtUploaded(svc, id, res.LogID)
uploaded++
emit(call + " — OK")
} else {
msg := res.Message
if err != nil {
msg = err.Error()
}
emit(call + " — FAILED: " + msg)
}
}
} else { } else {
// QRZ.com: one record per request (its logbook API has no batch upload), // QRZ.com: one record per request (its logbook API has no batch upload),
// paced for the same reason as HRDLog above. // paced for the same reason as HRDLog above.
@@ -11755,6 +11842,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
label := map[extsvc.Service]string{ label := map[extsvc.Service]string{
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog", extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL", extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
extsvc.ServiceHamlog: "HAMLOG.online", extsvc.ServiceHamQTH: "HamQTH",
}[svc] }[svc]
if label == "" { if label == "" {
label = string(svc) label = string(svc)
@@ -13490,14 +13578,21 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
// is not remembered — hence no on-close mode and no manual backlog. // is not remembered — hence no on-close mode and no manual backlog.
return true return true
case extsvc.ServiceHamlog: case extsvc.ServiceHamlog:
// The stamp is an extra, not a column — see markExtUploaded. Present means // The stamp is an extra, not a column — see markExtUploaded. A stamp that
// it has gone, which is what stops an on-demand re-upload of a whole log // MEANS SENT is what stops an on-demand re-upload of a whole log from
// from sending every contact twice. // sending every contact twice.
if q.Extras != nil && strings.TrimSpace(q.Extras[hamlogSentKey]) != "" { if q.Extras != nil && extrasSaysSent(q.Extras[hamlogSentKey]) {
applog.Printf("extsvc: QSO %d not eligible for hamlog — already sent on %s", id, q.Extras[hamlogSentKey]) applog.Printf("extsvc: QSO %d not eligible for hamlog — already sent on %s", id, q.Extras[hamlogSentKey])
return false return false
} }
return true return true
case extsvc.ServiceHamQTH:
// Same extras stamp as HAMLOG.online — ADIF names no HamQTH field.
if q.Extras != nil && extrasSaysSent(q.Extras[hamqthSentKey]) {
applog.Printf("extsvc: QSO %d not eligible for hamqth — already sent on %s", id, q.Extras[hamqthSentKey])
return false
}
return true
case extsvc.ServiceLoTW: case extsvc.ServiceLoTW:
for _, f := range a.loadExternalServices().LoTW.UploadFlags { for _, f := range a.loadExternalServices().LoTW.UploadFlags {
if strings.EqualFold(q.LOTWSent, f) { if strings.EqualFold(q.LOTWSent, f) {
@@ -13518,8 +13613,26 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
const ( const (
hamlogSentKey = "APP_OPSLOG_HAMLOG_SENT" hamlogSentKey = "APP_OPSLOG_HAMLOG_SENT"
hamlogSentDateKey = "APP_OPSLOG_HAMLOG_SENT_DATE" hamlogSentDateKey = "APP_OPSLOG_HAMLOG_SENT_DATE"
hamqthSentKey = "APP_OPSLOG_HAMQTH_SENT"
hamqthSentDateKey = "APP_OPSLOG_HAMQTH_SENT_DATE"
) )
// extrasSaysSent reads an extras upload stamp as "this QSO has GONE".
//
// Mere presence will not do. markExtUploaded writes "Y" (older builds wrote the
// date), but the Confirmations page pre-fills the same key with a TO-DO status
// — "R", requested, is the natural default for a service you intend to upload
// to — and reading that as "already gone" would silently disable the very
// auto-upload the default was set to arm. So the pending statuses mean pending,
// and anything else means sent.
func extrasSaysSent(v string) bool {
switch strings.ToUpper(strings.TrimSpace(v)) {
case "", "R", "N", "Q", "I":
return false
}
return true
}
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) { func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
date := time.Now().UTC().Format("20060102") date := time.Now().UTC().Format("20060102")
// Use a fresh background context, NOT a.ctx: this stamp often runs during // Use a fresh background context, NOT a.ctx: this stamp often runs during
@@ -13565,6 +13678,10 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil { if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil {
err = a.qso.SetExtra(ctx, id, hamlogSentDateKey, date) err = a.qso.SetExtra(ctx, id, hamlogSentDateKey, date)
} }
case extsvc.ServiceHamQTH:
if err = a.qso.SetExtra(ctx, id, hamqthSentKey, "Y"); err == nil {
err = a.qso.SetExtra(ctx, id, hamqthSentDateKey, date)
}
} }
if err != nil { if err != nil {
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err) applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
+1
View File
@@ -25,6 +25,7 @@ var sensitiveSettingKeys = map[string]bool{
keyExtLoTWKeyPassword: true, keyExtLoTWKeyPassword: true,
keyExtLoTWWebPassword: true, keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true, keyExtHRDLogCode: true,
keyExtHamqthPassword: true,
keyExtEQSLPassword: true, keyExtEQSLPassword: true,
keyExtCloudlogAPIKey: true, keyExtCloudlogAPIKey: true,
// The web-publish config is one JSON blob and the FTP password lives inside // The web-publish config is one JSON blob and the FTP password lives inside
+20
View File
@@ -1,4 +1,24 @@
[ [
{
"version": "0.27.6",
"date": "",
"en": [
"Switching the settings database no longer shows “OpsLog is already running”: the automatic relaunch now waits for the closing instance to release its lock instead of racing it.",
"HamQTH upload: 8th external service — real-time QSO upload to the HamQTH online logbook with your callbook credentials (auto-upload on log, “Send to…” right-click, QSL Manager backlog upload, connection test).",
"Fixed: the right-click “Send to HAMLOG.online” was uploading the selection to QRZ.com with the QRZ key — it now goes to HAMLOG.online.",
"LoTW: TQSL no longer refuses non-US stations over MY_CNTY — the field is stripped before signing unless it is the US “XX,County” shape LoTW actually validates (a Canadian “ONTARIO,Kawartha” was rejecting the whole record). Exports also stop gluing a full state name onto the county.",
"DX Cluster: two new chase switches — Chase US counties and Chase new prefixes — and unchecking Chase new grids now also withdraws the NEW GRID badge. Each switch removes its badge from the spots AND its chip from the status filters, like Chase POTA always did.",
"Confirmations: a HamQTH row — sent only, defaulting to R (to upload), since HamQTH publishes no confirmations to receive. Setting such a default no longer disables the auto-upload it was meant to arm (it also affected HAMLOG.online)."
],
"fr": [
"Changer de base de réglages naffiche plus « OpsLog is already running » : la relance automatique attend désormais que linstance qui se ferme libère son verrou au lieu de la prendre de vitesse.",
"Upload HamQTH : 8e service externe — envoi des QSO en temps réel vers le logbook HamQTH avec vos identifiants du lookup (upload auto au log, « Envoyer vers… » au clic droit, rattrapage via le QSL Manager, test de connexion).",
"Corrigé : le clic droit « Envoyer vers HAMLOG.online » envoyait la sélection à QRZ.com avec la clé QRZ — elle part maintenant vers HAMLOG.online.",
"LoTW : TQSL ne refuse plus les stations hors US à cause de MY_CNTY — le champ est retiré avant signature sauf sil a la forme US « XX,County » que LoTW valide réellement (un « ONTARIO,Kawartha » canadien rejetait tout lenregistrement). Lexport cesse aussi de coller un nom d’état complet devant le comté.",
"DX Cluster : deux nouvelles cases — Chasser les comtés US et Chasser les nouveaux préfixes — et décocher Chasser les nouveaux locators retire désormais aussi le badge NEW GRID. Chaque case enlève son badge des spots ET sa puce des filtres de statut, comme Chase POTA le faisait déjà.",
"Confirmations : une ligne HamQTH — envoi seulement, à R (à envoyer) par défaut, HamQTH ne publiant aucune confirmation à recevoir. Définir un tel défaut ne désactive plus lupload automatique quil était censé armer (cela touchait aussi HAMLOG.online)."
]
},
{ {
"version": "0.27.5", "version": "0.27.5",
"date": "", "date": "",
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"testing"
"hamlog/internal/qso"
)
// A Confirmations default of "R" means "I intend to upload this", not "it has
// gone" — reading it as sent would silently disable the auto-upload the default
// was set to arm. Only a real stamp blocks.
func TestExtrasSaysSent(t *testing.T) {
pending := []string{"", " ", "R", "r", "N", "Q", "I"}
for _, v := range pending {
if extrasSaysSent(v) {
t.Errorf("extrasSaysSent(%q) = true, want false (still to upload)", v)
}
}
sent := []string{"Y", "y", "20260831"} // "Y" today, a date in older builds
for _, v := range sent {
if !extrasSaysSent(v) {
t.Errorf("extrasSaysSent(%q) = false, want true (already uploaded)", v)
}
}
}
// The HamQTH default lands on the extras key the uploader reads, and must leave
// the QSO eligible.
func TestHamQTHDefaultStaysUploadable(t *testing.T) {
q := &qso.QSO{Callsign: "F4BPO"}
applyQSLDefaultsTo(q, defaultQSLDefaults())
if got := q.Extras[hamqthSentKey]; got != "R" {
t.Fatalf("HamQTH sent extra = %q, want %q", got, "R")
}
if extrasSaysSent(q.Extras[hamqthSentKey]) {
t.Error("a freshly logged QSO reads as already uploaded to HamQTH")
}
}
+5 -2
View File
@@ -100,7 +100,7 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid'; import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { applySpotDisplay, chasePota, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay'; import { applySpotDisplay, chaseCounty, chaseGrid, chasePfx, chasePota, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App'; import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App';
import { applyMatrixColors } from '@/lib/matrixColors'; import { applyMatrixColors } from '@/lib/matrixColors';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
@@ -6261,7 +6261,10 @@ export default function App() {
// worked spots; the separate "Hide worked" checkbox drops them — they // worked spots; the separate "Hide worked" checkbox drops them — they
// are opposite controls, so don't use both at once. // are opposite controls, so don't use both at once.
{ k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' }, { k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
]).filter((s: any) => s.k !== 'new-pota' || chasePota()) ]).filter((s: any) => (s.k !== 'new-pota' || chasePota())
&& (s.k !== 'new-county' || chaseCounty())
&& (s.k !== 'new-pfx' || chasePfx())
&& (s.k !== 'new-grid' || chaseGrid()))
.map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k), .map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; }), s.style))} () => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; }), s.style))}
</div>, </div>,
@@ -42,6 +42,7 @@ const SERVICES = [
{ v: 'eqsl', label: 'eQSL.cc' }, { v: 'eqsl', label: 'eQSL.cc' },
{ v: 'lotw', label: 'LoTW' }, { v: 'lotw', label: 'LoTW' },
{ v: 'hamlog', label: 'HAMLOG.online' }, { v: 'hamlog', label: 'HAMLOG.online' },
{ v: 'hamqth', label: 'HamQTH' },
{ v: 'pota', label: 'POTA hunter log' }, { v: 'pota', label: 'POTA hunter log' },
{ v: 'paper', label: 'Paper QSL' }, { v: 'paper', label: 'Paper QSL' },
]; ];
@@ -35,6 +35,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
{ service: 'eqsl', name: 'eQSL.cc' }, { service: 'eqsl', name: 'eQSL.cc' },
{ service: 'lotw', name: 'LoTW' }, { service: 'lotw', name: 'LoTW' },
{ service: 'hamlog', name: 'HAMLOG.online' }, { service: 'hamlog', name: 'HAMLOG.online' },
{ service: 'hamqth', name: 'HamQTH' },
]; ];
// Lightweight right-click menu for the QSO grids. AG Grid's native context // Lightweight right-click menu for the QSO grids. AG Grid's native context
+90 -7
View File
@@ -45,7 +45,7 @@ import {
SetCIVTrace, SetCIVTrace,
CIVTraceEnabled, CIVTraceEnabled,
WinkeyerTraceEnabled, WinkeyerTraceEnabled,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload, GetExternalServices, SaveExternalServices, TestHamQTHUpload, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus, DownloadLoTWUsers, GetLoTWUsersStatus,
@@ -1825,6 +1825,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
clublog_status: string; clublog_confirmed: string; hrdlog_status: string; qrzcom_status: string; clublog_status: string; clublog_confirmed: string; hrdlog_status: string; qrzcom_status: string;
qrzcom_confirmed: string; qrzcom_confirmed: string;
hamlog_status: string; hamlog_confirmed: string; hamlog_status: string; hamlog_confirmed: string;
hamqth_status: string;
}; };
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({ const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
qsl_sent: '', qsl_rcvd: '', qsl_sent: '', qsl_rcvd: '',
@@ -1832,7 +1833,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
eqsl_sent: '', eqsl_rcvd: '', eqsl_sent: '', eqsl_rcvd: '',
clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '', clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '',
qrzcom_confirmed: '', qrzcom_confirmed: '',
hamlog_status: '', hamlog_confirmed: '', hamlog_status: '', hamlog_confirmed: '', hamqth_status: '',
}); });
// External services (logbook upload). One block per service; only QRZ is // External services (logbook upload). One block per service; only QRZ is
@@ -1848,7 +1849,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net. // HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
on_air?: boolean; on_air?: boolean;
}; };
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; delete_remote?: boolean }; type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; hamqth: ExtServiceCfg; delete_remote?: boolean };
const emptyExtCfg = (): ExtServiceCfg => ({ const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '', api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
@@ -1856,7 +1857,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
auto_upload: false, upload_mode: 'immediate', on_air: false, auto_upload: false, upload_mode: 'immediate', on_air: false,
}); });
const [extSvc, setExtSvc] = useState<ExternalServices>({ const [extSvc, setExtSvc] = useState<ExternalServices>({
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), delete_remote: false, qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), hamqth: emptyExtCfg(), delete_remote: false,
}); });
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);
@@ -2025,6 +2026,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [cloudlogTesting, setCloudlogTesting] = useState(false); const [cloudlogTesting, setCloudlogTesting] = useState(false);
const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null); const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hamlogTesting, setHamlogTesting] = useState(false); const [hamlogTesting, setHamlogTesting] = useState(false);
const [hamqthTest, setHamqthTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hamqthTesting, setHamqthTesting] = useState(false);
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null); const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [eqslTesting, setEqslTesting] = useState(false); const [eqslTesting, setEqslTesting] = useState(false);
const [stationLocations, setStationLocations] = useState<string[]>([]); const [stationLocations, setStationLocations] = useState<string[]>([]);
@@ -2032,7 +2035,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// could not hold hooks at all; PanelHost lifted that restriction, and this // could not hold hooks at all; PanelHost lifted that restriction, and this
// stays put because moving it down would reset the tab on every reopen — // stays put because moving it down would reset the tab on every reopen —
// a choice now, not a workaround. // a choice now, not a workaround.
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'pota'>('qrz'); const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'hamqth' | 'pota'>('qrz');
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log). // POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
const [potaToken, setPotaToken] = useState(''); const [potaToken, setPotaToken] = useState('');
const [potaBusy, setPotaBusy] = useState(false); const [potaBusy, setPotaBusy] = useState(false);
@@ -2076,6 +2079,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
}; };
const [chaseGrids, setChaseGrids] = useState(false); const [chaseGrids, setChaseGrids] = useState(false);
const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0'); const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0');
const [chaseCountyOn, setChaseCountyOn] = useState(() => localStorage.getItem('opslog.chaseCounty') !== '0');
const [chasePfxOn, setChasePfxOn] = useState(() => localStorage.getItem('opslog.chasePfx') !== '0');
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0'); const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
const [chaseNew, setChaseNew] = useState(false); const [chaseNew, setChaseNew] = useState(false);
const [spotTTL, setSpotTTL] = useState(0); const [spotTTL, setSpotTTL] = useState(0);
@@ -2097,7 +2102,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ } try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ } try {
const g = await GetChaseNewGrids();
setChaseGrids(g);
// Mirror for the display layer (spotDisplay.chaseGrid) — the cluster
// gates NEW GRID synchronously from localStorage.
writeUiPref('opslog.chaseGrids', g ? '1' : '0');
} catch { /* defaults stand */ }
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ } try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ } try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ } try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
@@ -5466,9 +5477,19 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} /> onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
{t('clu.chaseSota')} {t('clu.chaseSota')}
</label> </label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseCountyHint')}>
<Checkbox checked={chaseCountyOn}
onCheckedChange={(c) => { const v = !!c; setChaseCountyOn(v); writeUiPref('opslog.chaseCounty', v ? '1' : '0'); }} />
{t('clu.chaseCounty')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePfxHint')}>
<Checkbox checked={chasePfxOn}
onCheckedChange={(c) => { const v = !!c; setChasePfxOn(v); writeUiPref('opslog.chasePfx', v ? '1' : '0'); }} />
{t('clu.chasePfx')}
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer"> <label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseGrids} className="mt-0.5" <Checkbox checked={chaseGrids} className="mt-0.5"
onCheckedChange={(c) => { setChaseGrids(!!c); SetChaseNewGrids(!!c).catch(() => {}); }} /> onCheckedChange={(c) => { setChaseGrids(!!c); writeUiPref('opslog.chaseGrids', c ? '1' : '0'); SetChaseNewGrids(!!c).catch(() => {}); }} />
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span> <span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
</label> </label>
{chaseGrids && ( {chaseGrids && (
@@ -5735,6 +5756,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)} {renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
</div> </div>
</div> </div>
{/* HamQTH — no received side: the site has no confirmation feed. */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">HamQTH</Label>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
{renderSelect('hamqth_status', FULL_OPTIONS)}
</div>
<div />
</div>
{/* HAMLOG.online */} {/* HAMLOG.online */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end"> <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label> <Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label>
@@ -5928,6 +5958,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{ k: 'lotw', label: 'LOTW', ready: true }, { k: 'lotw', label: 'LOTW', ready: true },
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true }, { k: 'cloudlog', label: 'CLOUDLOG', ready: true },
{ k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true }, { k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true },
{ k: 'hamqth', label: 'HAMQTH', ready: true },
{ k: 'pota', label: 'POTA', ready: true }, { k: 'pota', label: 'POTA', ready: true },
]; ];
@@ -5997,6 +6028,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
} }
} }
const hamqth = extSvc.hamqth ?? emptyExtCfg();
const setHamqth = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, hamqth: { ...(s.hamqth ?? emptyExtCfg()), ...patch } }));
async function testHamqth() {
setHamqthTesting(true);
setHamqthTest(null);
try {
const msg = await TestHamQTHUpload();
setHamqthTest({ ok: true, msg });
} catch (e: any) {
setHamqthTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
setHamqthTesting(false);
}
}
const hamlog = extSvc.hamlog ?? emptyExtCfg(); const hamlog = extSvc.hamlog ?? emptyExtCfg();
const setHamlog = (patch: Partial<ExtServiceCfg>) => const setHamlog = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } })); setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));
@@ -6376,6 +6422,43 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div> </div>
</div> </div>
</div> </div>
) : extSvcTab === 'hamqth' ? (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">{t('es.username')}</Label>
<Input value={hamqth.username} onChange={(e) => setHamqth({ username: e.target.value })} className="text-xs w-64" />
<Label className="text-sm">{t('es.password')}</Label>
<Input type="password" value={hamqth.password} onChange={(e) => setHamqth({ password: e.target.value })} className="text-xs w-64" />
<Label className="text-sm">{t('es.hamqthCall')}</Label>
<Input value={hamqth.callsign} onChange={(e) => setHamqth({ callsign: e.target.value.toUpperCase() })} className="text-xs w-40 font-mono" placeholder={t('es.optional')} />
</div>
<div className="text-[10px] text-muted-foreground -mt-1">{t('es.hamqthHint')}</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={hamqth.auto_upload} onCheckedChange={(c) => setHamqth({ auto_upload: !!c })} />
{t('es.autoUpload')}
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">{t('es.uploadTiming')}</Label>
<Select value={hamqth.upload_mode === 'delayed' ? 'delayed' : 'immediate'} onValueChange={(v) => setHamqth({ upload_mode: v })}>
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={testHamqth} disabled={hamqthTesting}>
<UploadCloud className="size-3.5" /> {hamqthTesting ? t('es.testing') : t('es.testConn')}
</Button>
{hamqthTest && (
<span className={cn('text-xs', hamqthTest.ok ? 'text-success' : 'text-danger')}>{hamqthTest.msg}</span>
)}
</div>
</div>
</div>
) : extSvcTab === 'cloudlog' ? ( ) : extSvcTab === 'cloudlog' ? (
<div className="space-y-4 max-w-2xl"> <div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center"> <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
+4 -4
View File
@@ -347,7 +347,7 @@ const en: Dict = {
'chg.mode': 'Chase', 'chg.sources': 'Confirmed by', 'chg.card': 'QSL card', 'dec.unconfTip': 'Worked but not confirmed — a QSL to chase', 'gsc.scope': 'Match a square by', 'gsc.hunt': 'Chase', 'gsc.huntNew': 'New — never worked', 'gsc.huntUnconf': 'New and unconfirmed', 'gsc.scope_band_digi': 'This band + any digital mode', 'gsc.scope_band_mode': 'This band + this exact mode', 'gsc.scope_band_ftx': 'This band + any FT mode (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Any band + any digital mode', 'gsc.scope_mix_mode': 'Any band + this exact mode', 'gsc.scope_mix_ftx': 'Any band + any FT mode (FT8/FT4/FT2)', 'gsc.hint': 'Decides when a square stops being NEW. Narrower means more squares to chase: per band and per exact mode is the most demanding, any band and any digital mode the least. Chasing unconfirmed as well keeps a square wanted until a QSL, LoTW or eQSL confirmation arrives — it is still missing from the award until then.', 'chg.mode': 'Chase', 'chg.sources': 'Confirmed by', 'chg.card': 'QSL card', 'dec.unconfTip': 'Worked but not confirmed — a QSL to chase', 'gsc.scope': 'Match a square by', 'gsc.hunt': 'Chase', 'gsc.huntNew': 'New — never worked', 'gsc.huntUnconf': 'New and unconfirmed', 'gsc.scope_band_digi': 'This band + any digital mode', 'gsc.scope_band_mode': 'This band + this exact mode', 'gsc.scope_band_ftx': 'This band + any FT mode (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Any band + any digital mode', 'gsc.scope_mix_mode': 'Any band + this exact mode', 'gsc.scope_mix_ftx': 'Any band + any FT mode (FT8/FT4/FT2)', 'gsc.hint': 'Decides when a square stops being NEW. Narrower means more squares to chase: per band and per exact mode is the most demanding, any band and any digital mode the least. Chasing unconfirmed as well keeps a square wanted until a QSL, LoTW or eQSL confirmation arrives — it is still missing from the award until then.',
'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed', 'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed',
'bo.nearKm': 'Count receivers within', 'bo.nearKmHint': 'A report proves YOUR path only if it was collected near you. Smaller is more local but leaves fewer receivers listening — too small and the watch has nothing to look at. 300 km borrows a whole region; 100 km suits 2 m, where a duct is narrow.', 'bo.nearKm': 'Count receivers within', 'bo.nearKmHint': 'A report proves YOUR path only if it was collected near you. Smaller is more local but leaves fewer receivers listening — too small and the watch has nothing to look at. 300 km borrows a whole region; 100 km suits 2 m, where a duct is narrow.',
'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.spotTtl': 'Spot lifetime', 'clu.spotTtlNever': 'Keep', 'clu.spotMax': 'Spots kept', 'clu.spotMaxHint': '', 'clu.spotTtlHint': 'minutes — 0 keeps them.', 'clu.chasePota': 'Chase POTA', 'clu.chasePotaHint': 'Off: no NEW POTA badge or filter, and the POTA column stays empty — a new-band + new-POTA spot reads NEW BAND alone.', 'clu.chaseSota': 'Chase SOTA', 'clu.chaseSotaHint': 'Off: the SOTA column stays empty.', 'clu.chaseGrids': 'Chase new grids', 'clu.chaseGridsHint': '(learns locators from your own WSJT-X decodes AND from PSK Reporter, and keeps them in their own database so the cluster shows them from the first second)', 'clu.chaseGridsStat': '{n} locators known — {p} waiting to be written', 'clu.workedSameSlot': 'Already worked only on the same slot', 'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.spotTtl': 'Spot lifetime', 'clu.spotTtlNever': 'Keep', 'clu.spotMax': 'Spots kept', 'clu.spotMaxHint': '', 'clu.spotTtlHint': 'minutes — 0 keeps them.', 'clu.chasePota': 'Chase POTA', 'clu.chasePotaHint': 'Off: no NEW POTA badge or filter, and the POTA column stays empty — a new-band + new-POTA spot reads NEW BAND alone.', 'clu.chaseSota': 'Chase SOTA', 'clu.chaseSotaHint': 'Off: the SOTA column stays empty.', 'clu.chaseCounty': 'Chase US counties', 'clu.chaseCountyHint': 'Off: no NEW COUNTY badge or filter in the cluster.', 'clu.chasePfx': 'Chase new prefixes', 'clu.chasePfxHint': 'Off: no NEW PFX badge or filter in the cluster.', 'clu.chaseGrids': 'Chase new grids', 'clu.chaseGridsHint': '(learns locators from your own WSJT-X decodes AND from PSK Reporter, and keeps them in their own database so the cluster shows them from the first second)', 'clu.chaseGridsStat': '{n} locators known — {p} waiting to be written', 'clu.workedSameSlot': 'Already worked only on the same slot',
'clu.macros': 'Command buttons', 'clu.macrosHint': 'A named button beside the cluster command box. Leave the command empty and the button is not shown.', 'clu.macros': 'Command buttons', 'clu.macrosHint': 'A named button beside the cluster command box. Leave the command empty and the button is not shown.',
'clu.macroLabel': 'Button', 'clu.macroCmd': 'Command', 'clu.macroLabel': 'Button', 'clu.macroCmd': 'Command',
'clu.workedSameSlotHint': '— a spot shows "worked" only if you worked that call on the SAME band and mode, not just anywhere.', 'clu.workedSameSlotHint': '— a spot shows "worked" only if you worked that call on the SAME band and mode, not just anywhere.',
@@ -389,7 +389,7 @@ const en: Dict = {
'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.", 'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.",
'cat.ubOk': 'Connected — the antenna responded with a status frame.', 'cat.ubOk': 'Connected — the antenna responded with a status frame.',
// External services (repeated labels) // External services (repeated labels)
'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (12 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.showPass': 'Show password', 'es.hidePass': 'Hide password', 'es.apiKey': 'API key', 'es.cloudlogUrl': 'Instance URL', 'es.cloudlogUrlPh': 'https://log.example.com', 'es.cloudlogStationId': 'Station ID', 'es.cloudlogKeyPh': 'read/write API key', 'es.hamlogHint': 'Your personal API key, created at', 'es.hamlogCheckKey': 'Check the key', 'es.hamlogKeyOk': 'Key is valid', 'es.hamlogKeyOkCall': 'Key is valid — account {call}', 'es.cloudlogHint': 'Cloudlog and Wavelog are self-hosted: give the address of YOUR instance (an IP works on a LAN). The API key is created under Account → API Keys and must be read/write; the station ID is the number of the station location the QSOs are filed under (Station Locations page). Duplicates are rejected by the server, so re-sending a QSO is harmless.', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign', 'es.hamqthCall': 'Logbook callsign', 'es.hamqthHint': 'Your HamQTH account login — the same as the callbook lookup. Leave the callsign empty unless the account holds several logbooks.', 'es.optional': 'optional', 'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (12 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.showPass': 'Show password', 'es.hidePass': 'Hide password', 'es.apiKey': 'API key', 'es.cloudlogUrl': 'Instance URL', 'es.cloudlogUrlPh': 'https://log.example.com', 'es.cloudlogStationId': 'Station ID', 'es.cloudlogKeyPh': 'read/write API key', 'es.hamlogHint': 'Your personal API key, created at', 'es.hamlogCheckKey': 'Check the key', 'es.hamlogKeyOk': 'Key is valid', 'es.hamlogKeyOkCall': 'Key is valid — account {call}', 'es.cloudlogHint': 'Cloudlog and Wavelog are self-hosted: give the address of YOUR instance (an IP works on a LAN). The API key is created under Account → API Keys and must be read/write; the station ID is the number of the station location the QSOs are filed under (Station Locations page). Duplicates are rejected by the server, so re-sending a QSO is harmless.', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
// External-services placeholders + HRDLOG / eQSL / LoTW / POTA tabs // External-services placeholders + HRDLOG / eQSL / LoTW / POTA tabs
'es.qrzApiPh': 'QRZ.com logbook API key (XXXX-XXXX-XXXX-XXXX)', 'es.forceCallPh': 'e.g. F4BPO — optional', 'es.callDefaultPh': "defaults to the active profile's callsign", 'es.qrzApiPh': 'QRZ.com logbook API key (XXXX-XXXX-XXXX-XXXX)', 'es.forceCallPh': 'e.g. F4BPO — optional', 'es.callDefaultPh': "defaults to the active profile's callsign",
'es.clubEmailPh': 'your Club Log account email', 'es.clubPwPh': 'Club Log account password', 'es.clubEmailPh': 'your Club Log account email', 'es.clubPwPh': 'Club Log account password',
@@ -863,7 +863,7 @@ const fr: Dict = {
'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que sil a été collecté près de chez toi. Plus petit est plus local, mais laisse moins de récepteurs à l’écoute — trop petit, la veille na plus rien à observer. 300 km emprunte les oreilles de toute une région ; 100 km convient au 2 m, où un conduit est étroit.', 'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que sil a été collecté près de chez toi. Plus petit est plus local, mais laisse moins de récepteurs à l’écoute — trop petit, la veille na plus rien à observer. 300 km emprunte les oreilles de toute une région ; 100 km convient au 2 m, où un conduit est étroit.',
'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot', 'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
'clu.macros': 'Boutons de commande', 'clu.macrosHint': 'Un bouton nommé à côté du champ de commande du cluster. Laisse la commande vide et le bouton nest pas affiché.', 'clu.macros': 'Boutons de commande', 'clu.macrosHint': 'Un bouton nommé à côté du champ de commande du cluster. Laisse la commande vide et le bouton nest pas affiché.',
'clu.macroLabel': 'Bouton', 'clu.macroCmd': 'Commande', 'clu.spotTtl': 'Durée de vie des spots', 'clu.spotTtlNever': 'Garder', 'clu.spotMax': 'Nombre de spots conservés', 'clu.spotMaxHint': '', 'clu.spotTtlHint': 'minutes — 0 les conserve.', 'clu.chasePota': 'Chasser le POTA', 'clu.chasePotaHint': "Décoché : plus de badge ni de filtre NOUVEAU POTA, et la colonne POTA reste vide — un spot nouvelle bande + nouveau POTA affiche seulement NOUVELLE BANDE.", 'clu.chaseSota': 'Chasser le SOTA', 'clu.chaseSotaHint': 'Décoché : la colonne SOTA reste vide.', 'clu.chaseGrids': 'Chasser les nouveaux locators', 'clu.chaseGridsHint': '(apprend les locators depuis TES propres décodages WSJT-X ET depuis PSK Reporter, et les garde dans leur propre base pour que le cluster les affiche dès la première seconde)', 'clu.chaseGridsStat': '{n} locators connus — {p} en attente d’écriture', 'clu.macroLabel': 'Bouton', 'clu.macroCmd': 'Commande', 'clu.spotTtl': 'Durée de vie des spots', 'clu.spotTtlNever': 'Garder', 'clu.spotMax': 'Nombre de spots conservés', 'clu.spotMaxHint': '', 'clu.spotTtlHint': 'minutes — 0 les conserve.', 'clu.chasePota': 'Chasser le POTA', 'clu.chasePotaHint': "Décoché : plus de badge ni de filtre NOUVEAU POTA, et la colonne POTA reste vide — un spot nouvelle bande + nouveau POTA affiche seulement NOUVELLE BANDE.", 'clu.chaseSota': 'Chasser le SOTA', 'clu.chaseSotaHint': 'Décoché : la colonne SOTA reste vide.', 'clu.chaseCounty': 'Chasser les comtés US', 'clu.chaseCountyHint': 'Décoché : plus de badge ni de filtre NOUVEAU COMTÉ dans le cluster.', 'clu.chasePfx': 'Chasser les nouveaux préfixes', 'clu.chasePfxHint': 'Décoché : plus de badge ni de filtre NOUVEAU PFX dans le cluster.', 'clu.chaseGrids': 'Chasser les nouveaux locators', 'clu.chaseGridsHint': '(apprend les locators depuis TES propres décodages WSJT-X ET depuis PSK Reporter, et les garde dans leur propre base pour que le cluster les affiche dès la première seconde)', 'clu.chaseGridsStat': '{n} locators connus — {p} en attente d’écriture',
'clu.workedSameSlotHint': "— un spot n'est « contacté » que si cet indicatif a été fait sur la MÊME bande et le même mode, pas n'importe où.", 'clu.workedSameSlotHint': "— un spot n'est « contacté » que si cet indicatif a été fait sur la MÊME bande et le même mode, pas n'importe où.",
@@ -900,7 +900,7 @@ const fr: Dict = {
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).", 'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.", 'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
'cat.ubOk': "Connecté — l'antenne a répondu avec une trame de statut.", 'cat.ubOk': "Connecté — l'antenne a répondu avec une trame de statut.",
'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (12 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.showPass': 'Afficher le mot de passe', 'es.hidePass': 'Masquer le mot de passe', 'es.apiKey': 'Clé API', 'es.cloudlogUrl': "Adresse de l'instance", 'es.cloudlogUrlPh': 'https://log.exemple.fr', 'es.cloudlogStationId': 'ID de station', 'es.cloudlogKeyPh': 'clé API lecture/écriture', 'es.hamlogHint': 'Ta clé API personnelle, à créer sur', 'es.hamlogCheckKey': 'Vérifier la clé', 'es.hamlogKeyOk': 'Clé valide', 'es.hamlogKeyOkCall': 'Clé valide — compte {call}', 'es.cloudlogHint': "Cloudlog et Wavelog sont auto-hébergés : indiquez l'adresse de VOTRE instance (une IP convient en réseau local). La clé API se crée dans Compte → API Keys et doit être en lecture/écriture ; l'ID de station est le numéro de l'emplacement sous lequel les QSO sont classés (page Station Locations). Les doublons sont refusés par le serveur, renvoyer un QSO est donc sans risque.", 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet', 'es.hamqthCall': 'Indicatif du log', 'es.hamqthHint': 'Vos identifiants HamQTH — les mêmes que pour le lookup. Laissez lindicatif vide sauf si le compte héberge plusieurs logs.', 'es.optional': 'optionnel', 'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (12 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.showPass': 'Afficher le mot de passe', 'es.hidePass': 'Masquer le mot de passe', 'es.apiKey': 'Clé API', 'es.cloudlogUrl': "Adresse de l'instance", 'es.cloudlogUrlPh': 'https://log.exemple.fr', 'es.cloudlogStationId': 'ID de station', 'es.cloudlogKeyPh': 'clé API lecture/écriture', 'es.hamlogHint': 'Ta clé API personnelle, à créer sur', 'es.hamlogCheckKey': 'Vérifier la clé', 'es.hamlogKeyOk': 'Clé valide', 'es.hamlogKeyOkCall': 'Clé valide — compte {call}', 'es.cloudlogHint': "Cloudlog et Wavelog sont auto-hébergés : indiquez l'adresse de VOTRE instance (une IP convient en réseau local). La clé API se crée dans Compte → API Keys et doit être en lecture/écriture ; l'ID de station est le numéro de l'emplacement sous lequel les QSO sont classés (page Station Locations). Les doublons sont refusés par le serveur, renvoyer un QSO est donc sans risque.", 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
// Placeholders services externes + onglets HRDLOG / eQSL / LoTW / POTA // Placeholders services externes + onglets HRDLOG / eQSL / LoTW / POTA
'es.qrzApiPh': 'Clé API du logbook QRZ.com (XXXX-XXXX-XXXX-XXXX)', 'es.forceCallPh': 'p. ex. F4BPO — optionnel', 'es.callDefaultPh': "par défaut : l'indicatif du profil actif", 'es.qrzApiPh': 'Clé API du logbook QRZ.com (XXXX-XXXX-XXXX-XXXX)', 'es.forceCallPh': 'p. ex. F4BPO — optionnel', 'es.callDefaultPh': "par défaut : l'indicatif du profil actif",
'es.clubEmailPh': 'e-mail de ton compte Club Log', 'es.clubPwPh': 'mot de passe du compte Club Log', 'es.clubEmailPh': 'e-mail de ton compte Club Log', 'es.clubPwPh': 'mot de passe du compte Club Log',
+27 -2
View File
@@ -20,6 +20,7 @@ export type SpotDisplayOptions = {
// keep being computed; only the telling stops, so ticking the box back on // keep being computed; only the telling stops, so ticking the box back on
// needs no rescan. // needs no rescan.
chasePota: boolean; chaseSota: boolean; chasePota: boolean; chaseSota: boolean;
chaseCounty: boolean; chasePfx: boolean; chaseGrid: boolean;
}; };
// chasePota/chaseSota read the switches directly — for the places that show a // chasePota/chaseSota read the switches directly — for the places that show a
@@ -30,6 +31,18 @@ export function chasePota(): boolean {
export function chaseSota(): boolean { export function chaseSota(): boolean {
try { return localStorage.getItem('opslog.chaseSota') !== '0'; } catch { return true; } try { return localStorage.getItem('opslog.chaseSota') !== '0'; } catch { return true; }
} }
export function chaseCounty(): boolean {
try { return localStorage.getItem('opslog.chaseCounty') !== '0'; } catch { return true; }
}
export function chasePfx(): boolean {
try { return localStorage.getItem('opslog.chasePfx') !== '0'; } catch { return true; }
}
// chaseGrid mirrors the backend "chase new grids" setting (Settings writes the
// mirror on load and on toggle) so the display layer can gate NEW GRID without
// an async round-trip per row.
export function chaseGrid(): boolean {
try { return localStorage.getItem('opslog.chaseGrids') !== '0'; } catch { return true; }
}
// Both options are withdrawn from the filter panel for now. The machinery below // Both options are withdrawn from the filter panel for now. The machinery below
// is deliberately kept whole — it is correct and hard-won — so putting the two // is deliberately kept whole — it is correct and hard-won — so putting the two
@@ -44,16 +57,17 @@ export function readSpotDisplayOptions(): SpotDisplayOptions {
// The EXPOSED flag only withdraws the two original switches; the chase // The EXPOSED flag only withdraws the two original switches; the chase
// switches are live regardless. // switches are live regardless.
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) { if (!SPOT_DISPLAY_OPTIONS_EXPOSED) {
return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota() }; return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota(), chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid() };
} }
try { try {
return { return {
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1', muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1', slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
chasePota: chasePota(), chaseSota: chaseSota(), chasePota: chasePota(), chaseSota: chaseSota(),
chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(),
}; };
} catch { } catch {
return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true }; return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true, chaseCounty: true, chasePfx: true, chaseGrid: true };
} }
} }
@@ -98,6 +112,17 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
if (!o.chasePota && e.new_pota) { if (!o.chasePota && e.new_pota) {
e = { ...e, new_pota: false } as NonNullable<T>; e = { ...e, new_pota: false } as NonNullable<T>;
} }
// Same withdrawal for the other extra markers: the facts keep being
// computed, only the telling stops — re-ticking a box needs no rescan.
if (!o.chaseCounty && e.new_county) {
e = { ...e, new_county: false } as NonNullable<T>;
}
if (!o.chasePfx && e.new_pfx) {
e = { ...e, new_pfx: false } as NonNullable<T>;
}
if (!o.chaseGrid && e.new_grid) {
e = { ...e, new_grid: false } as NonNullable<T>;
}
return e; return e;
} }
+2
View File
@@ -1334,6 +1334,8 @@ export function TestEmail(arg1:string):Promise<void>;
export function TestHRDLogUpload():Promise<string>; export function TestHRDLogUpload():Promise<string>;
export function TestHamQTHUpload():Promise<string>;
export function TestLoTWUpload():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>;
+4
View File
@@ -2606,6 +2606,10 @@ export function TestHRDLogUpload() {
return window['go']['main']['App']['TestHRDLogUpload'](); return window['go']['main']['App']['TestHRDLogUpload']();
} }
export function TestHamQTHUpload() {
return window['go']['main']['App']['TestHamQTHUpload']();
}
export function TestLoTWUpload() { export function TestLoTWUpload() {
return window['go']['main']['App']['TestLoTWUpload'](); return window['go']['main']['App']['TestLoTWUpload']();
} }
+4
View File
@@ -1522,6 +1522,7 @@ export namespace extsvc {
eqsl: ServiceConfig; eqsl: ServiceConfig;
cloudlog: ServiceConfig; cloudlog: ServiceConfig;
hamlog: ServiceConfig; hamlog: ServiceConfig;
hamqth: ServiceConfig;
delete_remote: boolean; delete_remote: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -1537,6 +1538,7 @@ export namespace extsvc {
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig); this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig); this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig); this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
this.hamqth = this.convertValues(source["hamqth"], ServiceConfig);
this.delete_remote = source["delete_remote"]; this.delete_remote = source["delete_remote"];
} }
@@ -3300,6 +3302,7 @@ export namespace main {
qrzcom_confirmed: string; qrzcom_confirmed: string;
hamlog_status: string; hamlog_status: string;
hamlog_confirmed: string; hamlog_confirmed: string;
hamqth_status: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSLDefaults(source); return new QSLDefaults(source);
@@ -3320,6 +3323,7 @@ export namespace main {
this.qrzcom_confirmed = source["qrzcom_confirmed"]; this.qrzcom_confirmed = source["qrzcom_confirmed"];
this.hamlog_status = source["hamlog_status"]; this.hamlog_status = source["hamlog_status"];
this.hamlog_confirmed = source["hamlog_confirmed"]; this.hamlog_confirmed = source["hamlog_confirmed"];
this.hamqth_status = source["hamqth_status"];
} }
} }
export class QSLEmailTemplates { export class QSLEmailTemplates {
+6
View File
@@ -443,5 +443,11 @@ func adifCounty(state, county string) string {
if c == "" || s == "" || strings.Contains(c, ",") { if c == "" || s == "" || strings.Contains(c, ",") {
return c return c
} }
// The "STATE,County" join is the ADIF secondary-subdivision format, and that
// enumeration is a US thing — a two-letter state code. Prefixing a Canadian
// "ONTARIO" produced "ONTARIO,Kawartha", which is valid nowhere.
if len(s) != 2 {
return c
}
return strings.ToUpper(s) + "," + c return strings.ToUpper(s) + "," + c
} }
+4
View File
@@ -38,6 +38,9 @@ const (
ServiceCloudlog Service = "cloudlog" ServiceCloudlog Service = "cloudlog"
// ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO. // ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO.
ServiceHamlog Service = "hamlog" ServiceHamlog Service = "hamlog"
// ServiceHamQTH is the HamQTH online logbook — the callbook credentials,
// one ADIF record per QSO.
ServiceHamQTH Service = "hamqth"
) )
// UploadMode selects when an auto-upload fires after a QSO is saved. // UploadMode selects when an auto-upload fires after a QSO is saved.
@@ -133,6 +136,7 @@ type ExternalServices struct {
EQSL ServiceConfig `json:"eqsl"` EQSL ServiceConfig `json:"eqsl"`
Cloudlog ServiceConfig `json:"cloudlog"` Cloudlog ServiceConfig `json:"cloudlog"`
Hamlog ServiceConfig `json:"hamlog"` Hamlog ServiceConfig `json:"hamlog"`
HamQTH ServiceConfig `json:"hamqth"`
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when // DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
// it is deleted locally. Off unless the operator turns it on: neither // it is deleted locally. Off unless the operator turns it on: neither
+129
View File
@@ -0,0 +1,129 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// HamQTH real-time QSO upload.
//
// One POST per QSO to qso_realtime.php with the account username/password —
// the same credentials the HamQTH callbook lookup uses. The answer is the
// HTTP status code, not a body format: 200 saved, 400 rejected (bad band,
// duplicate…, reason in the body), 403 wrong credentials, 500 server error.
const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php"
// hamqthLoginURL is the callbook session login — the one authenticated HamQTH
// endpoint that cannot change anything in the log, which is what the settings
// Test button must call.
const hamqthLoginURL = "https://www.hamqth.com/xml.php"
// UploadHamQTH pushes one ADIF record to the HamQTH online logbook.
func UploadHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
return uploadHamQTHTo(ctx, client, hamqthUploadURL, cfg, adifRecord)
}
func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
user := strings.TrimSpace(cfg.Username)
switch {
case user == "":
return UploadResult{}, fmt.Errorf("hamqth: username not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("hamqth: password not set")
}
rec := strings.TrimSpace(adifRecord)
if rec == "" {
return UploadResult{}, fmt.Errorf("hamqth: empty ADIF record")
}
form := url.Values{}
form.Set("u", user)
form.Set("p", cfg.Password)
// c: the logbook callsign when the account holds several; empty means the
// account's own call, which is the common case.
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
form.Set("c", c)
}
form.Set("adif", rec)
form.Set("prg", "OpsLog")
form.Set("cmd", "insert")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return UploadResult{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
msg := strings.TrimSpace(string(body))
switch resp.StatusCode {
case http.StatusOK:
return UploadResult{OK: true}, nil
case http.StatusBadRequest:
// "Rejected" covers duplicates too. A duplicate is a SUCCESS for our
// purposes — the QSO is in the logbook, retrying it forever isn't —
// same treatment HRDLog's <insert>0 gets.
if strings.Contains(strings.ToLower(msg), "dupl") {
return UploadResult{OK: true, Ignored: true, Message: "already in logbook"}, nil
}
if msg == "" {
msg = "QSO rejected"
}
return UploadResult{OK: false, Message: msg}, nil
case http.StatusForbidden:
return UploadResult{}, fmt.Errorf("hamqth: wrong username or password")
default:
if msg != "" && len(msg) < 200 {
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
}
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
}
}
// TestHamQTH verifies the credentials against the callbook session login —
// authenticated, and unable to touch the log.
func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
user := strings.TrimSpace(cfg.Username)
if user == "" || cfg.Password == "" {
return "", fmt.Errorf("hamqth: set the username and password first")
}
q := url.Values{}
q.Set("u", user)
q.Set("p", cfg.Password)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hamqthLoginURL+"?"+q.Encode(), nil)
if err != nil {
return "", err
}
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
s := string(body)
if strings.Contains(s, "<session_id>") {
return fmt.Sprintf("Connected to HamQTH as %s.", user), nil
}
if i := strings.Index(s, "<error>"); i >= 0 {
e := s[i+len("<error>"):]
if j := strings.Index(e, "</error>"); j >= 0 {
return "", fmt.Errorf("hamqth: %s", strings.TrimSpace(e[:j]))
}
}
return "", fmt.Errorf("hamqth: unexpected answer — check the username and password")
}
+31
View File
@@ -11,6 +11,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -337,7 +339,36 @@ func fileExists(p string) bool {
// they were already uploaded OR outside the callsign certificate's date range. // they were already uploaded OR outside the callsign certificate's date range.
// Reporting either as success is how a contact came to be stamped "uploaded" // Reporting either as success is how a contact came to be stamped "uploaded"
// while LoTW had never seen it. // while LoTW had never seen it.
// scrubMyCnty removes MY_CNTY fields TQSL would refuse. LoTW's secondary
// subdivisions are the US county enumeration — "XX,County" with a two-letter
// state — and TQSL rejects the whole record over anything else, so a Canadian
// station's "ONTARIO,Kawartha" (or a bare county) must simply not be sent.
// MY_STATE and MY_GRIDSQUARE already locate the station for LoTW.
var myCntyRe = regexp.MustCompile(`(?i)<MY_CNTY:([0-9]+)(?::[A-Za-z])?>`)
func scrubMyCnty(adif string) string {
for {
loc := myCntyRe.FindStringSubmatchIndex(adif)
if loc == nil {
return adif
}
n, _ := strconv.Atoi(adif[loc[2]:loc[3]])
end := loc[1] + n
if end > len(adif) {
end = len(adif)
}
val := adif[loc[1]:end]
if len(val) > 3 && val[2] == ',' {
// "XX,..." — the US shape TQSL accepts; leave it for the county hunters.
rest := scrubMyCnty(adif[end:])
return adif[:end] + rest
}
adif = adif[:loc[0]] + strings.TrimLeft(adif[end:], " ")
}
}
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) { func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
adifRecord = scrubMyCnty(adifRecord)
tqsl := strings.TrimSpace(cfg.TQSLPath) tqsl := strings.TrimSpace(cfg.TQSLPath)
loc := strings.TrimSpace(cfg.StationLocation) loc := strings.TrimSpace(cfg.StationLocation)
switch { switch {
+23
View File
@@ -0,0 +1,23 @@
package extsvc
import "testing"
// TQSL refuses whole records over a MY_CNTY it cannot validate, and its
// validation is the US "XX,County" enumeration — so anything else must be
// stripped before signing, and the US shape must survive untouched.
func TestScrubMyCnty(t *testing.T) {
cases := []struct{ in, want string }{
{"<CALL:5>F4BPO<MY_CNTY:16>ONTARIO,Kawartha<MY_STATE:7>ONTARIO<EOR>",
"<CALL:5>F4BPO<MY_STATE:7>ONTARIO<EOR>"},
{"<MY_CNTY:8>Kawartha<EOR>", "<EOR>"},
{"<MY_CNTY:9>NY,Monroe<EOR>", "<MY_CNTY:9>NY,Monroe<EOR>"},
{"<CALL:4>K1AB<EOR>", "<CALL:4>K1AB<EOR>"},
{"<MY_CNTY:8>Kawartha<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>",
"<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>"},
}
for _, c := range cases {
if got := scrubMyCnty(c.in); got != c.want {
t.Errorf("scrubMyCnty(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+25 -2
View File
@@ -140,6 +140,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
cfg.EQSL = cfg.EQSL.normalised() cfg.EQSL = cfg.EQSL.normalised()
cfg.Cloudlog = cfg.Cloudlog.normalised() cfg.Cloudlog = cfg.Cloudlog.normalised()
cfg.Hamlog = cfg.Hamlog.normalised() cfg.Hamlog = cfg.Hamlog.normalised()
cfg.HamQTH = cfg.HamQTH.normalised()
m.cfg = cfg m.cfg = cfg
// Summary of what is armed, written at startup and on every settings save. // Summary of what is armed, written at startup and on every settings save.
@@ -153,7 +154,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
}{ }{
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW}, {"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog}, {"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
{"hamlog", cfg.Hamlog}, {"hamlog", cfg.Hamlog}, {"hamqth", cfg.HamQTH},
} { } {
if s.cfg.AutoUpload { if s.cfg.AutoUpload {
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode)) on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
@@ -237,6 +238,14 @@ func (m *Manager) OnQSOLogged(id int64) {
m.route(ServiceHamlog, id, h) m.route(ServiceHamlog, id, h)
} }
} }
// HamQTH — the callbook credentials double as the logbook login.
if h := cfg.HamQTH; h.AutoUpload {
if h.Username == "" || h.Password == "" {
m.logf("extsvc: hamqth auto-upload is ON but the username/password is not set (QSO %d not sent)", id)
} else {
m.route(ServiceHamQTH, id, h)
}
}
} }
// route sends a logged QSO down the configured timing path: queue it for the // route sends a logged QSO down the configured timing path: queue it for the
@@ -290,6 +299,9 @@ func (m *Manager) onCloseServices() []Service {
if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" { if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" {
out = append(out, ServiceHamlog) out = append(out, ServiceHamlog)
} }
if h := cfg.HamQTH; h.AutoUpload && h.UploadMode == ModeOnClose && h.Username != "" && h.Password != "" {
out = append(out, ServiceHamQTH)
}
return out return out
} }
@@ -338,6 +350,8 @@ func (m *Manager) FlushOnClose() int {
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog) uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
case ServiceHamlog: case ServiceHamlog:
uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog) uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog)
case ServiceHamQTH:
uploaded += m.flushOneByOne(svc, ids, cfg.HamQTH)
} }
} }
return uploaded return uploaded
@@ -577,7 +591,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
switch svc { switch svc {
case ServiceQRZ, ServiceLoTW: case ServiceQRZ, ServiceLoTW:
owner = cfg.ForceStationCallsign owner = cfg.ForceStationCallsign
case ServiceClublog, ServiceHRDLog: case ServiceClublog, ServiceHRDLog, ServiceHamQTH:
owner = cfg.Callsign owner = cfg.Callsign
case ServiceEQSL: case ServiceEQSL:
owner = cfg.Username owner = cfg.Username
@@ -669,6 +683,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, false return false, false
} }
res, err = UploadHamlog(ctx, m.deps.Client, cfg, record) res, err = UploadHamlog(ctx, m.deps.Client, cfg, record)
case ServiceHamQTH:
// The c parameter names the logbook when the account holds several;
// the QSO keeps its own STATION_CALLSIGN in the ADIF.
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false, false
}
res, err = UploadHamQTH(ctx, m.deps.Client, cfg, record)
default: default:
return false, false return false, false
} }
+10 -6
View File
@@ -48,14 +48,14 @@ func hasFlag(args []string, flag string) bool {
} }
// acquireInstance grabs the single-instance mutex. On a normal launch it's a plain // acquireInstance grabs the single-instance mutex. On a normal launch it's a plain
// try (fail → another OpsLog is running, so exit). On a --post-update relaunch the // try (fail → another OpsLog is running, so exit). On a --post-update or
// previous instance may still be shutting down and holding the mutex, so retry for // --relaunch start the previous instance may still be shutting down and holding
// a few seconds until it frees. // the mutex, so retry for a few seconds until it frees.
func acquireInstance(postUpdate bool) bool { func acquireInstance(wait bool) bool {
if acquireSingleInstance() { if acquireSingleInstance() {
return true return true
} }
if !postUpdate { if !wait {
return false return false
} }
deadline := time.Now().Add(20 * time.Second) deadline := time.Now().Add(20 * time.Second)
@@ -90,7 +90,11 @@ func main() {
// to free instead of bailing out. Then clear the old exe it left behind. // to free instead of bailing out. Then clear the old exe it left behind.
bootLogLaunch() bootLogLaunch()
postUpdate := hasFlag(os.Args[1:], "--post-update") postUpdate := hasFlag(os.Args[1:], "--post-update")
if !acquireInstance(postUpdate) { // A self-relaunch (database switch) races its own parent: the new process
// regularly wins the start against the old one's teardown, and the operator
// got "OpsLog is already running" for following instructions. Same patience
// as post-update.
if !acquireInstance(postUpdate || hasFlag(os.Args[1:], "--relaunch")) {
// SAID, not merely done. This exit is correct — a second instance would // SAID, not merely done. This exit is correct — a second instance would
// fight the first over the rig — but it happened in total silence: no // fight the first over the rig — but it happened in total silence: no
// window, no data folder, no log, which is indistinguishable from a // window, no data folder, no log, which is indistinguishable from a