Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88f35e2c20 | ||
|
|
d2194da28e | ||
|
|
2f1d592497 | ||
|
|
2b6f1ba9d7 | ||
|
|
4a017f6290 | ||
|
|
7bab30aa71 | ||
|
|
77b95289e7 | ||
|
|
5b894b9dbc | ||
|
|
187ac9aa84 | ||
|
|
629bd8d84f | ||
|
|
7152d11007 | ||
|
|
a03d907128 | ||
|
|
91569e12f4 | ||
|
|
68f0d68980 |
@@ -38,6 +38,7 @@ import (
|
|||||||
"hamlog/internal/cwdecode"
|
"hamlog/internal/cwdecode"
|
||||||
"hamlog/internal/db"
|
"hamlog/internal/db"
|
||||||
"hamlog/internal/dxcc"
|
"hamlog/internal/dxcc"
|
||||||
|
"hamlog/internal/dxped"
|
||||||
"hamlog/internal/email"
|
"hamlog/internal/email"
|
||||||
"hamlog/internal/extsvc"
|
"hamlog/internal/extsvc"
|
||||||
"hamlog/internal/geo"
|
"hamlog/internal/geo"
|
||||||
@@ -361,6 +362,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 +403,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 +458,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
|
||||||
@@ -795,6 +806,9 @@ type App struct {
|
|||||||
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
||||||
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
|
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
|
||||||
scp *scp.Manager // Super Check Partial / N+1 callsign master list
|
scp *scp.Manager // Super Check Partial / N+1 callsign master list
|
||||||
|
// dxped reads the ADXO announcements and the DX-World feed for the
|
||||||
|
// DXpeditions tab. Built lazily: nothing fetches until the tab is opened.
|
||||||
|
dxped *dxped.Manager
|
||||||
|
|
||||||
// NET Control: persistent net definitions/rosters (global JSON) + the live
|
// NET Control: persistent net definitions/rosters (global JSON) + the live
|
||||||
// session (in-memory only — active stations currently in QSO).
|
// session (in-memory only — active stations currently in QSO).
|
||||||
@@ -2851,7 +2865,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 +10957,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 +10979,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 +10996,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 +11021,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 +11078,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 +11196,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 +11282,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 +11371,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 +11427,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 +11460,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 +11526,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 +11544,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 +11768,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 +11846,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 +13582,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 +13617,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 +13682,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)
|
||||||
|
|||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/dxped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── DXpeditions (ADXO announcements + DX-World news) ────────────────────
|
||||||
|
//
|
||||||
|
// What a logger can say that a news reader cannot: whether the announced
|
||||||
|
// operation is worth chasing. Every activation is judged against THIS log —
|
||||||
|
// the same verdict the cluster paints on a spot — so the list reads as "what I
|
||||||
|
// still need", not "what is on the air".
|
||||||
|
|
||||||
|
// DXpedition is one announced operation plus what it is worth here.
|
||||||
|
type DXpedition struct {
|
||||||
|
dxped.Activation
|
||||||
|
// Status is the strongest verdict across the announced callsigns, bands and
|
||||||
|
// modes: "new" (entity never worked) beats "new-band-mode", which beats
|
||||||
|
// "new-band", "new-mode", "new-slot", and finally "worked". Empty when the
|
||||||
|
// callsign resolves to no entity — a prefix ADXO knows and cty.dat does not.
|
||||||
|
Status string `json:"status_chase"`
|
||||||
|
// Unconfirmed marks a need that is only a missing QSL, so the badge can be
|
||||||
|
// drawn dimmed exactly as it is in the cluster and the decode list.
|
||||||
|
Unconfirmed bool `json:"unconfirmed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chaseRank orders the verdicts from "most worth chasing" down. The DXpedition
|
||||||
|
// list shows ONE badge, so the ranking is the whole decision.
|
||||||
|
var chaseRank = map[string]int{
|
||||||
|
"new": 6,
|
||||||
|
"new-band-mode": 5,
|
||||||
|
"new-band": 4,
|
||||||
|
"new-mode": 3,
|
||||||
|
"new-slot": 2,
|
||||||
|
"worked": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDXpeditions returns the announced operations, freshest feed permitting,
|
||||||
|
// each carrying its chase verdict.
|
||||||
|
func (a *App) GetDXpeditions() ([]DXpedition, error) {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
acts, err := a.dxped.Activations(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("dxped: adxo fetch: %v", err)
|
||||||
|
if len(acts) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Stale data with a logged error beats an empty tab.
|
||||||
|
}
|
||||||
|
out := make([]DXpedition, 0, len(acts))
|
||||||
|
for _, act := range acts {
|
||||||
|
out = append(out, DXpedition{Activation: act, Status: "", Unconfirmed: false})
|
||||||
|
}
|
||||||
|
a.judgeDXpeditions(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeDXpeditions fills in the chase verdict, in place.
|
||||||
|
//
|
||||||
|
// One ClusterSpotStatuses call for the whole list rather than one per row: the
|
||||||
|
// worked-index it builds is the expensive part (a full pass over the log), and
|
||||||
|
// asking it forty times to answer forty rows was the difference between a tab
|
||||||
|
// that opens and a tab that stalls a remote MySQL for a second.
|
||||||
|
func (a *App) judgeDXpeditions(list []DXpedition) {
|
||||||
|
if a.qso == nil || len(list) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type slot struct{ row int }
|
||||||
|
var queries []SpotQuery
|
||||||
|
var owners []slot
|
||||||
|
for i, d := range list {
|
||||||
|
calls := d.Calls
|
||||||
|
if len(calls) == 0 {
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(d.Callsign)); c != "" {
|
||||||
|
calls = []string{c}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, call := range calls {
|
||||||
|
// No announced band/mode: ask the entity-level question alone.
|
||||||
|
if len(d.Bands) == 0 && len(d.Modes) == 0 {
|
||||||
|
queries = append(queries, SpotQuery{Call: call})
|
||||||
|
owners = append(owners, slot{i})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bands := d.Bands
|
||||||
|
if len(bands) == 0 {
|
||||||
|
bands = []string{""}
|
||||||
|
}
|
||||||
|
modes := d.Modes
|
||||||
|
if len(modes) == 0 {
|
||||||
|
modes = []string{""}
|
||||||
|
}
|
||||||
|
for _, b := range bands {
|
||||||
|
for _, m := range modes {
|
||||||
|
queries = append(queries, SpotQuery{Call: call, Band: b, Mode: m})
|
||||||
|
owners = append(owners, slot{i})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(queries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := a.ClusterSpotStatuses(queries)
|
||||||
|
for i, r := range res {
|
||||||
|
if i >= len(owners) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
row := owners[i].row
|
||||||
|
if chaseRank[r.Status] > chaseRank[list[row].Status] {
|
||||||
|
list[row].Status = r.Status
|
||||||
|
list[row].Unconfirmed = r.UnconfStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDXWorldNews returns the DX-World headlines, with the callsigns mined out
|
||||||
|
// of each one so the reader can watch them.
|
||||||
|
//
|
||||||
|
// Deliberately NOT judged against the log. A headline names no band, so the
|
||||||
|
// only verdict available is entity-level, and a pane of "NEW DXCC" and
|
||||||
|
// "worked" badges turned out to say nothing an operator could act on — the
|
||||||
|
// same two words on every row is noise wearing the clothes of information.
|
||||||
|
// The announcements pane, which knows the bands and modes, is where a chase
|
||||||
|
// verdict is worth drawing.
|
||||||
|
func (a *App) GetDXWorldNews() ([]dxped.News, error) {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
news, err := a.dxped.News(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("dxped: dx-world fetch: %v", err)
|
||||||
|
if len(news) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return news, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshDXpeditions drops both caches so the next read goes to the network.
|
||||||
|
// Wired to the tab's refresh button: an operator who has just read of a landing
|
||||||
|
// on a cluster should not wait out the cache to see it here.
|
||||||
|
func (a *App) RefreshDXpeditions() {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
a.dxped.Invalidate()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+26
-2
@@ -55,7 +55,11 @@ func (a *App) WatchlistAdd(callsign string, contest bool) error {
|
|||||||
if a.watchlist == nil {
|
if a.watchlist == nil {
|
||||||
return fmt.Errorf("watchlist not initialized")
|
return fmt.Errorf("watchlist not initialized")
|
||||||
}
|
}
|
||||||
return a.watchlist.Add(callsign, contest)
|
if err := a.watchlist.Add(callsign, contest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.notifyWatchlist(callsign, true)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchlistRemove deletes an entry.
|
// WatchlistRemove deletes an entry.
|
||||||
@@ -63,7 +67,27 @@ func (a *App) WatchlistRemove(callsign string) error {
|
|||||||
if a.watchlist == nil {
|
if a.watchlist == nil {
|
||||||
return fmt.Errorf("watchlist not initialized")
|
return fmt.Errorf("watchlist not initialized")
|
||||||
}
|
}
|
||||||
return a.watchlist.Remove(callsign)
|
if err := a.watchlist.Remove(callsign); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.notifyWatchlist(callsign, false)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyWatchlist announces a membership change to the UI.
|
||||||
|
//
|
||||||
|
// Emitted from the BINDINGS rather than from the watchlist panel, because a
|
||||||
|
// call can now be added from three places (the panel, the cluster's menu, the
|
||||||
|
// DXpeditions tab) and an operator who added one from the cluster saw nothing
|
||||||
|
// at all — the confirmation lived inside a panel they were not looking at.
|
||||||
|
func (a *App) notifyWatchlist(callsign string, added bool) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "watchlist:changed", map[string]any{
|
||||||
|
"call": strings.ToUpper(strings.TrimSpace(callsign)),
|
||||||
|
"added": added,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
|
// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
|
||||||
|
|||||||
@@ -1,4 +1,38 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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).",
|
||||||
|
"New DXpeditions tab (Tools): the announced operations from NG3K’s ADXO next to the DX-World news feed. Every announcement is judged against YOUR log and carries one badge — NEW DXCC, NEW BAND, NEW SLOT… — with an “only what I need” filter, and one click adds its callsigns to the watchlist. The news headlines carry the same Watch button, over the callsigns mined out of their titles.",
|
||||||
|
"Watchlist: adding or removing a callsign now raises the same kind of notification as a new version, instead of a message inside the watchlist page — a call can be added from the cluster or the DXpeditions tab, where that message was never seen.",
|
||||||
|
"QSO editor, QSL Info: a HamQTH channel and its row in the status table — sent only, the received column showing a dash since the site publishes no confirmations.",
|
||||||
|
"QSO editor, QSL Info: the confirmation channels are listed paper QSL and LoTW first — the two that carry an ARRL award — then alphabetically, in both the picker and the status table.",
|
||||||
|
"Band map: a chevron in the footer folds the colour legend away and brings it back — four lines of a short screen, remembered between sessions.",
|
||||||
|
"Band map: ctrl+wheel no longer zooms it — that gesture is the window zoom everywhere else in OpsLog. The + and − buttons keep the zoom.",
|
||||||
|
"DX Cluster: the server pills drop the CONNECTED/DISCONNECTED word — the colour already said it — and clicking one now connects or disconnects that server on its own, without opening Settings. State, retries, address and last error moved into the tooltip."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Changer de base de réglages n’affiche plus « OpsLog is already running » : la relance automatique attend désormais que l’instance 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 s’il a la forme US « XX,County » que LoTW valide réellement (un « ONTARIO,Kawartha » canadien rejetait tout l’enregistrement). L’export 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 l’upload automatique qu’il était censé armer (cela touchait aussi HAMLOG.online).",
|
||||||
|
"Nouvel onglet DXpéditions (Outils) : les opérations annoncées par l’ADXO de NG3K à côté du fil d’actualités DX-World. Chaque annonce est jugée sur VOTRE log et porte un badge — NOUVEAU DXCC, NOUVELLE BANDE, NOUVEAU SLOT… — avec un filtre « seulement ce qu’il me manque », et un clic ajoute ses indicatifs à la watchlist. Les actualités portent le même bouton Surveiller, sur les indicatifs extraits de leurs titres.",
|
||||||
|
"Watchlist : ajouter ou retirer un indicatif déclenche désormais une notification du même type que celle des nouvelles versions, au lieu d’un message dans la page watchlist — un indicatif peut être ajouté depuis le cluster ou l’onglet DXpéditions, où ce message n’était jamais vu.",
|
||||||
|
"Éditeur de QSO, onglet QSL : un canal HamQTH et sa ligne dans le tableau des statuts — envoi seulement, la colonne reçu affichant un tiret puisque le site ne publie aucune confirmation.",
|
||||||
|
"Éditeur de QSO, onglet QSL : les canaux de confirmation sont classés QSL papier puis LoTW — les deux qui comptent pour un diplôme ARRL — puis par ordre alphabétique, dans le sélecteur comme dans le tableau.",
|
||||||
|
"Band map : un chevron dans le pied de page replie la légende des couleurs et la fait revenir — quatre lignes gagnées sur un petit écran, mémorisé d’une session à l’autre.",
|
||||||
|
"Band map : ctrl+molette ne zoome plus la carte — ce geste est le zoom de la fenêtre partout ailleurs dans OpsLog. Les boutons + et − gardent le zoom.",
|
||||||
|
"DX Cluster : les pastilles de serveur perdent le mot CONNECTED/DISCONNECTED — la couleur le disait déjà — et cliquer sur l’une connecte ou déconnecte ce serveur seul, sans passer par les réglages. État, tentatives, adresse et dernière erreur passent dans l’infobulle."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.5",
|
"version": "0.27.5",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
-10
@@ -1,7 +1,7 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
||||||
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
|
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
|
||||||
GetScpStatus, ScpLookup,
|
GetScpStatus, ScpLookup,
|
||||||
OpenExternalURL,
|
OpenExternalURL,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
ConnectAllClusters, DisconnectAllClusters, ConnectClusterServer, DisconnectClusterServer, GetClusterStatus, SendClusterCommand,
|
||||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||||
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
|
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
|
||||||
GetSolarData,
|
GetSolarData,
|
||||||
@@ -76,6 +76,7 @@ import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
|||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
import { FTMapPanel } from '@/components/FTMapPanel';
|
import { FTMapPanel } from '@/components/FTMapPanel';
|
||||||
|
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
|
||||||
import { FirstRunModal } from '@/components/FirstRunModal';
|
import { FirstRunModal } from '@/components/FirstRunModal';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
import { BandMap } from '@/components/BandMap';
|
import { BandMap } from '@/components/BandMap';
|
||||||
@@ -100,7 +101,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';
|
||||||
@@ -1315,6 +1316,17 @@ export default function App() {
|
|||||||
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
|
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
|
||||||
}
|
}
|
||||||
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
|
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
|
||||||
|
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
|
||||||
|
function openDxpedTab() {
|
||||||
|
setDxpedTabOpen(true);
|
||||||
|
writeUiPref('opslog.dxpedTab', '1');
|
||||||
|
setActiveTab('dxped');
|
||||||
|
}
|
||||||
|
function closeDxpedTab() {
|
||||||
|
setDxpedTabOpen(false);
|
||||||
|
writeUiPref('opslog.dxpedTab', '0');
|
||||||
|
setActiveTab((t) => (t === 'dxped' ? 'recent' : t));
|
||||||
|
}
|
||||||
function openFtmapTab() {
|
function openFtmapTab() {
|
||||||
setFtmapTabOpen(true);
|
setFtmapTabOpen(true);
|
||||||
writeUiPref('opslog.ftmapTab', '1');
|
writeUiPref('opslog.ftmapTab', '1');
|
||||||
@@ -2346,6 +2358,18 @@ export default function App() {
|
|||||||
}, [showSettings]);
|
}, [showSettings]);
|
||||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
||||||
|
// Watchlist membership changes announce themselves here rather than inside
|
||||||
|
// the watchlist panel: a call can be added from the cluster or the
|
||||||
|
// DXpeditions tab, and the old inline message lived on a page nobody was
|
||||||
|
// looking at.
|
||||||
|
const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
|
||||||
|
const wlNoticeTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => EventsOn('watchlist:changed', (e: any) => {
|
||||||
|
if (!e?.call) return;
|
||||||
|
setWlNotice({ call: String(e.call), added: !!e.added });
|
||||||
|
if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
|
||||||
|
wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
|
||||||
|
}), []);
|
||||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||||
// Fresh update check on demand (opening About), so it never shows a stale
|
// Fresh update check on demand (opening About), so it never shows a stale
|
||||||
// "you're up to date". Clears updateInfo when the latest check finds nothing.
|
// "you're up to date". Clears updateInfo when the latest check finds nothing.
|
||||||
@@ -5126,6 +5150,7 @@ export default function App() {
|
|||||||
]},
|
]},
|
||||||
{ name: 'tools', label: t('menu.tools'), items: [
|
{ name: 'tools', label: t('menu.tools'), items: [
|
||||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||||
|
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
|
||||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||||
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
||||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||||
@@ -5180,6 +5205,7 @@ export default function App() {
|
|||||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||||
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||||
|
case 'tools.dxped': openDxpedTab(); break;
|
||||||
case 'tools.decodes': openDecodesTab(); break;
|
case 'tools.decodes': openDecodesTab(); break;
|
||||||
case 'tools.ftmap': openFtmapTab(); break;
|
case 'tools.ftmap': openFtmapTab(); break;
|
||||||
case 'tools.grids': openGridsTab(); break;
|
case 'tools.grids': openGridsTab(); break;
|
||||||
@@ -6261,7 +6287,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>,
|
||||||
@@ -7073,6 +7102,26 @@ export default function App() {
|
|||||||
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
|
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{wlNotice && (
|
||||||
|
<div className={cn('fixed right-4 z-[150] w-72 rounded-lg border bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in',
|
||||||
|
wlNotice.added ? 'border-warning/40' : 'border-border',
|
||||||
|
// Stacked above the update card when both are up.
|
||||||
|
updateInfo ? 'bottom-44' : 'bottom-4')}>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Star className={cn('size-4 mt-0.5 shrink-0', wlNotice.added ? 'fill-current text-warning' : 'text-muted-foreground')} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{t(wlNotice.added ? 'wlnote.added' : 'wlnote.removed', { call: wlNotice.call })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('wlnote.hint')}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setWlNotice(null)}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground" aria-label="Close">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{updateInfo && (
|
{updateInfo && (
|
||||||
<div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
|
<div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -7813,6 +7862,21 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{dxpedTabOpen && (
|
||||||
|
<TabsTrigger value="dxped" className="gap-1.5">
|
||||||
|
{t('dxp.tab')}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="Close DXpeditions"
|
||||||
|
title="Close"
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||||
|
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); closeDxpedTab(); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{ftmapTabOpen && (
|
{ftmapTabOpen && (
|
||||||
<TabsTrigger value="ftmap" className="gap-1.5">
|
<TabsTrigger value="ftmap" className="gap-1.5">
|
||||||
{t('ftmap.tab')}
|
{t('ftmap.tab')}
|
||||||
@@ -8069,22 +8133,37 @@ export default function App() {
|
|||||||
const isMaster = clusterServers
|
const isMaster = clusterServers
|
||||||
.filter((x) => x.enabled)
|
.filter((x) => x.enabled)
|
||||||
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
|
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
|
||||||
|
// The colour already says the state, so the word beside it was
|
||||||
|
// saying it twice — and the pills are the only place a single
|
||||||
|
// cluster can be reached without opening Settings. Clicking one
|
||||||
|
// now drops or reopens that session; everything the word used
|
||||||
|
// to carry (state, retries, last error, address) moves into the
|
||||||
|
// tooltip, where it costs no width.
|
||||||
|
const up = s.state === 'connected';
|
||||||
|
const busy = s.state === 'connecting' || s.state === 'reconnecting';
|
||||||
return (
|
return (
|
||||||
<span
|
<button
|
||||||
key={s.server_id}
|
key={s.server_id}
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
if (up || busy) await DisconnectClusterServer(s.server_id);
|
||||||
|
else await ConnectClusterServer(s.server_id);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
await reloadClusterMeta();
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border',
|
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border transition-opacity hover:opacity-80',
|
||||||
s.state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
|
s.state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
|
||||||
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
|
busy ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
|
||||||
s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
|
s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
|
||||||
'bg-muted text-muted-foreground border-border',
|
'bg-muted text-muted-foreground border-border',
|
||||||
)}
|
)}
|
||||||
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
|
title={`${s.name} — ${s.state.toUpperCase()}${s.retries ? ` #${s.retries}` : ''} · ${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}\n${up || busy ? t('clu.pillDisconnect') : t('clu.pillConnect')}`}
|
||||||
>
|
>
|
||||||
{isMaster && <span className="text-warning" title="Master (commands go here)">★</span>}
|
{isMaster && <span className="text-warning" title="Master (commands go here)">★</span>}
|
||||||
{s.name}
|
{s.name}
|
||||||
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span>
|
</button>
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
@@ -8426,6 +8505,15 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{dxpedTabOpen && (
|
||||||
|
<TabsContent value="dxped" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
|
{activeTab === 'dxped' && (
|
||||||
|
<div className="h-full w-full min-h-0 p-1">
|
||||||
|
<DXpeditionsPanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
{ftmapTabOpen && (
|
{ftmapTabOpen && (
|
||||||
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
{activeTab === 'ftmap' && (
|
{activeTab === 'ftmap' && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
|
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
|
||||||
@@ -283,6 +283,17 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
const range = bandRange(band);
|
const range = bandRange(band);
|
||||||
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
|
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
|
||||||
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
||||||
|
// The legend is a reference, not a running display: once its colours are
|
||||||
|
// learnt it is four lines of a short screen spent saying nothing new. Folded
|
||||||
|
// away by a toggle, and the choice is remembered.
|
||||||
|
const [legendOpen, setLegendOpen] = useState(() => {
|
||||||
|
try { return localStorage.getItem('opslog.bmpLegend') !== '0'; } catch { return true; }
|
||||||
|
});
|
||||||
|
const toggleLegend = () => setLegendOpen((v) => {
|
||||||
|
const next = !v;
|
||||||
|
try { localStorage.setItem('opslog.bmpLegend', next ? '1' : '0'); } catch { /* private mode */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
// The docked map follows the rig, so a band change must bring up THAT band's
|
// The docked map follows the rig, so a band change must bring up THAT band's
|
||||||
// remembered zoom.
|
// remembered zoom.
|
||||||
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
||||||
@@ -460,19 +471,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||||
|
|
||||||
useEffect(() => {
|
// No ctrl+wheel zoom here any more: ctrl+wheel is the WINDOW zoom everywhere
|
||||||
const el = scrollerRef.current;
|
// else in OpsLog (View ▸ Zoom in/out), and one gesture that resizes the whole
|
||||||
if (!el) return;
|
// app over one panel and one band map over another is a gesture nobody can
|
||||||
const onWheel = (e: WheelEvent) => {
|
// trust. The + / − buttons keep the zoom, deliberately and visibly.
|
||||||
if (!range) return;
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
changeZoom(e.deltaY > 0 ? -1 : 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
|
||||||
return () => el.removeEventListener('wheel', onWheel);
|
|
||||||
}, [range]);
|
|
||||||
|
|
||||||
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||||||
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||||||
@@ -753,6 +755,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Colour legend — what each pill colour means. */}
|
{/* Colour legend — what each pill colour means. */}
|
||||||
|
{legendOpen && (
|
||||||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||||
@@ -769,9 +772,18 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
|
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
|
||||||
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
|
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
)}
|
||||||
{t('bmp.footerHint')}
|
<div className="px-3 py-1 flex items-center gap-2 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono shrink-0">
|
||||||
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
<span className="flex-1 text-center">
|
||||||
|
{t('bmp.footerHint')}
|
||||||
|
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={toggleLegend}
|
||||||
|
title={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
|
||||||
|
aria-label={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
|
||||||
|
className="shrink-0 inline-flex items-center gap-0.5 rounded px-1 py-px hover:bg-muted hover:text-foreground">
|
||||||
|
{legendOpen ? <ChevronDown className="size-3" /> : <ChevronUp className="size-3" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { RefreshCw, Star, ExternalLink } from 'lucide-react';
|
||||||
|
import { GetDXpeditions, GetDXWorldNews, RefreshDXpeditions, WatchlistEntries, WatchlistAdd } from '../../wailsjs/go/main/App';
|
||||||
|
import { BrowserOpenURL, EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// DXpeditions — the two feeds the DX world announces itself on, side by side.
|
||||||
|
//
|
||||||
|
// Left: NG3K's ADXO, the structured announcements, each judged against THIS log
|
||||||
|
// so the list reads as "what I still need" rather than "what is on". Right:
|
||||||
|
// DX-World's headlines, whose callsigns are mined out of the title so they can
|
||||||
|
// be watched with the same one click.
|
||||||
|
|
||||||
|
type DXped = {
|
||||||
|
dxcc: string; callsign: string; calls?: string[];
|
||||||
|
start_date: string; end_date: string;
|
||||||
|
bands?: string[]; modes?: string[];
|
||||||
|
qsl: string; operators: string; source: string; link: string;
|
||||||
|
status: string; // active | upcoming
|
||||||
|
status_chase: string; // new | new-band-mode | new-band | new-mode | new-slot | worked | ''
|
||||||
|
unconfirmed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type News = {
|
||||||
|
title: string; link: string; pub_date: string; excerpt: string;
|
||||||
|
creator: string; image_url: string; tag: string; calls?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// One badge per expedition, the strongest verdict winning — the same palette
|
||||||
|
// and the same words the cluster uses, so the two views teach one vocabulary.
|
||||||
|
const CHASE_BADGE: Record<string, { label: string; colour: string }> = {
|
||||||
|
'new': { label: 'clg2.newDxcc', colour: 'var(--danger)' },
|
||||||
|
'new-band-mode': { label: 'clg2.newBandMode', colour: 'var(--danger)' },
|
||||||
|
'new-band': { label: 'clg2.newBand', colour: 'var(--warning)' },
|
||||||
|
'new-mode': { label: 'clg2.newMode', colour: 'var(--caution)' },
|
||||||
|
'new-slot': { label: 'clg2.newSlot', colour: '#5AC8FA' },
|
||||||
|
'worked': { label: 'wl.worked', colour: 'var(--info)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DXpeditionsPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [peds, setPeds] = useState<DXped[]>([]);
|
||||||
|
const [news, setNews] = useState<News[]>([]);
|
||||||
|
const [watched, setWatched] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [neededOnly, setNeededOnly] = useState(() => localStorage.getItem('opslog.dxpedNeeded') === '1');
|
||||||
|
|
||||||
|
const loadWatchlist = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const e: any[] = await WatchlistEntries();
|
||||||
|
setWatched(new Set((e ?? []).map((x) => String(x.callsign ?? '').toUpperCase())));
|
||||||
|
} catch { /* the list simply shows every call as unwatched */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setErr('');
|
||||||
|
const [p, n] = await Promise.allSettled([GetDXpeditions(), GetDXWorldNews()]);
|
||||||
|
if (p.status === 'fulfilled') setPeds((p.value as any) ?? []);
|
||||||
|
else setErr(String((p.reason as any)?.message ?? p.reason));
|
||||||
|
if (n.status === 'fulfilled') setNews((n.value as any) ?? []);
|
||||||
|
setBusy(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); void loadWatchlist(); }, [load, loadWatchlist]);
|
||||||
|
useEffect(() => EventsOn('watchlist:changed', () => { void loadWatchlist(); }), [loadWatchlist]);
|
||||||
|
|
||||||
|
const refresh = async () => { await RefreshDXpeditions(); await load(); };
|
||||||
|
|
||||||
|
const addAll = async (calls: string[]) => {
|
||||||
|
for (const c of calls) {
|
||||||
|
if (!watched.has(c.toUpperCase())) {
|
||||||
|
try { await WatchlistAdd(c, false); } catch { /* reported by the notice */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadWatchlist();
|
||||||
|
};
|
||||||
|
|
||||||
|
const shown = useMemo(
|
||||||
|
() => (neededOnly ? peds.filter((p) => p.status_chase && p.status_chase !== 'worked') : peds),
|
||||||
|
[peds, neededOnly]);
|
||||||
|
|
||||||
|
// A row's calls: the mined ones, else whatever the announcement called it.
|
||||||
|
const callsOf = (p: DXped) => (p.calls?.length ? p.calls : p.callsign ? [p.callsign] : []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 gap-3">
|
||||||
|
{/* ── Announcements (ADXO) ── */}
|
||||||
|
<section className="flex flex-col min-h-0 flex-[3] rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-sm font-semibold">{t('dxp.announced')}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'NG3K ADXO' })}</span>
|
||||||
|
<label className="ml-auto flex items-center gap-1.5 text-[11px] cursor-pointer text-muted-foreground hover:text-foreground">
|
||||||
|
<input type="checkbox" checked={neededOnly}
|
||||||
|
onChange={(e) => { setNeededOnly(e.target.checked); localStorage.setItem('opslog.dxpedNeeded', e.target.checked ? '1' : '0'); }} />
|
||||||
|
{t('dxp.neededOnly')}
|
||||||
|
</label>
|
||||||
|
<Button variant="outline" size="sm" onClick={refresh} disabled={busy}>
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('dxp.refresh')}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{err && <div className="px-3 py-2 text-xs text-danger shrink-0">{err}</div>}
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{shown.length === 0 && !busy && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.none')}</p>
|
||||||
|
)}
|
||||||
|
{shown.map((p, i) => {
|
||||||
|
const calls = callsOf(p);
|
||||||
|
const badge = CHASE_BADGE[p.status_chase];
|
||||||
|
const allWatched = calls.length > 0 && calls.every((c) => watched.has(c.toUpperCase()));
|
||||||
|
return (
|
||||||
|
<article key={`${p.callsign}-${p.start_date}-${i}`}
|
||||||
|
className={cn('rounded-md border p-2 text-xs',
|
||||||
|
p.status === 'active' ? 'border-success/40 bg-success/5' : 'border-border bg-muted/20')}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-mono font-bold text-sm text-info">{p.callsign || '—'}</span>
|
||||||
|
<span className="font-medium">{p.dxcc}</span>
|
||||||
|
{p.status === 'active' && (
|
||||||
|
<span className="px-1 py-px rounded text-[10px] font-bold uppercase bg-success-muted text-success-muted-foreground">
|
||||||
|
{t('dxp.onAir')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{badge && (
|
||||||
|
<span className="px-1 py-px rounded text-[10px] font-bold uppercase tracking-wide border"
|
||||||
|
title={p.unconfirmed ? t('dec.unconfTip') : undefined}
|
||||||
|
style={p.unconfirmed
|
||||||
|
? { color: badge.colour, borderColor: badge.colour, borderStyle: 'dashed', opacity: 0.6 }
|
||||||
|
: { color: badge.colour, borderColor: badge.colour }}>
|
||||||
|
{t(badge.label)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-[11px] text-muted-foreground whitespace-nowrap">
|
||||||
|
{p.start_date} → {p.end_date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground">
|
||||||
|
{!!p.bands?.length && <span>{p.bands.join(' · ')}</span>}
|
||||||
|
{!!p.modes?.length && <span className="text-foreground/70">{p.modes.join(' ')}</span>}
|
||||||
|
{p.qsl && <span>QSL: {p.qsl}</span>}
|
||||||
|
{p.source && <span>· {p.source}</span>}
|
||||||
|
</div>
|
||||||
|
{p.operators && <p className="mt-0.5 text-[11px] text-muted-foreground truncate">{p.operators}</p>}
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Button variant={allWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
|
||||||
|
disabled={calls.length === 0 || allWatched}
|
||||||
|
onClick={() => addAll(calls)}
|
||||||
|
title={t('dxp.watchTip')}>
|
||||||
|
<Star className={cn('size-3', allWatched && 'fill-current text-warning')} />
|
||||||
|
{allWatched ? t('dxp.watched') : t('dxp.watch')}
|
||||||
|
</Button>
|
||||||
|
{p.link && (
|
||||||
|
<button type="button" onClick={() => BrowserOpenURL(p.link)}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
|
||||||
|
<ExternalLink className="size-3" /> {t('dxp.open')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── News (DX-World) ── */}
|
||||||
|
<section className="flex flex-col min-h-0 flex-[2] rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-sm font-semibold">{t('dxp.news')}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'DX-World' })}</span>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{news.length === 0 && !busy && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.noNews')}</p>
|
||||||
|
)}
|
||||||
|
{news.map((n, i) => {
|
||||||
|
const newsCalls = n.calls ?? [];
|
||||||
|
const newsAllWatched = newsCalls.length > 0 && newsCalls.every((c) => watched.has(c.toUpperCase()));
|
||||||
|
return (
|
||||||
|
<article key={`${n.link}-${i}`} className="rounded-md border border-border bg-muted/20 p-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{n.image_url && (
|
||||||
|
<img src={n.image_url} alt="" className="size-12 rounded object-cover shrink-0"
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{n.tag && (
|
||||||
|
<span className="px-1 py-px rounded text-[9px] font-bold uppercase bg-primary/15 text-primary">{n.tag}</span>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => n.link && BrowserOpenURL(n.link)}
|
||||||
|
className="text-xs font-medium text-left hover:underline">{n.title}</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-muted-foreground line-clamp-3">{n.excerpt}</p>
|
||||||
|
{/* The callsigns are shown, not clicked: watching is the same
|
||||||
|
one button as an announcement, so the gesture is learned
|
||||||
|
once for the whole tab. */}
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
|
||||||
|
{n.pub_date && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{new Date(n.pub_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(n.calls ?? []).map((c) => (
|
||||||
|
<span key={c} className={cn('font-mono text-[10px]',
|
||||||
|
watched.has(c.toUpperCase()) ? 'text-warning' : 'text-info')}>
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Button variant={newsAllWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
|
||||||
|
disabled={newsCalls.length === 0 || newsAllWatched}
|
||||||
|
onClick={() => addAll(newsCalls)}
|
||||||
|
title={t('dxp.watchTip')}>
|
||||||
|
<Star className={cn('size-3', newsAllWatched && 'fill-current text-warning')} />
|
||||||
|
{newsAllWatched ? t('dxp.watched') : t('dxp.watch')}
|
||||||
|
</Button>
|
||||||
|
{n.link && (
|
||||||
|
<button type="button" onClick={() => BrowserOpenURL(n.link)}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
|
||||||
|
<ExternalLink className="size-3" /> {t('dxp.open')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</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
|
||||||
|
|||||||
@@ -80,6 +80,19 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
|||||||
QSL: 'qedit.confQslPaper',
|
QSL: 'qedit.confQslPaper',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reading order for the channel list: the two that carry an ARRL award first —
|
||||||
|
// paper QSL and LoTW — then everything else alphabetically, so a channel is
|
||||||
|
// found by its name rather than by remembering the order it was added in.
|
||||||
|
const CONF_FIRST: Record<string, number> = { QSL: 0, LOTW: 1 };
|
||||||
|
function confOrder<T extends { key: string; label: string }>(rows: T[]): T[] {
|
||||||
|
return rows.slice().sort((a, b) => {
|
||||||
|
const ra = CONF_FIRST[a.key] ?? 2;
|
||||||
|
const rb = CONF_FIRST[b.key] ?? 2;
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
||||||
// columns and this channel is backed by ADIF extras — but it still belongs in
|
// columns and this channel is backed by ADIF extras — but it still belongs in
|
||||||
// the channel picker and the status table alongside the rest.
|
// the channel picker and the status table alongside the rest.
|
||||||
@@ -98,6 +111,15 @@ const HAMLOG_KEYS = {
|
|||||||
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// HamQTH — extras again (ADIF names no HamQTH field), and SENT only: the site
|
||||||
|
// publishes no confirmation feed, so a received column here would be a promise
|
||||||
|
// nothing can keep.
|
||||||
|
const HAMQTH_CONF = 'HAMQTH';
|
||||||
|
const HAMQTH_KEYS = {
|
||||||
|
sent: 'APP_OPSLOG_HAMQTH_SENT',
|
||||||
|
sentDate: 'APP_OPSLOG_HAMQTH_SENT_DATE',
|
||||||
|
};
|
||||||
|
|
||||||
// Colour-coded status cell for the confirmation grid.
|
// Colour-coded status cell for the confirmation grid.
|
||||||
function StatusCell({ value }: { value?: string }) {
|
function StatusCell({ value }: { value?: string }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -747,19 +769,34 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<Select value={confSel} onValueChange={setConfSel}>
|
<Select value={confSel} onValueChange={setConfSel}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}
|
{confOrder([
|
||||||
{/* Listed here but NOT in CONFIRMATIONS: that table maps
|
...CONFIRMATIONS.map((c) => ({ key: c.key, label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label })),
|
||||||
|
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog') },
|
||||||
|
{ key: HAMLOG_CONF, label: 'HAMLOG.online' },
|
||||||
|
{ key: HAMQTH_CONF, label: 'HamQTH' },
|
||||||
|
]).map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}
|
||||||
|
{/* The three above that are NOT in CONFIRMATIONS — the
|
||||||
QSO columns, and this channel lives in the ADIF
|
QSO columns, and this channel lives in the ADIF
|
||||||
extras. It gets its own editor below rather than the
|
OpsLog card, HAMLOG.online and HamQTH — are backed by
|
||||||
generic sent/received/date grid, which has no field
|
ADIF extras rather than QSO columns, and each gets its
|
||||||
to bind to. */}
|
own editor below instead of the generic
|
||||||
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
sent/received/date grid, which has no field to bind
|
||||||
<SelectItem value={HAMLOG_CONF}>HAMLOG.online</SelectItem>
|
to. */}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{confSel === HAMLOG_CONF ? (
|
{confSel === HAMQTH_CONF ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMQTH_KEYS.sent)} onChange={(v) => exPut(HAMQTH_KEYS.sent, v)} /></div>
|
||||||
|
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMQTH_KEYS.sentDate)} onChange={(v) => exPut(HAMQTH_KEYS.sentDate, v)} /></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : confSel === HAMLOG_CONF ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
||||||
@@ -845,30 +882,32 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{CONFIRMATIONS.map((c) => (
|
{/* The extras-backed channels (OpsLog card,
|
||||||
|
HAMLOG.online, HamQTH) sit in the same ordered list
|
||||||
|
as the column-backed ones: the reader is looking for
|
||||||
|
a name, not for a storage detail. A dash in RECEIVED
|
||||||
|
means the channel has nothing to receive — Club Log
|
||||||
|
and HamQTH publish no confirmations — which is not
|
||||||
|
the same statement as "N". */}
|
||||||
|
{confOrder([
|
||||||
|
...CONFIRMATIONS.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label,
|
||||||
|
sent: val(c.sent),
|
||||||
|
rcvd: c.rcvd ? val(c.rcvd) : null,
|
||||||
|
})),
|
||||||
|
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog'), sent: opslogQslSent ? 'Y' : 'N', rcvd: qslReceived ? 'Y' : 'N' },
|
||||||
|
{ key: HAMLOG_CONF, label: 'HAMLOG.online', sent: exVal(HAMLOG_KEYS.sent), rcvd: exVal(HAMLOG_KEYS.rcvd) },
|
||||||
|
{ key: HAMQTH_CONF, label: 'HamQTH', sent: exVal(HAMQTH_KEYS.sent), rcvd: null },
|
||||||
|
]).map((c) => (
|
||||||
<tr key={c.key} className="text-xs">
|
<tr key={c.key} className="text-xs">
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{c.label}</td>
|
||||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
<td className="w-24"><StatusCell value={c.sent} /></td>
|
||||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
<td className="w-24">{c.rcvd === null
|
||||||
|
? <span className="block text-center text-[11px] text-muted-foreground">—</span>
|
||||||
|
: <StatusCell value={c.rcvd} />}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{/* OpsLog's own card, read from the ADIF extras rather
|
|
||||||
than a QSO column — hence a hand-written row instead
|
|
||||||
of a CONFIRMATIONS entry. "Sent" is stamped by OpsLog
|
|
||||||
when the card actually goes out, so it stays
|
|
||||||
read-only here: an operator ticking it by hand would
|
|
||||||
be recording something that never happened. */}
|
|
||||||
<tr className="text-xs">
|
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{t('qedit.confOpsLog')}</td>
|
|
||||||
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
|
|
||||||
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></td>
|
|
||||||
</tr>
|
|
||||||
{/* HAMLOG.online — extras again, same hand-written row. */}
|
|
||||||
<tr className="text-xs">
|
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">HAMLOG.online</td>
|
|
||||||
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.sent)} /></td>
|
|
||||||
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.rcvd)} /></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -181,7 +181,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
try {
|
try {
|
||||||
await WatchlistAdd(c, addContest);
|
await WatchlistAdd(c, addContest);
|
||||||
setAddCall('');
|
setAddCall('');
|
||||||
flash(t(addContest ? 'wl.addedContest' : 'wl.added', { call: c }), false);
|
// The confirmation is the app-level notice (App.tsx, on watchlist:changed)
|
||||||
|
// — saying it twice, once per place a call can be added from, was noise.
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e: any) { flash(String(e?.message ?? e), true); }
|
} catch (e: any) { flash(String(e?.message ?? e), true); }
|
||||||
};
|
};
|
||||||
@@ -190,7 +191,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
// entry is cheap to undo (type it again), so it does not earn a confirmation
|
// entry is cheap to undo (type it again), so it does not earn a confirmation
|
||||||
// the other two buttons do not have.
|
// the other two buttons do not have.
|
||||||
const remove = async (call: string) => {
|
const remove = async (call: string) => {
|
||||||
try { await WatchlistRemove(call); flash(t('wl.removed', { call }), false); await refresh(); }
|
try { await WatchlistRemove(call); await refresh(); }
|
||||||
catch (e: any) { flash(String(e?.message ?? e), true); }
|
catch (e: any) { flash(String(e?.message ?? e), true); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const en: Dict = {
|
|||||||
'wl.totalSpots': '{n} spots', 'wl.toggleContest': 'Contest entry: judged per UTC day',
|
'wl.totalSpots': '{n} spots', 'wl.toggleContest': 'Contest entry: judged per UTC day',
|
||||||
'wl.notifyOn': 'Alert me when this station is spotted', 'wl.notifyOff': 'Stop alerting for this station',
|
'wl.notifyOn': 'Alert me when this station is spotted', 'wl.notifyOff': 'Stop alerting for this station',
|
||||||
'wl.remove': 'Remove', 'wl.removed': '{call} removed from the watchlist.', 'wl.noSpots': 'No live spots',
|
'wl.remove': 'Remove', 'wl.removed': '{call} removed from the watchlist.', 'wl.noSpots': 'No live spots',
|
||||||
'wl.newDxcc': 'NEW DXCC', 'wl.worked': 'Worked', 'wl.needed': 'Needed!', 'wl.todayOk': 'Today ✓', 'wl.workToday': 'Work today!',
|
'wl.newDxcc': 'NEW DXCC', 'dxp.tab': 'DXpeditions', 'dxp.announced': 'Announced operations', 'dxp.news': 'DX news', 'dxp.source': 'from {name}', 'dxp.neededOnly': 'Only what I need', 'dxp.refresh': 'Refresh', 'dxp.none': 'No announced operation — or the feed could not be read.', 'dxp.noNews': 'No news.', 'dxp.onAir': 'ON AIR', 'dxp.watch': 'Watch', 'dxp.watched': 'Watched', 'dxp.watchTip': 'Add to the watchlist', 'dxp.open': 'Open', 'wlnote.added': '{call} added to the watchlist', 'wlnote.removed': '{call} removed from the watchlist', 'wlnote.hint': 'Its spots are highlighted in the cluster.', 'wl.worked': 'Worked', 'wl.needed': 'Needed!', 'wl.todayOk': 'Today ✓', 'wl.workToday': 'Work today!',
|
||||||
'wl.spotTip': 'Click: fill the callsign · double-click: tune and work',
|
'wl.spotTip': 'Click: fill the callsign · double-click: tune and work',
|
||||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
||||||
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
|
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
|
||||||
@@ -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.pillConnect': 'Click to connect', 'clu.pillDisconnect': 'Click to disconnect', '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 (1–2 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 (1–2 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',
|
||||||
@@ -436,7 +436,7 @@ const en: Dict = {
|
|||||||
'bmp.statusWorked': 'Entity worked on this band and mode', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
|
'bmp.statusWorked': 'Entity worked on this band and mode', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
|
||||||
'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
|
'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
|
||||||
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.ftx': 'FTx', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.widthTip': 'Drag to resize — double-click to reset', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
|
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.ftx': 'FTx', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.widthTip': 'Drag to resize — double-click to reset', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
|
||||||
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.openUnusual': 'unusual for the season', 'bmp.legendWorked': 'Entity worked', 'bmp.legendNewPota': 'New POTA', 'bmp.legendNewCounty': 'New county', 'bmp.legendWorkedCall': 'Callsign already worked', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Data', 'bmp.legendPhone': 'Phone', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
|
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.openUnusual': 'unusual for the season', 'bmp.legendWorked': 'Entity worked', 'bmp.legendNewPota': 'New POTA', 'bmp.legendNewCounty': 'New county', 'bmp.legendWorkedCall': 'Callsign already worked', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Data', 'bmp.legendPhone': 'Phone', 'bmp.legendHide': 'Hide the legend', 'bmp.legendShow': 'Show the legend', 'bmp.footerHint': 'scroll · ◎ = go to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
|
||||||
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
|
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
|
||||||
'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name',
|
'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name',
|
||||||
'frm.awardRefs': 'Award reference lists', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).', 'frm.downloading': 'Downloading…', 'frm.reDownload': 'Re-download', 'frm.download': 'Download', 'frm.required': 'Callsign and locator are required.', 'frm.saving': 'Saving…', 'frm.startLogging': 'Start logging',
|
'frm.awardRefs': 'Award reference lists', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).', 'frm.downloading': 'Downloading…', 'frm.reDownload': 'Re-download', 'frm.download': 'Download', 'frm.required': 'Callsign and locator are required.', 'frm.saving': 'Saving…', 'frm.startLogging': 'Start logging',
|
||||||
@@ -574,7 +574,7 @@ const fr: Dict = {
|
|||||||
'wl.totalSpots': '{n} spots', 'wl.toggleContest': 'Entrée contest : jugée par jour UTC',
|
'wl.totalSpots': '{n} spots', 'wl.toggleContest': 'Entrée contest : jugée par jour UTC',
|
||||||
'wl.notifyOn': "M'alerter quand cette station est spottée", 'wl.notifyOff': "Ne plus alerter pour cette station",
|
'wl.notifyOn': "M'alerter quand cette station est spottée", 'wl.notifyOff': "Ne plus alerter pour cette station",
|
||||||
'wl.remove': 'Supprimer', 'wl.removed': '{call} retiré de la watchlist.', 'wl.noSpots': 'Aucun spot en cours',
|
'wl.remove': 'Supprimer', 'wl.removed': '{call} retiré de la watchlist.', 'wl.noSpots': 'Aucun spot en cours',
|
||||||
'wl.newDxcc': 'NOUV DXCC', 'wl.worked': 'Contacté', 'wl.needed': 'Manquant !', 'wl.todayOk': "Auj. ✓", 'wl.workToday': "À faire auj. !",
|
'wl.newDxcc': 'NOUV DXCC', 'dxp.tab': 'DXpéditions', 'dxp.announced': 'Opérations annoncées', 'dxp.news': 'Actualités DX', 'dxp.source': 'depuis {name}', 'dxp.neededOnly': 'Seulement ce qu’il me manque', 'dxp.refresh': 'Actualiser', 'dxp.none': 'Aucune opération annoncée — ou le flux n’a pas pu être lu.', 'dxp.noNews': 'Aucune actualité.', 'dxp.onAir': 'EN COURS', 'dxp.watch': 'Surveiller', 'dxp.watched': 'Surveillé', 'dxp.watchTip': 'Ajouter à la watchlist', 'dxp.open': 'Ouvrir', 'wlnote.added': '{call} ajouté à la watchlist', 'wlnote.removed': '{call} retiré de la watchlist', 'wlnote.hint': 'Ses spots sont mis en évidence dans le cluster.', 'wl.worked': 'Contacté', 'wl.needed': 'Manquant !', 'wl.todayOk': "Auj. ✓", 'wl.workToday': "À faire auj. !",
|
||||||
'wl.spotTip': "Clic : remplir l'indicatif · double-clic : régler la radio et travailler",
|
'wl.spotTip': "Clic : remplir l'indicatif · double-clic : régler la radio et travailler",
|
||||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
||||||
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
|
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
|
||||||
@@ -863,7 +863,7 @@ const fr: Dict = {
|
|||||||
'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que s’il 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 n’a 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 s’il 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 n’a 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 n’est 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 n’est 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.pillConnect': 'Cliquer pour connecter', 'clu.pillDisconnect': 'Cliquer pour déconnecter', '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é (1–2 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 l’indicatif 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é (1–2 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',
|
||||||
@@ -943,7 +943,7 @@ const fr: Dict = {
|
|||||||
'bmp.statusWorked': 'Entité contactée sur cette bande et ce mode', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
|
'bmp.statusWorked': 'Entité contactée sur cette bande et ce mode', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
|
||||||
'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
|
'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
|
||||||
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.ftx': 'FTx', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.widthTip': 'Glisser pour redimensionner — double-clic pour réinitialiser', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
|
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.ftx': 'FTx', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.widthTip': 'Glisser pour redimensionner — double-clic pour réinitialiser', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
|
||||||
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.openUnusual': 'inhabituel pour la saison', 'bmp.legendWorked': 'Entité contactée', 'bmp.legendNewPota': 'Nouveau POTA', 'bmp.legendNewCounty': 'Nouveau comté', 'bmp.legendWorkedCall': 'Indicatif déjà contacté', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Numérique', 'bmp.legendPhone': 'Phonie', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
|
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.openUnusual': 'inhabituel pour la saison', 'bmp.legendWorked': 'Entité contactée', 'bmp.legendNewPota': 'Nouveau POTA', 'bmp.legendNewCounty': 'Nouveau comté', 'bmp.legendWorkedCall': 'Indicatif déjà contacté', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Numérique', 'bmp.legendPhone': 'Phonie', 'bmp.legendHide': 'Masquer la légende', 'bmp.legendShow': 'Afficher la légende', 'bmp.footerHint': 'défiler · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
|
||||||
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
|
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
|
||||||
'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom',
|
'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom',
|
||||||
'frm.awardRefs': 'Listes de références des diplômes', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — noms et totaux pour ces diplômes (optionnel, peut prendre une minute).', 'frm.downloading': 'Téléchargement…', 'frm.reDownload': 'Retélécharger', 'frm.download': 'Télécharger', 'frm.required': "L'indicatif et le locator sont obligatoires.", 'frm.saving': 'Enregistrement…', 'frm.startLogging': 'Commencer à logger',
|
'frm.awardRefs': 'Listes de références des diplômes', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — noms et totaux pour ces diplômes (optionnel, peut prendre une minute).', 'frm.downloading': 'Téléchargement…', 'frm.reDownload': 'Retélécharger', 'frm.download': 'Télécharger', 'frm.required': "L'indicatif et le locator sont obligatoires.", 'frm.saving': 'Enregistrement…', 'frm.startLogging': 'Commencer à logger',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.27.5';
|
export const APP_VERSION = '0.27.6';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+9
@@ -12,6 +12,7 @@ import {award} from '../models';
|
|||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
import {bandopen} from '../models';
|
import {bandopen} from '../models';
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
|
import {dxped} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
import {pskr} from '../models';
|
import {pskr} from '../models';
|
||||||
@@ -476,6 +477,10 @@ export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
|
|||||||
|
|
||||||
export function GetDVKStatus():Promise<main.DVKStatus>;
|
export function GetDVKStatus():Promise<main.DVKStatus>;
|
||||||
|
|
||||||
|
export function GetDXWorldNews():Promise<Array<dxped.News>>;
|
||||||
|
|
||||||
|
export function GetDXpeditions():Promise<Array<main.DXpedition>>;
|
||||||
|
|
||||||
export function GetDataDir():Promise<string>;
|
export function GetDataDir():Promise<string>;
|
||||||
|
|
||||||
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
||||||
@@ -944,6 +949,8 @@ export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
|
|||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function RefreshDXpeditions():Promise<void>;
|
||||||
|
|
||||||
export function RefreshKenwood():Promise<void>;
|
export function RefreshKenwood():Promise<void>;
|
||||||
|
|
||||||
export function RefreshSolar():Promise<void>;
|
export function RefreshSolar():Promise<void>;
|
||||||
@@ -1334,6 +1341,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>;
|
||||||
|
|||||||
@@ -890,6 +890,14 @@ export function GetDVKStatus() {
|
|||||||
return window['go']['main']['App']['GetDVKStatus']();
|
return window['go']['main']['App']['GetDVKStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetDXWorldNews() {
|
||||||
|
return window['go']['main']['App']['GetDXWorldNews']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetDXpeditions() {
|
||||||
|
return window['go']['main']['App']['GetDXpeditions']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetDataDir() {
|
export function GetDataDir() {
|
||||||
return window['go']['main']['App']['GetDataDir']();
|
return window['go']['main']['App']['GetDataDir']();
|
||||||
}
|
}
|
||||||
@@ -1826,6 +1834,10 @@ export function RefreshCtyDat() {
|
|||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RefreshDXpeditions() {
|
||||||
|
return window['go']['main']['App']['RefreshDXpeditions']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshKenwood() {
|
export function RefreshKenwood() {
|
||||||
return window['go']['main']['App']['RefreshKenwood']();
|
return window['go']['main']['App']['RefreshKenwood']();
|
||||||
}
|
}
|
||||||
@@ -2606,6 +2618,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']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1466,6 +1466,37 @@ export namespace contest {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace dxped {
|
||||||
|
|
||||||
|
export class News {
|
||||||
|
title: string;
|
||||||
|
link: string;
|
||||||
|
pub_date: string;
|
||||||
|
excerpt: string;
|
||||||
|
creator: string;
|
||||||
|
image_url: string;
|
||||||
|
tag: string;
|
||||||
|
calls: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new News(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.title = source["title"];
|
||||||
|
this.link = source["link"];
|
||||||
|
this.pub_date = source["pub_date"];
|
||||||
|
this.excerpt = source["excerpt"];
|
||||||
|
this.creator = source["creator"];
|
||||||
|
this.image_url = source["image_url"];
|
||||||
|
this.tag = source["tag"];
|
||||||
|
this.calls = source["calls"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace extsvc {
|
export namespace extsvc {
|
||||||
|
|
||||||
export class ServiceConfig {
|
export class ServiceConfig {
|
||||||
@@ -1522,6 +1553,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 +1569,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"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2672,6 +2705,44 @@ export namespace main {
|
|||||||
this.rec_slot = source["rec_slot"];
|
this.rec_slot = source["rec_slot"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class DXpedition {
|
||||||
|
dxcc: string;
|
||||||
|
callsign: string;
|
||||||
|
calls: string[];
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
bands: string[];
|
||||||
|
modes: string[];
|
||||||
|
qsl: string;
|
||||||
|
operators: string;
|
||||||
|
source: string;
|
||||||
|
link: string;
|
||||||
|
status: string;
|
||||||
|
status_chase: string;
|
||||||
|
unconfirmed: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DXpedition(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.dxcc = source["dxcc"];
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.calls = source["calls"];
|
||||||
|
this.start_date = source["start_date"];
|
||||||
|
this.end_date = source["end_date"];
|
||||||
|
this.bands = source["bands"];
|
||||||
|
this.modes = source["modes"];
|
||||||
|
this.qsl = source["qsl"];
|
||||||
|
this.operators = source["operators"];
|
||||||
|
this.source = source["source"];
|
||||||
|
this.link = source["link"];
|
||||||
|
this.status = source["status"];
|
||||||
|
this.status_chase = source["status_chase"];
|
||||||
|
this.unconfirmed = source["unconfirmed"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class DatabaseSettings {
|
export class DatabaseSettings {
|
||||||
path: string;
|
path: string;
|
||||||
default_path: string;
|
default_path: string;
|
||||||
@@ -3300,6 +3371,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 +3392,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
// Package dxped reads the two feeds the DX world announces itself on.
|
||||||
|
//
|
||||||
|
// NG3K's ADXO is the STRUCTURED one: an RSS item per announced operation whose
|
||||||
|
// description is a fixed, dash-separated sentence — dates, entity, callsign,
|
||||||
|
// QSL route, source, then the operators/bands/modes prose. It is what a
|
||||||
|
// DXpedition list is actually made of.
|
||||||
|
//
|
||||||
|
// DX-World's feed is NEWS: WordPress posts with a headline and an excerpt. It
|
||||||
|
// carries no structure to act on, but the headline nearly always names the
|
||||||
|
// callsign, so the calls are mined from the title and the reader can act on
|
||||||
|
// them the same way (watchlist, chase status).
|
||||||
|
//
|
||||||
|
// Both are cached: the announcements change a few times a day, the news a few
|
||||||
|
// times an hour, and neither is worth a request per screen repaint.
|
||||||
|
package dxped
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
adxoURL = "https://www.ng3k.com/adxo.xml"
|
||||||
|
dxworldURL = "https://dx-world.net/feed/"
|
||||||
|
|
||||||
|
adxoTTL = 1 * time.Hour
|
||||||
|
dxworldTTL = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// Activation is one announced operation, as ADXO describes it.
|
||||||
|
type Activation struct {
|
||||||
|
DXCC string `json:"dxcc"`
|
||||||
|
Callsign string `json:"callsign"` // display form; "A, B" when several
|
||||||
|
Calls []string `json:"calls"` // the individual callsigns, normalised
|
||||||
|
StartDate string `json:"start_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
Bands []string `json:"bands"`
|
||||||
|
Modes []string `json:"modes"`
|
||||||
|
QSL string `json:"qsl"`
|
||||||
|
Operators string `json:"operators"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
Status string `json:"status"` // "active" | "upcoming"
|
||||||
|
}
|
||||||
|
|
||||||
|
// News is one DX-World post.
|
||||||
|
type News struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
PubDate string `json:"pub_date"` // RFC3339, "" when unparseable
|
||||||
|
Excerpt string `json:"excerpt"`
|
||||||
|
Creator string `json:"creator"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Tag string `json:"tag"` // NEWS / UPDATE / NEW ACTIVITY…
|
||||||
|
Calls []string `json:"calls"` // callsigns mined from the headline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager holds both caches and fetches on demand.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
acts []Activation
|
||||||
|
actsAt time.Time
|
||||||
|
news []News
|
||||||
|
newsAt time.Time
|
||||||
|
client *http.Client
|
||||||
|
fetching sync.Mutex // one refresh at a time, whichever pane asked
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Manager {
|
||||||
|
return &Manager{client: &http.Client{Timeout: 30 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activations returns the cached announcements, refreshing when stale.
|
||||||
|
func (m *Manager) Activations(ctx context.Context) ([]Activation, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
fresh := time.Since(m.actsAt) < adxoTTL && m.acts != nil
|
||||||
|
out := m.acts
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
m.fetching.Lock()
|
||||||
|
defer m.fetching.Unlock()
|
||||||
|
// Someone else may have refreshed while we waited for the lock.
|
||||||
|
m.mu.RLock()
|
||||||
|
fresh = time.Since(m.actsAt) < adxoTTL && m.acts != nil
|
||||||
|
out = m.acts
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
acts, err := m.fetchADXO(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Stale beats empty: a feed that is down should not blank a list the
|
||||||
|
// operator was reading a minute ago.
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.acts, err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.acts, m.actsAt = acts, time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return acts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// News returns the cached DX-World posts, refreshing when stale.
|
||||||
|
func (m *Manager) News(ctx context.Context) ([]News, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
fresh := time.Since(m.newsAt) < dxworldTTL && m.news != nil
|
||||||
|
out := m.news
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
m.fetching.Lock()
|
||||||
|
defer m.fetching.Unlock()
|
||||||
|
m.mu.RLock()
|
||||||
|
fresh = time.Since(m.newsAt) < dxworldTTL && m.news != nil
|
||||||
|
out = m.news
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
news, err := m.fetchDXWorld(ctx)
|
||||||
|
if err != nil {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.news, err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.news, m.newsAt = news, time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return news, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate drops both caches so the next read refetches.
|
||||||
|
func (m *Manager) Invalidate() {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.actsAt, m.newsAt = time.Time{}, time.Time{}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) get(ctx context.Context, url string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "OpsLog")
|
||||||
|
resp, err := m.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ADXO ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type rssItem struct {
|
||||||
|
Title string `xml:"title"`
|
||||||
|
Description string `xml:"description"`
|
||||||
|
Link string `xml:"link"`
|
||||||
|
PubDate string `xml:"pubDate"`
|
||||||
|
Creator string `xml:"creator"`
|
||||||
|
Enclosure struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
} `xml:"enclosure"`
|
||||||
|
MediaContent struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
} `xml:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rssFeed struct {
|
||||||
|
Items []rssItem `xml:"channel>item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchADXO(ctx context.Context) ([]Activation, error) {
|
||||||
|
body, err := m.get(ctx, adxoURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("adxo: %w", err)
|
||||||
|
}
|
||||||
|
var feed rssFeed
|
||||||
|
if err := xml.Unmarshal(body, &feed); err != nil {
|
||||||
|
return nil, fmt.Errorf("adxo: parse: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
out := make([]Activation, 0, len(feed.Items))
|
||||||
|
for _, it := range feed.Items {
|
||||||
|
a := parseActivation(it.Description, it.Link)
|
||||||
|
if a == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.Status = activationStatus(a.StartDate, a.EndDate, now)
|
||||||
|
if a.Status == "ended" {
|
||||||
|
continue // the list is about what is on or coming
|
||||||
|
}
|
||||||
|
out = append(out, *a)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var spaceRe = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
// parseActivation reads one ADXO description. Its shape is fixed and has been
|
||||||
|
// for twenty years:
|
||||||
|
//
|
||||||
|
// "Feb 17-Mar 30, 2026 -- Entity -- CALL -- QSL: route -- Source: who (date)
|
||||||
|
// -- By ops; bands; modes; notes"
|
||||||
|
func parseActivation(desc, link string) *Activation {
|
||||||
|
desc = spaceRe.ReplaceAllString(strings.NewReplacer("\n", " ", "\r", " ").Replace(desc), " ")
|
||||||
|
desc = strings.TrimSpace(html.UnescapeString(desc))
|
||||||
|
if desc == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(desc, " -- ")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a := &Activation{Link: link}
|
||||||
|
a.StartDate, a.EndDate = parseDateRange(strings.TrimSpace(parts[0]))
|
||||||
|
a.DXCC = strings.TrimSpace(parts[1])
|
||||||
|
a.Callsign = strings.TrimSpace(parts[2])
|
||||||
|
|
||||||
|
for _, p := range parts[3:] {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(p, "QSL:"):
|
||||||
|
a.QSL = strings.TrimSpace(strings.TrimPrefix(p, "QSL:"))
|
||||||
|
case strings.HasPrefix(p, "Source:"):
|
||||||
|
a.Source = strings.TrimSpace(strings.TrimPrefix(p, "Source:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tail carries "By <ops>; <bands>; <modes>; <notes>".
|
||||||
|
tail := parts[len(parts)-1]
|
||||||
|
if i := strings.Index(tail, "By "); i >= 0 {
|
||||||
|
sub := strings.Split(tail[i+3:], ";")
|
||||||
|
if len(sub) > 0 {
|
||||||
|
a.Operators = strings.TrimSpace(sub[0])
|
||||||
|
}
|
||||||
|
if len(sub) > 1 {
|
||||||
|
a.Bands = parseBands(sub[1])
|
||||||
|
}
|
||||||
|
if len(sub) > 2 {
|
||||||
|
a.Modes = parseModes(sub[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The callsign field often holds only the PREFIX; the real calls hide in the
|
||||||
|
// operators prose as "W2APF as PJ2/W2APF". Prefer those when present.
|
||||||
|
if calls := callsAfterAs(a.Operators); len(calls) > 0 {
|
||||||
|
prefix := strings.ToUpper(strings.TrimSpace(parts[2]))
|
||||||
|
for i := range calls {
|
||||||
|
calls[i] = normalizeCall(calls[i], prefix)
|
||||||
|
}
|
||||||
|
a.Calls = calls
|
||||||
|
a.Callsign = strings.Join(calls, ", ")
|
||||||
|
} else if c := strings.ToUpper(a.Callsign); plausibleCall(c) {
|
||||||
|
a.Calls = []string{c}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDateRange handles the two forms ADXO writes: "Feb 17-Mar 30, 2026" and
|
||||||
|
// "Mar 3-20, 2026" (the month carried over).
|
||||||
|
var (
|
||||||
|
fullRangeRe = regexp.MustCompile(`(?i)(\w+ \d+)\s*-\s*(\w+ \d+),\s*(\d{4})`)
|
||||||
|
shortRangeRe = regexp.MustCompile(`(?i)(\w+) (\d+)\s*-\s*(\d+),\s*(\d{4})`)
|
||||||
|
singleDayRe = regexp.MustCompile(`(?i)(\w+ \d+),\s*(\d{4})`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseDateRange(s string) (start, end string) {
|
||||||
|
if m := fullRangeRe.FindStringSubmatch(s); m != nil {
|
||||||
|
return m[1] + ", " + m[3], m[2] + ", " + m[3]
|
||||||
|
}
|
||||||
|
if m := shortRangeRe.FindStringSubmatch(s); m != nil {
|
||||||
|
return m[1] + " " + m[2] + ", " + m[4], m[1] + " " + m[3] + ", " + m[4]
|
||||||
|
}
|
||||||
|
if m := singleDayRe.FindStringSubmatch(s); m != nil {
|
||||||
|
return m[1] + ", " + m[2], m[1] + ", " + m[2]
|
||||||
|
}
|
||||||
|
return s, s
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseADXODate(s string) (time.Time, bool) {
|
||||||
|
for _, layout := range []string{"Jan 2, 2006", "January 2, 2006"} {
|
||||||
|
if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func activationStatus(start, end string, now time.Time) string {
|
||||||
|
s, okS := parseADXODate(start)
|
||||||
|
e, okE := parseADXODate(end)
|
||||||
|
if !okS || !okE {
|
||||||
|
return "upcoming" // unreadable dates: keep it, an operator can read them
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case now.Before(s):
|
||||||
|
return "upcoming"
|
||||||
|
// The end date is a DAY, so an operation is on until that day is over.
|
||||||
|
case now.After(e.Add(24 * time.Hour)):
|
||||||
|
return "ended"
|
||||||
|
default:
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// callsAfterAs mines "…as CALL…" out of the operators prose:
|
||||||
|
//
|
||||||
|
// "W2APF as PJ2/W2APF" → PJ2/W2APF
|
||||||
|
// "SQ2RAD as VP2EAD, M0PLX as VP2ELX" → VP2EAD, VP2ELX
|
||||||
|
var asCallRe = regexp.MustCompile(`(?i)\bas\s+([A-Z0-9]+(?:/[A-Z0-9]+)*)`)
|
||||||
|
|
||||||
|
func callsAfterAs(operators string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range asCallRe.FindAllStringSubmatch(operators, -1) {
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(m[1]))
|
||||||
|
if !plausibleCall(c) || seen[c] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c] = true
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeCall puts the DXCC prefix first: a cluster spot says JD1/JG8NQJ, and
|
||||||
|
// matching the log against JG8NQJ/JD1 would find nothing.
|
||||||
|
func normalizeCall(call, dxccPrefix string) string {
|
||||||
|
if dxccPrefix == "" || !strings.Contains(call, "/") {
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
left, right, _ := strings.Cut(call, "/")
|
||||||
|
if strings.HasPrefix(right, dxccPrefix) && !strings.HasPrefix(left, dxccPrefix) {
|
||||||
|
return right + "/" + left
|
||||||
|
}
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// A span carries its unit only once — "160-6m" is the commonest way ADXO
|
||||||
|
// states coverage, and reading it as "6m" alone loses the whole low end.
|
||||||
|
bandRe = regexp.MustCompile(`(?i)\b(?:(\d{1,4})\s*-\s*)?(\d{1,4})\s*(m|cm)\b`)
|
||||||
|
// The modes ADXO actually writes. A list beats a pattern here: "FT8" and
|
||||||
|
// "SSB" have no shape in common, and inventing one invites "QSL" as a mode.
|
||||||
|
knownModes = []string{"SSB", "CW", "FT8", "FT4", "RTTY", "PSK", "SSTV", "AM", "FM", "JT65", "JS8", "Q65", "MSK144", "DIGI", "DATA"}
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseBands(s string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
add := func(num, unit string) {
|
||||||
|
if num == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := strings.ToLower(num + unit)
|
||||||
|
if seen[b] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[b] = true
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
// A span keeps only its endpoints: ADXO states coverage in prose, and the
|
||||||
|
// two ends are the part it gives reliably.
|
||||||
|
for _, m := range bandRe.FindAllStringSubmatch(s, -1) {
|
||||||
|
add(m[1], m[3])
|
||||||
|
add(m[2], m[3])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseModes(s string) []string {
|
||||||
|
up := strings.ToUpper(s)
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, mode := range knownModes {
|
||||||
|
if seen[mode] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`\b` + mode + `\b`).MatchString(up) {
|
||||||
|
seen[mode] = true
|
||||||
|
out = append(out, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DX-World ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var (
|
||||||
|
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
||||||
|
tagPrefixRe = regexp.MustCompile(`(?i)^\s*\[([^\]]+)\]\s*`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func stripHTML(s string) string {
|
||||||
|
s = htmlTagRe.ReplaceAllString(s, " ")
|
||||||
|
s = html.UnescapeString(s)
|
||||||
|
return strings.TrimSpace(spaceRe.ReplaceAllString(s, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchDXWorld(ctx context.Context) ([]News, error) {
|
||||||
|
body, err := m.get(ctx, dxworldURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dx-world: %w", err)
|
||||||
|
}
|
||||||
|
var feed rssFeed
|
||||||
|
if err := xml.Unmarshal(body, &feed); err != nil {
|
||||||
|
return nil, fmt.Errorf("dx-world: parse: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]News, 0, len(feed.Items))
|
||||||
|
for _, it := range feed.Items {
|
||||||
|
title := stripHTML(it.Title)
|
||||||
|
tag, title := splitTag(title)
|
||||||
|
n := News{
|
||||||
|
Title: title,
|
||||||
|
Link: strings.TrimSpace(it.Link),
|
||||||
|
Creator: strings.TrimSpace(it.Creator),
|
||||||
|
Tag: tag,
|
||||||
|
Calls: callsInHeadline(title),
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)); err == nil {
|
||||||
|
n.PubDate = t.UTC().Format(time.RFC3339)
|
||||||
|
} else if t, err := time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)); err == nil {
|
||||||
|
n.PubDate = t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
ex := stripHTML(it.Description)
|
||||||
|
if t2, rest := splitTag(ex); t2 != "" {
|
||||||
|
if n.Tag == "" {
|
||||||
|
n.Tag = t2
|
||||||
|
}
|
||||||
|
ex = rest
|
||||||
|
}
|
||||||
|
n.Excerpt = truncateRunes(ex, 400)
|
||||||
|
if u := strings.TrimSpace(it.Enclosure.URL); u != "" {
|
||||||
|
n.ImageURL = u
|
||||||
|
} else if u := strings.TrimSpace(it.MediaContent.URL); u != "" {
|
||||||
|
n.ImageURL = u
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitTag pulls the leading "[UPDATE]" DX-World puts on most posts.
|
||||||
|
func splitTag(s string) (tag, rest string) {
|
||||||
|
m := tagPrefixRe.FindStringSubmatchIndex(s)
|
||||||
|
if m == nil {
|
||||||
|
return "", s
|
||||||
|
}
|
||||||
|
tag = strings.ToUpper(strings.TrimSpace(s[m[2]:m[3]]))
|
||||||
|
rest = strings.TrimSpace(s[m[1]:])
|
||||||
|
rest = strings.TrimPrefix(strings.TrimPrefix(rest, "– "), "- ")
|
||||||
|
return tag, strings.TrimSpace(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRunes(s string, max int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(r[:max])) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// callsInHeadline mines callsigns out of a news headline — "3B7M, St Brandon"
|
||||||
|
// or "TX5S team lands". The reader can then chase or watch them, which is the
|
||||||
|
// whole reason a news feed sits next to the announcements.
|
||||||
|
func callsInHeadline(title string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, tok := range strings.FieldsFunc(title, func(r rune) bool {
|
||||||
|
return !(r == '/' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'))
|
||||||
|
}) {
|
||||||
|
c := strings.ToUpper(tok)
|
||||||
|
if !plausibleCall(c) || seen[c] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c] = true
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
bandTokenRe = regexp.MustCompile(`^\d{1,4}(M|CM)$`)
|
||||||
|
// Jargon shaped exactly like a callsign. Every one of these was seen in a
|
||||||
|
// real headline before it earned its place here.
|
||||||
|
notACall = map[string]bool{
|
||||||
|
"FT8": true, "FT4": true, "JT65": true, "JT9": true, "JS8": true, "Q65": true,
|
||||||
|
"MSK144": true, "PSK31": true, "SSTV": true, "OQRS": true, "LOTW": true,
|
||||||
|
"IOTA": true, "SOTA": true, "POTA": true, "WWFF": true, "DXCC": true,
|
||||||
|
"CQWW": true, "CQWPX": true, "ARRL": true, "3D": true, "4K": true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// plausibleCall keeps tokens shaped like an amateur callsign: 3–12 characters
|
||||||
|
// of A–Z/0–9 (with optional /prefix or /suffix), at least one letter AND one
|
||||||
|
// digit, and a letter somewhere after the first digit — which is what separates
|
||||||
|
// a callsign from a band or a year.
|
||||||
|
func plausibleCall(s string) bool {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if len(s) < 3 || len(s) > 12 || notACall[s] || bandTokenRe.MatchString(s) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Judge the longest part — the real call in "PJ2/W2APF" either way.
|
||||||
|
base := s
|
||||||
|
if strings.Contains(s, "/") {
|
||||||
|
base = ""
|
||||||
|
for _, p := range strings.Split(s, "/") {
|
||||||
|
if len(p) > len(base) {
|
||||||
|
base = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var hasLetter, hasDigit, letterAfterDigit bool
|
||||||
|
seenDigit := false
|
||||||
|
for _, r := range base {
|
||||||
|
switch {
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
hasLetter = true
|
||||||
|
if seenDigit {
|
||||||
|
letterAfterDigit = true
|
||||||
|
}
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
hasDigit = true
|
||||||
|
seenDigit = true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasLetter && hasDigit && letterAfterDigit
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package dxped
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pinned against REAL feed text: the parser reads a fixed sentence, and a
|
||||||
|
// wrong split silently empties a DXpedition list rather than failing loudly.
|
||||||
|
func TestParseActivation(t *testing.T) {
|
||||||
|
desc := "Aug 24-31, 2026 -- St Kitts and Nevis -- V47JA -- QSL: LoTW -- " +
|
||||||
|
"Source: W5JON (Aug 1, 2026) -- By W5JON as V47JA fm Calypso Bay; 160-6m; SSB FT8; yagi, verticals"
|
||||||
|
a := parseActivation(desc, "https://www.qrz.com/lookup/v47ja")
|
||||||
|
if a == nil {
|
||||||
|
t.Fatal("parseActivation returned nil on a real ADXO description")
|
||||||
|
}
|
||||||
|
if a.DXCC != "St Kitts and Nevis" {
|
||||||
|
t.Errorf("DXCC = %q", a.DXCC)
|
||||||
|
}
|
||||||
|
if a.Callsign != "V47JA" {
|
||||||
|
t.Errorf("Callsign = %q, want V47JA (mined from 'as')", a.Callsign)
|
||||||
|
}
|
||||||
|
if a.QSL != "LoTW" {
|
||||||
|
t.Errorf("QSL = %q", a.QSL)
|
||||||
|
}
|
||||||
|
if a.StartDate != "Aug 24, 2026" || a.EndDate != "Aug 31, 2026" {
|
||||||
|
t.Errorf("dates = %q..%q", a.StartDate, a.EndDate)
|
||||||
|
}
|
||||||
|
if want := []string{"160m", "6m"}; !reflect.DeepEqual(a.Bands, want) {
|
||||||
|
t.Errorf("Bands = %v, want %v", a.Bands, want)
|
||||||
|
}
|
||||||
|
if want := []string{"SSB", "FT8"}; !reflect.DeepEqual(a.Modes, want) {
|
||||||
|
t.Errorf("Modes = %v, want %v", a.Modes, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The callsign column often holds only the prefix; the operators prose holds
|
||||||
|
// the real thing, and a slashed call must lead with the DXCC prefix or no spot
|
||||||
|
// will ever match it.
|
||||||
|
func TestCallsAfterAsAndNormalise(t *testing.T) {
|
||||||
|
if got := callsAfterAs("SQ2RAD as VP2EAD, M0PLX as VP2ELX"); !reflect.DeepEqual(got, []string{"VP2EAD", "VP2ELX"}) {
|
||||||
|
t.Errorf("callsAfterAs = %v", got)
|
||||||
|
}
|
||||||
|
if got := normalizeCall("JG8NQJ/JD1", "JD1"); got != "JD1/JG8NQJ" {
|
||||||
|
t.Errorf("normalizeCall = %q, want JD1/JG8NQJ", got)
|
||||||
|
}
|
||||||
|
if got := normalizeCall("PJ2/W2APF", "PJ2"); got != "PJ2/W2APF" {
|
||||||
|
t.Errorf("normalizeCall rewrote an already-correct call: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivationStatus(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct{ start, end, want string }{
|
||||||
|
{"Aug 24, 2026", "Aug 31, 2026", "active"},
|
||||||
|
{"Sep 10, 2026", "Sep 20, 2026", "upcoming"},
|
||||||
|
{"Aug 1, 2026", "Aug 10, 2026", "ended"},
|
||||||
|
{"Aug 24, 2026", "Aug 27, 2026", "active"}, // ends TODAY: still on
|
||||||
|
{"garbage", "garbage", "upcoming"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := activationStatus(c.start, c.end, now); got != c.want {
|
||||||
|
t.Errorf("activationStatus(%q,%q) = %q, want %q", c.start, c.end, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mining a headline must find calls without inventing them out of jargon.
|
||||||
|
func TestCallsInHeadline(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
title string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"3B7M, St Brandon", []string{"3B7M"}},
|
||||||
|
{"TX5S team lands on Clipperton", []string{"TX5S"}},
|
||||||
|
{"FT8 activity on 160m in 2026", nil},
|
||||||
|
{"VP6D QSL via OQRS, LoTW", []string{"VP6D"}},
|
||||||
|
{"JD1/JG8NQJ from Minami Torishima", []string{"JD1/JG8NQJ"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := callsInHeadline(c.title); !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Errorf("callsInHeadline(%q) = %v, want %v", c.title, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.27.5"
|
appVersion = "0.27.6"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user