Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b7756beb4 | ||
|
|
dee4ffce2a | ||
|
|
fbf3cd116b | ||
|
|
b2165173cf | ||
|
|
912d727d7a | ||
|
|
32036a93ed | ||
|
|
7f4b0b78c0 | ||
|
|
775daa233e | ||
|
|
4fb013701b | ||
|
|
2dd68da284 | ||
|
|
65991be09d | ||
|
|
1c4697ecb2 | ||
|
|
ab89611eca | ||
|
|
b952e074f4 | ||
|
|
e01fc39abc | ||
|
|
deabb74393 | ||
|
|
803f4dc4ed | ||
|
|
20784c2fb1 | ||
|
|
83dbdf0539 | ||
|
|
c9d46cfab9 | ||
|
|
c7140fd639 | ||
|
|
c887b77b5f | ||
|
|
b3e398b8d6 | ||
|
|
f4a72da118 | ||
|
|
086581851b | ||
|
|
f8fdfab031 | ||
|
|
3f82da3c18 | ||
|
|
cd557acf81 | ||
|
|
036dc53fe8 | ||
|
|
73806cbd70 | ||
|
|
ee148732dd | ||
|
|
3014796c1d | ||
|
|
405b0e24cf |
@@ -344,6 +344,8 @@ const (
|
||||
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
||||
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
|
||||
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
|
||||
keyQSLDefaultHamlogStatus = "qsl.hamlog_status"
|
||||
keyQSLDefaultHamlogCfm = "qsl.hamlog_confirmed"
|
||||
|
||||
// External services (logbook upload). QRZ.com first; Clublog / LoTW
|
||||
// will add their own keys under the same extsvc.* prefix.
|
||||
@@ -382,6 +384,10 @@ const (
|
||||
keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
|
||||
keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode"
|
||||
|
||||
keyExtHamlogAPIKey = "extsvc.hamlog.api_key"
|
||||
keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload"
|
||||
keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode"
|
||||
|
||||
keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync
|
||||
|
||||
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
||||
@@ -416,6 +422,12 @@ type QSLDefaults struct {
|
||||
HRDLogStatus string `json:"hrdlog_status"`
|
||||
QRZComStatus string `json:"qrzcom_status"`
|
||||
QRZComCfm string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status
|
||||
// HAMLOG.online lives in the ADIF extras rather than a column — the standard
|
||||
// names a field for hamlog.EU and none for hamlog.ONLINE — but a default is
|
||||
// a default: an operator who uploads everything wants new contacts to start
|
||||
// at "N" here exactly as they do for Club Log.
|
||||
HamlogStatus string `json:"hamlog_status"`
|
||||
HamlogCfm string `json:"hamlog_confirmed"`
|
||||
}
|
||||
|
||||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||
@@ -716,6 +728,11 @@ type App struct {
|
||||
clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in)
|
||||
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
|
||||
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
||||
// qsoRecPushed records which SOURCE the recorder was started on: the network
|
||||
// stream, or a sound device. Switching CAT backends changes the answer, and a
|
||||
// recorder left capturing a device that no longer carries the radio records
|
||||
// silence without saying so.
|
||||
qsoRecPushed bool
|
||||
motorStartMu sync.Mutex // serialises startUltrabeam: two restarts at once left two poll loops on one COM port
|
||||
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
|
||||
motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window)
|
||||
@@ -765,6 +782,13 @@ type App struct {
|
||||
// a still-running QRZ sync bleed its log into a freshly started LoTW download).
|
||||
confDLMu sync.Mutex
|
||||
confDLCancel context.CancelFunc
|
||||
|
||||
// hamlogUnmatched holds the confirmations the last HAMLOG.online import
|
||||
// could not place onto a QSO, kept so they can be exported and worked
|
||||
// through — see ExportHamlogUnmatched. Replaced by each import, never
|
||||
// accumulated: it describes one run, not a history.
|
||||
hamlogUnmatchedMu sync.Mutex
|
||||
hamlogUnmatched []qso.QSO
|
||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
||||
syncMu sync.Mutex // serialises folder synchronisation: config, the seq counter, and the append to our own file
|
||||
@@ -6434,6 +6458,26 @@ func (a *App) DeleteQSO(id int64) error {
|
||||
// Failures never block the local delete. The operator asked for this QSO to go;
|
||||
// a refusing website is not a reason to keep it, and the log says what happened
|
||||
// so it can be sorted out on the site afterwards.
|
||||
// deleteSnapshot reads the rows behind a set of ids in ONE query.
|
||||
//
|
||||
// The delete hooks used to fetch them one by one. That is a round trip per
|
||||
// contact, and on a remote MySQL logbook 200 deletions became 400 sequential
|
||||
// queries before the DELETE was even issued — the reason deleting a selection
|
||||
// felt broken rather than slow.
|
||||
func (a *App) deleteSnapshot(ids []int64) []qso.QSO {
|
||||
if a.qso == nil || len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]qso.QSO, 0, len(ids))
|
||||
if err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
|
||||
out = append(out, q)
|
||||
return nil
|
||||
}); err != nil {
|
||||
applog.Printf("delete: reading %d QSO(s) before deletion failed: %v", len(ids), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) deleteRemoteCopies(ids []int64) {
|
||||
if a.qso == nil || len(ids) == 0 {
|
||||
return
|
||||
@@ -6452,9 +6496,37 @@ func (a *App) deleteRemoteCopies(ids []int64) {
|
||||
if !doQRZ && !doClublog {
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil || q.Callsign == "" {
|
||||
// Snapshot first — the rows are about to disappear — then withdraw them in
|
||||
// the background. Each service is an HTTP round trip PER CONTACT, so doing
|
||||
// this on the caller's goroutine held the whole UI for as long as the
|
||||
// websites took: minutes for a large selection, with nothing on screen
|
||||
// saying why. The local log is the operator's own copy and must not wait on
|
||||
// two remote sites to answer.
|
||||
rows := a.deleteSnapshot(ids)
|
||||
go a.withdrawRemoteCopies(rows, cfg, doQRZ, doClublog)
|
||||
}
|
||||
|
||||
// maxClublogRefusals is how many consecutive refusals are tolerated before the
|
||||
// withdrawals stop. Three is enough to tell a one-off from a blocked account.
|
||||
const maxClublogRefusals = 3
|
||||
|
||||
// clublogWasUploaded reports whether this QSO ever reached Club Log. "M"
|
||||
// (modified since upload) counts: the copy is there, it is merely out of date.
|
||||
func clublogWasUploaded(status string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(status)) {
|
||||
case "Y", "M":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) withdrawRemoteCopies(rows []qso.QSO, cfg extsvc.ExternalServices, doQRZ, doClublog bool) {
|
||||
started := time.Now()
|
||||
clublogRefused := 0
|
||||
for i := range rows {
|
||||
q := rows[i]
|
||||
id := q.ID
|
||||
if q.Callsign == "" {
|
||||
continue
|
||||
}
|
||||
if doQRZ {
|
||||
@@ -6473,14 +6545,31 @@ func (a *App) deleteRemoteCopies(ids []int64) {
|
||||
applog.Printf("extsvc: QRZ delete of QSO %d (%s): %s", id, q.Callsign, msg)
|
||||
}
|
||||
}
|
||||
if doClublog {
|
||||
// Only ask Club Log about contacts it was actually given. A QSO that was
|
||||
// never uploaded has no copy to withdraw, and asking anyway is not merely
|
||||
// pointless: Club Log answers 403 and starts blocking the account, so a
|
||||
// selection of never-uploaded QSOs turned into hundreds of refused
|
||||
// requests, one after another, each waiting on the network.
|
||||
if doClublog && !clublogWasUploaded(q.ClublogUploadStatus) {
|
||||
applog.Printf("extsvc: QSO %d (%s) was never uploaded to Club Log — nothing to withdraw", id, q.Callsign)
|
||||
} else if doClublog {
|
||||
if msg, err := extsvc.DeleteClublog(a.ctx, nil, cfg.Clublog, q.Callsign, q.QSODate, q.Band); err != nil {
|
||||
clublogRefused++
|
||||
applog.Printf("extsvc: Club Log delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
|
||||
// Club Log blocks an account that keeps sending requests it
|
||||
// refuses. Stop after a few in a row rather than work through
|
||||
// the whole selection earning a longer block.
|
||||
if clublogRefused >= maxClublogRefusals {
|
||||
applog.Printf("extsvc: Club Log refused %d deletions in a row — giving up on the rest", clublogRefused)
|
||||
doClublog = false
|
||||
}
|
||||
} else {
|
||||
clublogRefused = 0
|
||||
applog.Printf("extsvc: Club Log delete of QSO %d (%s): %s", id, q.Callsign, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
applog.Printf("extsvc: withdrew %d deleted QSO(s) from the remote services in %s", len(rows), time.Since(started).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
|
||||
@@ -6489,9 +6578,23 @@ func (a *App) DeleteQSOs(ids []int64) (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
started := time.Now()
|
||||
a.deleteRemoteCopies(ids)
|
||||
a.syncPublishDeletes(ids)
|
||||
return a.qso.DeleteMany(a.ctx, ids)
|
||||
beforeDelete := time.Since(started)
|
||||
n, err := a.qso.DeleteMany(a.ctx, ids)
|
||||
// Logged because a delete that does nothing is otherwise indistinguishable
|
||||
// from a delete that worked: the rows leave the grid either way once it
|
||||
// reloads, and the only place the truth survives is here.
|
||||
if err != nil {
|
||||
applog.Printf("delete: %d QSO(s) requested, FAILED after %s: %v", len(ids), time.Since(started).Round(time.Millisecond), err)
|
||||
} else {
|
||||
// Both timings, because they blame different things: a long prelude is
|
||||
// the sync/withdraw hooks, a long DELETE is the database itself.
|
||||
applog.Printf("delete: %d QSO(s) requested, %d row(s) removed — %s before the delete, %s total",
|
||||
len(ids), n, beforeDelete.Round(time.Millisecond), time.Since(started).Round(time.Millisecond))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// DuplicateGroup is a set of QSOs the log considers the same contact.
|
||||
@@ -8321,7 +8424,19 @@ func (a *App) startQSORecorderIfEnabled() {
|
||||
if !cfg.QSORecord {
|
||||
return
|
||||
}
|
||||
if err := a.qsoRec.Start(cfg.FromRadio, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
|
||||
// Over the network there IS no radio sound device.
|
||||
//
|
||||
// An IC-705 or IC-7610 reached by LAN streams its receive audio through the
|
||||
// Icom protocol, and Windows sees nothing: the operator's audio settings can
|
||||
// only offer the PC's own microphone and speakers, so "From radio" has
|
||||
// nothing right to point at. The stream is pushed into the recorder instead
|
||||
// — same samples, no sound card in the middle, and no virtual cable to set up.
|
||||
from := cfg.FromRadio
|
||||
a.qsoRecPushed = a.icomNetAudioActive()
|
||||
if a.qsoRecPushed {
|
||||
from = audio.PushedSource
|
||||
}
|
||||
if err := a.qsoRec.Start(from, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
|
||||
applog.Printf("qso-rec: start failed: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -8336,6 +8451,17 @@ func (a *App) startQSORecorderIfEnabled() {
|
||||
applog.Printf("qso-rec: running (preroll %ds, mix=%v, gains rx=%.2f mic=%.2f)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio, fromGain, micGain)
|
||||
}
|
||||
|
||||
// icomNetAudioActive reports whether the receive audio is arriving over the
|
||||
// network rather than from a sound card — the Icom LAN backend with its audio
|
||||
// option on.
|
||||
func (a *App) icomNetAudioActive() bool {
|
||||
s, err := a.GetCATSettings()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return s.Enabled && s.Backend == "icom-net" && s.IcomNetAudio
|
||||
}
|
||||
|
||||
// qsoRecDir returns the configured recordings folder, defaulting to
|
||||
// <dataDir>/Recordings, and ensures it exists.
|
||||
func (a *App) qsoRecDir() string {
|
||||
@@ -10489,6 +10615,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
||||
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
|
||||
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
|
||||
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
|
||||
)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -10503,6 +10630,8 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
|
||||
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
||||
out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
|
||||
out.HamlogStatus = m[keyQSLDefaultHamlogStatus]
|
||||
out.HamlogCfm = m[keyQSLDefaultHamlogCfm]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -10524,6 +10653,8 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
||||
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
|
||||
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
|
||||
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
|
||||
keyQSLDefaultHamlogStatus: strings.ToUpper(strings.TrimSpace(d.HamlogStatus)),
|
||||
keyQSLDefaultHamlogCfm: strings.ToUpper(strings.TrimSpace(d.HamlogCfm)),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||
return err
|
||||
@@ -10574,6 +10705,27 @@ func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
|
||||
fill(&q.HRDLogUploadStatus, d.HRDLogStatus)
|
||||
fill(&q.QRZComUploadStatus, d.QRZComStatus)
|
||||
fill(&q.QRZComDownloadStatus, d.QRZComCfm)
|
||||
// The extras, which fill() cannot reach: it writes through a pointer to a
|
||||
// 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.
|
||||
setExtraDefault(q, hamlogSentKey, d.HamlogStatus)
|
||||
setExtraDefault(q, award.HamlogQSLKey, d.HamlogCfm)
|
||||
}
|
||||
|
||||
// setExtraDefault writes a default into an ADIF extras key, leaving an existing
|
||||
// value alone. A blank default means "say nothing", not "write an empty string":
|
||||
// an empty extra would be carried into every export.
|
||||
func setExtraDefault(q *qso.QSO, key, def string) {
|
||||
def = strings.ToUpper(strings.TrimSpace(def))
|
||||
if def == "" {
|
||||
return
|
||||
}
|
||||
if q.Extras == nil {
|
||||
q.Extras = map[string]string{}
|
||||
}
|
||||
if strings.TrimSpace(q.Extras[key]) == "" {
|
||||
q.Extras[key] = def
|
||||
}
|
||||
}
|
||||
|
||||
// ── External services (logbook upload) ─────────────────────────────────
|
||||
@@ -10673,7 +10825,8 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode, keyExtHRDLogOnAir,
|
||||
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
||||
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
||||
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote)
|
||||
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote,
|
||||
keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
@@ -10752,6 +10905,11 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
AutoUpload: m[keyExtCloudlogAutoUpload] == "1",
|
||||
UploadMode: extsvc.UploadMode(m[keyExtCloudlogUploadMode]),
|
||||
}
|
||||
out.Hamlog = extsvc.ServiceConfig{
|
||||
APIKey: m[keyExtHamlogAPIKey],
|
||||
AutoUpload: m[keyExtHamlogAutoUpload] == "1",
|
||||
UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]),
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -10820,6 +10978,16 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
if cfg.Cloudlog.AutoUpload {
|
||||
clgAuto = "1"
|
||||
}
|
||||
// Same reasoning as Cloudlog: HAMLOG has no per-QSO upload-status column to
|
||||
// select on, so an on-close sweep would have nothing to sweep.
|
||||
hamMode := modeOf(cfg.Hamlog.UploadMode)
|
||||
if hamMode == string(extsvc.ModeOnClose) {
|
||||
hamMode = string(extsvc.ModeImmediate)
|
||||
}
|
||||
hamAuto := "0"
|
||||
if cfg.Hamlog.AutoUpload {
|
||||
hamAuto = "1"
|
||||
}
|
||||
delRemote := "0"
|
||||
if cfg.DeleteRemote {
|
||||
delRemote = "1"
|
||||
@@ -10867,6 +11035,10 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
keyExtCloudlogStationID: strings.TrimSpace(cfg.Cloudlog.StationID),
|
||||
keyExtCloudlogAutoUpload: clgAuto,
|
||||
keyExtCloudlogUploadMode: clgMode,
|
||||
|
||||
keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey),
|
||||
keyExtHamlogAutoUpload: hamAuto,
|
||||
keyExtHamlogUploadMode: hamMode,
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||
return err
|
||||
@@ -10947,6 +11119,13 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
|
||||
if a.qso == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
// HAMLOG.online keeps its state in an ADIF extra rather than a column, so
|
||||
// its backlog is "the extra is absent" instead of "the column is blank".
|
||||
// The sent-status filter has nothing to select on — a QSO has gone or it has
|
||||
// not — so it is ignored rather than pretended to work.
|
||||
if extsvc.Service(service) == extsvc.ServiceHamlog {
|
||||
return a.qso.ListMissingExtra(a.ctx, hamlogSentKey)
|
||||
}
|
||||
col := uploadColumnFor(service)
|
||||
if col == "" {
|
||||
return nil, fmt.Errorf("unknown service %q", service)
|
||||
@@ -10962,7 +11141,7 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
svc := extsvc.Service(service)
|
||||
if uploadColumnFor(service) == "" {
|
||||
if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog {
|
||||
return fmt.Errorf("unknown service %q", service)
|
||||
}
|
||||
cfg := a.loadExternalServices()
|
||||
@@ -12768,6 +12947,15 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
||||
// and every QSO is eligible. The cost is that a permanently failed upload
|
||||
// is not remembered — hence no on-close mode and no manual backlog.
|
||||
return true
|
||||
case extsvc.ServiceHamlog:
|
||||
// The stamp is an extra, not a column — see markExtUploaded. Present means
|
||||
// it has gone, which is what stops an on-demand re-upload of a whole log
|
||||
// from sending every contact twice.
|
||||
if q.Extras != nil && strings.TrimSpace(q.Extras[hamlogSentKey]) != "" {
|
||||
applog.Printf("extsvc: QSO %d not eligible for hamlog — already sent on %s", id, q.Extras[hamlogSentKey])
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case extsvc.ServiceLoTW:
|
||||
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
||||
if strings.EqualFold(q.LOTWSent, f) {
|
||||
@@ -12781,6 +12969,15 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
||||
|
||||
// markExtUploaded stamps the per-service upload status on the QSO row and
|
||||
// tells the frontend to refresh that row's confirmation columns.
|
||||
// hamlogSentKey says a QSO has gone to HAMLOG.online; hamlogSentDateKey says
|
||||
// when. Split the way every other service splits it — a status and a date —
|
||||
// and shared with the row colours (lib/rowColors.ts), the QSL Manager's backlog
|
||||
// and the bulk editor, so all four agree on what "already sent" means.
|
||||
const (
|
||||
hamlogSentKey = "APP_OPSLOG_HAMLOG_SENT"
|
||||
hamlogSentDateKey = "APP_OPSLOG_HAMLOG_SENT_DATE"
|
||||
)
|
||||
|
||||
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
||||
date := time.Now().UTC().Format("20060102")
|
||||
// Use a fresh background context, NOT a.ctx: this stamp often runs during
|
||||
@@ -12817,6 +13014,15 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
||||
case extsvc.ServiceCloudlog:
|
||||
// Nothing to stamp — see extShouldUpload. Still logged and announced so
|
||||
// the upload is visible in the diagnostic log and the UI.
|
||||
case extsvc.ServiceHamlog:
|
||||
// An ADIF EXTRA, not a column: the standard names a field for hamlog.EU
|
||||
// and none for hamlog.ONLINE, and borrowing the other site's would write
|
||||
// a falsehood into every export. The date rather than a Y, so a log tells
|
||||
// WHEN a contact went — and so the same shape serves the QSL Manager's
|
||||
// backlog query, which asks "is this key present at all".
|
||||
if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil {
|
||||
err = a.qso.SetExtra(ctx, id, hamlogSentDateKey, date)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
|
||||
@@ -14892,6 +15098,13 @@ func (a *App) reloadCAT() {
|
||||
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
|
||||
a.catFlexDecodeSecs = s.FlexDecodeSecs
|
||||
a.catFlexDVKDax = s.Enabled && s.Backend == "flex" && s.FlexDVKDax
|
||||
// The recorder's source depends on the backend: over the Icom LAN there is no
|
||||
// sound card to capture, the audio is pushed in. Restarted only when the
|
||||
// answer CHANGES, so an ordinary settings save never interrupts a recording.
|
||||
if want := s.Enabled && s.Backend == "icom-net" && s.IcomNetAudio; want != a.qsoRecPushed {
|
||||
applog.Printf("qso-rec: audio source changes (network=%v) — restarting the recorder", want)
|
||||
go a.startQSORecorderIfEnabled()
|
||||
}
|
||||
a.reloadCATShare(s)
|
||||
// Nothing about the link changed → leave it connected. See catLinkSig.
|
||||
if sig := catLinkSig(s); sig == a.catSig {
|
||||
@@ -14927,6 +15140,9 @@ func (a *App) reloadCAT() {
|
||||
// looking at the same signal, and only the click's origin differs.
|
||||
a.FlexZoomForSpot(mode, hz)
|
||||
}
|
||||
// Spots posted by ANOTHER program — a CW skimmer, in practice. See
|
||||
// flexrstchase.go: a marked report is where the DX was just listening.
|
||||
fb.OnForeignSpot = a.handleForeignSpot
|
||||
a.cat.Start(fb)
|
||||
case "xiegu":
|
||||
// Xiegu G90/X6100/X6200/X5105 — CI-V, but a REDUCED command set: no scope,
|
||||
@@ -15000,8 +15216,16 @@ func (a *App) reloadCAT() {
|
||||
} else {
|
||||
codec := audio.NewPCM16Codec()
|
||||
audioSink = func(payload []byte) {
|
||||
if pcm, err := codec.Decode(payload); err == nil {
|
||||
pcm, err := codec.Decode(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
a.audioMgr.PushMonitorAudio(pcm)
|
||||
// And to the QSO recorder, which has no device to capture from
|
||||
// on this backend. It drops the samples unless a recording is
|
||||
// actually running, so this costs a function call when idle.
|
||||
if a.qsoRec != nil {
|
||||
a.qsoRec.PushRX(pcm)
|
||||
}
|
||||
}
|
||||
applog.Printf("icom-net audio: RX audio streaming ENABLED (experimental) → %q", acfg.ListeningDevice)
|
||||
@@ -19839,6 +20063,86 @@ func (a *App) SetYaesuPreamp(n int) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuPreamp(n) })
|
||||
}
|
||||
|
||||
// SetYaesuAntenna selects an antenna jack on a Yaesu, and REMEMBERS it for the
|
||||
// band the rig is on.
|
||||
//
|
||||
// No settings page for this, deliberately. A per-band antenna table is one more
|
||||
// form to fill in and one more menu to find, for a fact the operator states
|
||||
// perfectly well by choosing the antenna once on that band. So the choice is
|
||||
// learnt where it is made, and re-applied on the next visit — see
|
||||
// applyYaesuBandAntenna.
|
||||
func (a *App) SetYaesuAntenna(n int) error {
|
||||
if err := a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAntenna(n) }); err != nil {
|
||||
return err
|
||||
}
|
||||
if band := strings.ToLower(strings.TrimSpace(a.cat.State().Band)); band != "" && n > 0 {
|
||||
m := a.yaesuBandAntennas()
|
||||
if m[band] != n {
|
||||
m[band] = n
|
||||
a.saveYaesuBandAntennas(m)
|
||||
applog.Printf("yaesu: antenna %d remembered for %s", n, band)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// keyYaesuBandAnt holds the learnt band → antenna map, as JSON.
|
||||
const keyYaesuBandAnt = "yaesu.band_antennas"
|
||||
|
||||
func (a *App) yaesuBandAntennas() map[string]int {
|
||||
out := map[string]int{}
|
||||
if raw := a.settingOr(keyYaesuBandAnt, ""); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) saveYaesuBandAntennas(m map[string]int) {
|
||||
if b, err := json.Marshal(m); err == nil {
|
||||
a.setSetting(keyYaesuBandAnt, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// GetYaesuBandAntennas exposes the learnt map, so the panel can show which band
|
||||
// carries which antenna without the operator having to change band to find out.
|
||||
func (a *App) GetYaesuBandAntennas() map[string]int { return a.yaesuBandAntennas() }
|
||||
|
||||
// ForgetYaesuBandAntenna drops the memory for one band — the way to undo a
|
||||
// choice made by mistake, without a table to edit.
|
||||
func (a *App) ForgetYaesuBandAntenna(band string) {
|
||||
m := a.yaesuBandAntennas()
|
||||
delete(m, strings.ToLower(strings.TrimSpace(band)))
|
||||
a.saveYaesuBandAntennas(m)
|
||||
}
|
||||
|
||||
// YaesuApplyBandAntenna is applyYaesuBandAntenna as a binding, called from the
|
||||
// same place the Flex one is — a band change or a spot click.
|
||||
func (a *App) YaesuApplyBandAntenna(band string) error {
|
||||
a.applyYaesuBandAntenna(band)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyYaesuBandAntenna puts the remembered antenna back when the rig changes
|
||||
// band. Silent when nothing was ever learnt for it: an operator who has not
|
||||
// expressed a preference for 17 m must not have their antenna moved.
|
||||
func (a *App) applyYaesuBandAntenna(band string) {
|
||||
b := strings.ToLower(strings.TrimSpace(band))
|
||||
if b == "" || a.cat == nil {
|
||||
return
|
||||
}
|
||||
want := a.yaesuBandAntennas()[b]
|
||||
if want <= 0 {
|
||||
return
|
||||
}
|
||||
_ = a.cat.YaesuDo(func(y cat.YaesuController) error {
|
||||
if st := y.YaesuState(); st.Antenna == want || st.Antenna == 0 {
|
||||
return nil // already there, or a rig with a single socket
|
||||
}
|
||||
applog.Printf("yaesu: band %s → antenna %d (remembered)", b, want)
|
||||
return y.SetYaesuAntenna(want)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) SetYaesuAtt(db int) error {
|
||||
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAtt(db) })
|
||||
}
|
||||
@@ -20227,3 +20531,16 @@ func (a *App) countryFor(call string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CheckHamlogKey validates a HAMLOG.online API key and returns the callsign it
|
||||
// belongs to.
|
||||
//
|
||||
// Its own binding rather than part of a save: the answer names the ACCOUNT, so
|
||||
// an operator who pasted a club's key, or one that expired last month, learns it
|
||||
// while looking at the field they just filled in — not from QSOs that quietly
|
||||
// never arrive.
|
||||
func (a *App) CheckHamlogKey(key string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
return extsvc.CheckHamlogKey(ctx, nil, key)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ var deniedCallHashes = map[string]struct{}{
|
||||
"d1ae6212ec057f9d5c6f379fb57acedac7c94142c9d49667dc04b2016d03d1b6": {},
|
||||
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
|
||||
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
|
||||
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
|
||||
}
|
||||
|
||||
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ type RowColorRule struct {
|
||||
ID string `json:"id"`
|
||||
Color string `json:"color"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// Channels this rule looks at: qsl (paper), lotw, eqsl, qrz.
|
||||
// Channels this rule looks at: qsl (paper), lotw, eqsl, qrz, hamlog.
|
||||
//
|
||||
// Empty means ALL of them — what an operator expects from a rule they have
|
||||
// not narrowed, and what keeps a config written before this field meaningful.
|
||||
@@ -27,7 +27,7 @@ type RowColorRule struct {
|
||||
}
|
||||
|
||||
// The QSL channels a rule can be scoped to.
|
||||
var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz"}
|
||||
var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz", "hamlog"}
|
||||
|
||||
// RowColorSettings is the whole appearance block.
|
||||
type RowColorSettings struct {
|
||||
|
||||
@@ -1,4 +1,96 @@
|
||||
[
|
||||
{
|
||||
"version": "0.26.10",
|
||||
"date": "",
|
||||
"en": [
|
||||
"The HAMLOG.online confirmation import now fills the Results view with the contacts it confirmed, flagged new entity / band / mode / slot, and can export the unmatched ones as ADIF. Those are the interesting half: each is a contact their site holds and the log does not confirm — a minute of drift, a portable call, or a QSO genuinely missing.",
|
||||
"Awards: 'county' joins the searchable QSO fields — the CNTY field as it stands. The existing 'us_county' keys the county to its state, which is right for the United States and wrong everywhere else: an RDA district or a Japanese city code is already unique.",
|
||||
"*** FlexRadio: a split pile-up chaser. When a CW skimmer (SDC) marks a report on the panadapter, OpsLog moves the TRANSMIT slice there — where the DX was listening a second ago — plus a signed offset in Hz; the receive slice never moves. The button sits on the FlexRadio panel beside SPLIT, with the offset next to it. Right-click the button to set the marker text — it has to match what SDC is set to write (599 by default, several allowed), so it is edited where it is switched on rather than in a settings page.***",
|
||||
"The radio's spot feed is now subscribed to even when OpsLog draws no spots of its own — that feed is how another program's spots arrive. Clearing the panadapter at connect still only happens when the overlay is OpsLog's: 'spot clear' removes every spot on the radio, a skimmer's included.",
|
||||
"CW keyer: fixed Enter sending nothing on the Icom, Yaesu and Kenwood engines. Send-on-type only exists on the WinKeyer and Flex CWX, but the setting survived a change of engine — so the switch stayed on, invisibly, and the text field believed everything had already been keyed. Macros were unaffected, which is what made it puzzling."
|
||||
],
|
||||
"fr": [
|
||||
"L'import des confirmations HAMLOG.online alimente maintenant la vue Résultats avec les contacts confirmés, marqués nouvelle entité / bande / mode / slot, et peut exporter les non-rapprochés en ADIF. C'est la moitié intéressante : chacun est un contact que leur site détient et que le journal ne confirme pas — minute décalée, indicatif portable, ou QSO réellement absent.",
|
||||
"Diplômes : « county » rejoint les champs de QSO interrogeables — le champ CNTY tel quel. Le « us_county » existant associe le comté à son État, ce qui est juste aux États-Unis et faux ailleurs : un district RDA ou un code de ville japonais est déjà unique.",
|
||||
"*** FlexRadio : chasseur de pile-up en split. Quand un skimmer CW (SDC) marque un report sur le panadapter, OpsLog déplace la slice d'ÉMISSION dessus — là où le DX écoutait une seconde plus tôt — plus un décalage signé en Hz ; la slice de réception ne bouge jamais. Le bouton est sur le panneau FlexRadio à côté de SPLIT, avec le décalage juste à côté. Clic droit sur le bouton pour régler le texte du marqueur — il doit correspondre à ce que SDC est réglé à écrire (599 par défaut, plusieurs possibles), donc il se modifie là où on l'active plutôt que dans les réglages.***",
|
||||
"Le flux de spots de la radio est désormais suivi même si OpsLog n'affiche aucun spot : c'est par lui qu'arrivent les spots des autres programmes. Le nettoyage du panadapter à la connexion reste réservé au cas où l'affichage est celui d'OpsLog : « spot clear » efface tous les spots de la radio, y compris ceux d'un skimmer.",
|
||||
"Manipulateur CW : correction d'Entrée qui n'envoyait rien sur les moteurs Icom, Yaesu et Kenwood. L'émission au fil de la frappe n'existe que sur le WinKeyer et le Flex CWX, mais le réglage survivait à un changement de moteur — l'interrupteur restait donc actif, invisible, et le champ de texte croyait que tout avait déjà été émis. Les macros n'étaient pas touchées, ce qui rendait la chose incompréhensible."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.9",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Edit QSO: HAMLOG.online joins the QSL Info tab — its own channel in the picker, with sent/received and both dates, and a row in the status table. Its four columns also move from the QSL group to Uploads, next to Club Log and QRZ.com.",
|
||||
"The filter can now match on when a QSO was added to the log ('Added to the log on'), which is the only way to isolate what an import brought in — an import of old contacts carries old QSO dates and otherwise hides inside the log.",
|
||||
"QSL Manager: HAMLOG.online gains 'Import confirmations (ADIF)…'. Their site exports a log, this reads it back and stamps the confirmations on QSOs already present — it never inserts. Importing that same file through the ordinary ADIF import does insert, because it matches on the UTC minute and their export rarely agrees to the minute.",
|
||||
"Deleting QSOs now says how many rows were actually removed, and writes it to the log. A delete that removes nothing used to look exactly like one that worked.",
|
||||
"Deleting QSOs is fast again when 'delete from the remote services' is on. Club Log is only asked about contacts that were actually uploaded to it — asking about the others earned a 403 each time, one network round trip per QSO — the withdrawals now run in the background instead of holding the window, the rows behind a selection are read in one query rather than one per QSO, and repeated refusals stop the run rather than earning a longer block."
|
||||
],
|
||||
"fr": [
|
||||
"Édition de QSO : HAMLOG.online rejoint l'onglet QSL Info — son propre canal dans la liste, avec envoyé/reçu et les deux dates, et une ligne dans le tableau de statut. Ses quatre colonnes passent aussi du groupe QSL au groupe Uploads, à côté de Club Log et QRZ.com.",
|
||||
"Le filtre peut maintenant porter sur la date d'ajout au journal (« Ajouté au journal le »), seul moyen d'isoler ce qu'un import a apporté : un import de vieux contacts porte de vieilles dates de QSO et se fond sinon dans le journal.",
|
||||
"Gestionnaire QSL : HAMLOG.online reçoit « Importer les confirmations (ADIF)… ». Leur site exporte un log, cette fonction le relit et appose les confirmations sur les QSO déjà présents — elle n'ajoute jamais rien. Le même fichier passé par l'import ADIF ordinaire, lui, ajoute : il compare à la minute UTC près et leur export s'accorde rarement à la minute.",
|
||||
"La suppression de QSO indique maintenant combien de lignes ont réellement été supprimées, et l'écrit dans le journal. Une suppression sans effet ressemblait exactement à une suppression réussie.",
|
||||
"La suppression de QSO redevient rapide quand « supprimer aussi des services externes » est activé. Club Log n'est plus interrogé que pour les contacts qui y ont réellement été envoyés — pour les autres il répondait 403, un aller-retour réseau par QSO — les retraits se font désormais en arrière-plan au lieu de bloquer la fenêtre, les lignes d'une sélection sont lues en une seule requête au lieu d'une par QSO, et une série de refus interrompt le traitement au lieu d'aggraver le blocage."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.8",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Yaesu: an ANT row in the rig panel selects the antenna jack, and the choice is remembered for the band it was made on — change band and the antenna follows. There is no page to configure: picking the antenna once on a band says it perfectly well. Rigs with a single socket never show the row.",
|
||||
"HAMLOG.online: QSOs can be uploaded as they are logged, like QRZ.com or Club Log. The API key is checked in the settings before the first contact — the answer names the account it belongs to, so a key pasted from another callsign or an expired one is caught at once instead of after a week of silent refusals.",
|
||||
"HAMLOG.online is also a confirmation source for awards, alongside LoTW, QSL, eQSL and QRZ.com — tick it under Confirmed, under Validated, or both.",
|
||||
"A rig whose serial port is refused now says who is likely holding it — OmniRig stays resident and keeps the port of the rig configured in it, which is what a native backend then never gets. \"Serial port busy\" alone named nobody.",
|
||||
"The row colours in Appearance can be scoped to HAMLOG.online as well, alongside paper, LoTW, eQSL and QRZ.com.",
|
||||
"HAMLOG.online appears in the QSL Manager too: pick it to see every QSO never sent there and upload the backlog in one go. A contact already sent carries the date it went, so a second sweep does not send it twice.",
|
||||
"Right-clicking QSOs offers HAMLOG.online among the upload targets, and the toast names each service properly instead of echoing its internal id.",
|
||||
"Bulk edit can set HAMLOG.online sent and received, each with its status and its date — the same four fields every other service has there. A log uploaded to their site by hand had no way to be marked as sent, so OpsLog kept offering to upload every contact again.",
|
||||
"HAMLOG.online joins the Confirmations defaults and the QSO grid columns — sent and received, each with its status and date, hidden by default like the other extras-backed columns.",
|
||||
"Removed the explanatory paragraph from the Confirmations page.",
|
||||
"HAMLOG.online confirmations are read from the site's own ADIF field, APP_HAMLOG_QSO_CFM, taken from a real export. Import a log downloaded from HAMLOG and its confirmations count for awards straight away, with nothing to rename and no mapping to configure.",
|
||||
"The four HAMLOG.online fields are filterable too, so 'never sent there' is a filter and not a guess.",
|
||||
"RDA: a read-only comparison of the two district sources — the offline RDA database against the CNTY carried by a log imported from HAMLOG.online — reporting how many agree, how many differ, and listing the disagreements with the contact, both districts, and whether the database answer was a dated record or an assumption.",
|
||||
"The QSO right-click menu can now export the selection to ADIF with the OpsLog fields, so the award references (RDA@KR-04 and the like) travel with the contacts. The existing entry still writes standard ADIF for other loggers.",
|
||||
"The filtered-view export gets the same treatment: a second right-click entry writes the whole filtered view to ADIF with the OpsLog fields."
|
||||
],
|
||||
"fr": [
|
||||
"Yaesu : une ligne ANT dans le panneau du poste sélectionne la prise d'antenne, et le choix est mémorisé pour la bande sur laquelle il a été fait — on change de bande, l'antenne suit. Aucune page à configurer : choisir l'antenne une fois sur une bande le dit très bien. Les postes à une seule prise n'affichent jamais la ligne.",
|
||||
"HAMLOG.online : les QSO peuvent être envoyés au fil de leur enregistrement, comme QRZ.com ou Club Log. La clé API se vérifie dans les réglages avant le premier contact — la réponse nomme le compte auquel elle appartient, donc une clé d'un autre indicatif ou expirée se voit tout de suite au lieu d'après une semaine de refus silencieux.",
|
||||
"HAMLOG.online est aussi une source de confirmation pour les diplômes, aux côtés de LoTW, QSL, eQSL et QRZ.com — à cocher dans Confirmé, dans Validé, ou les deux.",
|
||||
"Un poste dont le port série est refusé indique maintenant qui le détient vraisemblablement — OmniRig reste résident et garde le port de la radio configurée chez lui, que le backend natif n'obtient alors jamais. « Serial port busy » ne désignait personne.",
|
||||
"Les couleurs de lignes dans Apparence peuvent aussi être limitées à HAMLOG.online, aux côtés du papier, de LoTW, d'eQSL et de QRZ.com.",
|
||||
"HAMLOG.online figure aussi dans le gestionnaire QSL : on le choisit pour voir tous les QSO jamais envoyés là-bas et expédier l'arriéré d'un coup. Un contact déjà envoyé porte la date de son départ, donc un second passage ne le renvoie pas.",
|
||||
"Le clic droit sur des QSO propose HAMLOG.online parmi les destinations d'envoi, et la notification nomme correctement chaque service au lieu d'afficher son identifiant interne.",
|
||||
"L'édition en masse peut renseigner HAMLOG.online envoyé et reçu, chacun avec son statut et sa date — les mêmes quatre champs que les autres services. Un journal téléversé à la main sur leur site n'avait aucun moyen d'être marqué comme envoyé, et OpsLog proposait donc de renvoyer tous les contacts.",
|
||||
"HAMLOG.online rejoint les valeurs par défaut des Confirmations et les colonnes de la grille des QSO — envoyé et reçu, chacun avec son statut et sa date, masquées par défaut comme les autres colonnes issues des extras.",
|
||||
"Suppression du paragraphe explicatif de la page Confirmations.",
|
||||
"Les confirmations HAMLOG.online sont lues dans le champ ADIF du site lui-même, APP_HAMLOG_QSO_CFM, relevé sur un export réel. Il suffit d'importer un journal téléchargé depuis HAMLOG pour que ses confirmations comptent aussitôt pour les diplômes, sans rien renommer ni configurer.",
|
||||
"Les quatre champs HAMLOG.online sont aussi filtrables, pour que « jamais envoyé là-bas » soit un filtre et non une supposition.",
|
||||
"RDA : une comparaison en lecture seule des deux sources de district — la base RDA hors ligne face au CNTY d'un journal importé depuis HAMLOG.online — indiquant combien concordent, combien divergent, et listant les désaccords avec le contact, les deux districts, et si la réponse de la base était un enregistrement daté ou une hypothèse.",
|
||||
"Le menu contextuel des QSO peut maintenant exporter la sélection en ADIF avec les champs OpsLog, pour que les références de diplômes (RDA@KR-04 et compagnie) suivent les contacts. L'entrée existante écrit toujours de l'ADIF standard pour les autres carnets.",
|
||||
"L'export de la vue filtrée reçoit le même traitement : une seconde entrée du menu contextuel écrit toute la vue filtrée en ADIF avec les champs OpsLog."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.7",
|
||||
"date": "",
|
||||
"en": [
|
||||
"The NEW badge on a county now shows in the entry form itself, inside the field, and not only on the Info tab — it answers the question at the moment the operator is deciding whether to call.",
|
||||
"OmniRig: a Yaesu is no longer sent OmniRig's SetSimplexMode when tuning. It is a no-op on some (an FT-891 never moved) and harmful on others — on an FT-2000 it turned split on and moved reception to VFO B, which is why OpsLog then showed B instead of A. The direct frequency write, which is what tunes these rigs, is unchanged.",
|
||||
"The basemap buttons on the main map are shifted clear of the zoom controls — Light sat a few pixels from the minus button and was being clicked by mistake.",
|
||||
"Cluster, French interface: the WKD CALL status now reads DÉJÀ QSO instead of DÉJÀ CTC.",
|
||||
"Icom over LAN: the receive audio streamed by the rig now feeds the QSO recorder as well as the speakers. On a network connection Windows sees no radio sound card at all — the audio settings could only offer the PC microphone — so the stream is pushed straight into the recorder, with no virtual cable to set up."
|
||||
],
|
||||
"fr": [
|
||||
"Le badge NOUV du comté s'affiche maintenant dans le formulaire de saisie lui-même, à l'intérieur du champ, et plus seulement dans l'onglet Info — il répond au moment où l'opérateur décide d'appeler ou non.",
|
||||
"OmniRig : le SetSimplexMode d'OmniRig n'est plus envoyé aux Yaesu lors d'un changement de fréquence. Il est sans effet sur certains (un FT-891 ne bougeait pas) et nuisible sur d'autres — sur un FT-2000 il activait le split et faisait passer la réception sur le VFO B, d'où l'affichage du B au lieu du A. L'écriture directe de la fréquence, qui est ce qui accorde réellement ces postes, ne change pas.",
|
||||
"Les boutons de fond de carte sont décalés à l'écart des commandes de zoom — Light était à quelques pixels du bouton moins et se faisait cliquer par erreur.",
|
||||
"Cluster, interface française : le statut « DÉJÀ CTC » devient « DÉJÀ QSO ».",
|
||||
"Icom en réseau : l'audio reçu diffusé par le poste alimente maintenant l'enregistreur de QSO en plus des haut-parleurs. Sur une liaison réseau, Windows ne voit aucune carte son de la radio — les réglages audio ne proposaient que le micro du PC — donc le flux est injecté directement dans l'enregistreur, sans câble virtuel à installer."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.6",
|
||||
"date": "",
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
// Chasing a split pile-up by the report the DX just sent.
|
||||
//
|
||||
// Working a DXpedition in split means guessing where it is listening. The DX
|
||||
// answers one station, sends "5NN", and moves on; the useful information is
|
||||
// therefore not the callsign it answered but the FREQUENCY that callsign was
|
||||
// transmitting on, because the DX's receiver was there a second ago.
|
||||
//
|
||||
// A CW skimmer already knows this. SDC (Software Defined Connector) decodes the
|
||||
// whole pile-up and marks each report on the panadapter as a spot — the marker
|
||||
// TEXT is configured in SDC by the operator ("599" for a fresh report, "X" for
|
||||
// an older one, by default). Those spots reach OpsLog already: it subscribes to
|
||||
// the radio's spot feed, and every spot another program posts arrives on
|
||||
// Flex.OnForeignSpot.
|
||||
//
|
||||
// So the whole feature is: recognise the marker, move the TRANSMIT slice there,
|
||||
// leave the receive slice on the DX. Nothing here decodes anything.
|
||||
//
|
||||
// Three deliberate choices:
|
||||
//
|
||||
// - the marker text is a SETTING, not a constant. It is chosen in SDC, and
|
||||
// any guess made here would be wrong for the operator who chose otherwise.
|
||||
// - only the transmit slice moves, never the receive slice. Losing the DX is
|
||||
// a worse outcome than a missed call.
|
||||
// - the offset is signed and in Hz, because working "up a bit" from where the
|
||||
// last station was answered is exactly how a pile-up is chased.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
const keyFlexRSTChase = "flex.rst_chase"
|
||||
|
||||
// FlexRSTChase is the whole configuration.
|
||||
type FlexRSTChase struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// Markers are the skimmer's marker texts, comma-separated ("599,5NN").
|
||||
// Matched against the spot's callsign field, case-insensitively and whole:
|
||||
// a spot IS the marker or it is an ordinary callsign, and a substring rule
|
||||
// would drag in any station whose call happens to contain the digits.
|
||||
Markers string `json:"markers"`
|
||||
// OffsetHz is added to the marker's frequency. Signed: chasing upward from
|
||||
// the last station worked is the usual tactic, downward happens too.
|
||||
OffsetHz int `json:"offset_hz"`
|
||||
// SplitOnly refuses to act when the radio is not in split. On by default:
|
||||
// out of split the transmit slice IS the receive slice, so "move the TX
|
||||
// slice" would take the operator off the DX they are listening to.
|
||||
SplitOnly bool `json:"split_only"`
|
||||
}
|
||||
|
||||
var defaultFlexRSTChase = FlexRSTChase{Enabled: false, Markers: "599", OffsetHz: 0, SplitOnly: true}
|
||||
|
||||
// rstChaseMinGap throttles the moves. A skimmer marks every report it decodes,
|
||||
// and a busy pile-up produces several a second; without a floor the transmit
|
||||
// slice would twitch continuously and never be anywhere long enough to call.
|
||||
const rstChaseMinGap = 700 * time.Millisecond
|
||||
|
||||
// rstChaseMinStep ignores a marker that lands where the slice already is.
|
||||
// Re-sending the same frequency is not free: it is a command to the radio and a
|
||||
// slice status back, several times a second, for no change at all.
|
||||
const rstChaseMinStep = 20 // Hz
|
||||
|
||||
var (
|
||||
rstChaseMu sync.Mutex
|
||||
rstChaseLast time.Time
|
||||
rstChaseFreq int64
|
||||
)
|
||||
|
||||
// GetFlexRSTChase returns the stored configuration (defaults when unset).
|
||||
func (a *App) GetFlexRSTChase() FlexRSTChase {
|
||||
s := defaultFlexRSTChase
|
||||
if a.settings == nil {
|
||||
return s
|
||||
}
|
||||
// settingOr, NOT settings.Get with profileScope(): the store already applies
|
||||
// the active profile's prefix, so scoping the key here wrote p3.flex.rst_chase
|
||||
// and read p3.p3.flex.rst_chase — the switch could never be read back on, and
|
||||
// the feature did nothing at all with no sign of why.
|
||||
if v := a.settingOr(keyFlexRSTChase, ""); strings.TrimSpace(v) != "" {
|
||||
_ = json.Unmarshal([]byte(v), &s)
|
||||
}
|
||||
return normRSTChase(s)
|
||||
}
|
||||
|
||||
// SaveFlexRSTChase stores the configuration.
|
||||
func (a *App) SaveFlexRSTChase(s FlexRSTChase) error {
|
||||
b, err := json.Marshal(normRSTChase(s))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setSetting(keyFlexRSTChase, string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetFlexRSTChaseEnabled flips the switch alone.
|
||||
//
|
||||
// Its own binding because this belongs on a button in the panel, not in a
|
||||
// settings page: it is turned on when a DXpedition appears and off when it is
|
||||
// worked, which is a thing done mid-QSO with one hand.
|
||||
func (a *App) SetFlexRSTChaseEnabled(on bool) error {
|
||||
s := a.GetFlexRSTChase()
|
||||
s.Enabled = on
|
||||
return a.SaveFlexRSTChase(s)
|
||||
}
|
||||
|
||||
func normRSTChase(s FlexRSTChase) FlexRSTChase {
|
||||
if strings.TrimSpace(s.Markers) == "" {
|
||||
s.Markers = defaultFlexRSTChase.Markers
|
||||
}
|
||||
// A pile-up is a few kHz wide. Anything past that is a typo (Hz entered as
|
||||
// if it were kHz), and honouring it would transmit far outside the segment.
|
||||
if s.OffsetHz > 10000 {
|
||||
s.OffsetHz = 10000
|
||||
}
|
||||
if s.OffsetHz < -10000 {
|
||||
s.OffsetHz = -10000
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// rstChaseMarkerSet splits the configured markers into a comparison set.
|
||||
func rstChaseMarkerSet(markers string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range strings.FieldsFunc(markers, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
|
||||
if m = strings.ToUpper(strings.TrimSpace(m)); m != "" {
|
||||
out[m] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleForeignSpot is what a skimmer's spot arrives at.
|
||||
func (a *App) handleForeignSpot(callsign string, freqHz int64) {
|
||||
cfg := a.GetFlexRSTChase()
|
||||
if !cfg.Enabled || a.cat == nil {
|
||||
return
|
||||
}
|
||||
if !rstChaseMarkerSet(cfg.Markers)[strings.ToUpper(strings.TrimSpace(callsign))] {
|
||||
return // an ordinary spot: another station's callsign, not a report
|
||||
}
|
||||
// From here on every refusal is logged. A marker WAS recognised, so the
|
||||
// operator is entitled to know why the slice stayed where it was — silence
|
||||
// at this point is indistinguishable from a feature that does not work.
|
||||
if cfg.SplitOnly && !a.cat.State().Split {
|
||||
applog.Printf("rst chase: %s marked at %s, but the radio is not in split — ignored",
|
||||
strings.ToUpper(callsign), hzText(freqHz))
|
||||
return
|
||||
}
|
||||
target := freqHz + int64(cfg.OffsetHz)
|
||||
if target <= 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
rstChaseMu.Lock()
|
||||
if now.Sub(rstChaseLast) < rstChaseMinGap {
|
||||
rstChaseMu.Unlock()
|
||||
return
|
||||
}
|
||||
if rstChaseFreq != 0 && absInt64(target-rstChaseFreq) < rstChaseMinStep {
|
||||
rstChaseMu.Unlock()
|
||||
return
|
||||
}
|
||||
rstChaseLast, rstChaseFreq = now, target
|
||||
rstChaseMu.Unlock()
|
||||
|
||||
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||
return fc.SetTXSliceFrequency(target)
|
||||
}); err != nil {
|
||||
applog.Printf("rst chase: moving the TX slice to %s failed: %v", hzText(target), err)
|
||||
return
|
||||
}
|
||||
applog.Printf("rst chase: %s marked at %s → TX slice to %s (offset %+d Hz)",
|
||||
strings.ToUpper(callsign), hzText(freqHz), hzText(target), cfg.OffsetHz)
|
||||
}
|
||||
|
||||
// hzText renders a frequency the way an operator reads one on a dial.
|
||||
func hzText(hz int64) string {
|
||||
return strconv.FormatFloat(float64(hz)/1000, 'f', 3, 64) + " kHz"
|
||||
}
|
||||
+62
-16
@@ -18,7 +18,7 @@ import {
|
||||
WorkedBefore,
|
||||
SetCompactMode, SetCompactHeight,
|
||||
RotatorGoToPath,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower, YaesuApplyBandAntenna,
|
||||
GetSecretStatus, UnlockSecrets,
|
||||
RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||
GetDBConnectionInfo, GetLogbookRevision,
|
||||
@@ -3297,11 +3297,16 @@ export default function App() {
|
||||
// backends. The backend no-ops if the band has no configured mapping.
|
||||
const lastAntBandRef = useRef('');
|
||||
useEffect(() => {
|
||||
if (catState.backend !== 'flex' || locks.band) return;
|
||||
if (locks.band) return;
|
||||
const b = band.trim();
|
||||
if (!b || b === lastAntBandRef.current) return;
|
||||
lastAntBandRef.current = b;
|
||||
FlexApplyBandAntenna(b).catch(() => {});
|
||||
// Flex configures its antennas per band in Preferences; a Yaesu LEARNS
|
||||
// them — the antenna picked on a band is remembered for that band. Two
|
||||
// ways to say the same thing, and each backend keeps the one that suits
|
||||
// it: a Flex names its antennas, a Yaesu has two sockets and a knob.
|
||||
if (catState.backend === 'flex') FlexApplyBandAntenna(b).catch(() => {});
|
||||
if (catState.backend === 'yaesu') YaesuApplyBandAntenna(b).catch(() => {});
|
||||
}, [band, catState.backend, locks.band]);
|
||||
|
||||
// Per-band, per-mode TX power. Applied on band AND mode changes — the point is
|
||||
@@ -4198,7 +4203,13 @@ export default function App() {
|
||||
// background; qslmgr:done refreshes the grid when finished.
|
||||
async function bulkSendTo(service: string, ids: number[]) {
|
||||
if (ids.length === 0) return;
|
||||
const label = service === 'qrz' ? 'QRZ.com' : service === 'clublog' ? 'Club Log' : service === 'lotw' ? 'LoTW' : service;
|
||||
// Named rather than echoed: an operator who picked a menu entry should see
|
||||
// the same words back, not the internal id.
|
||||
const LABELS: Record<string, string> = {
|
||||
qrz: 'QRZ.com', clublog: 'Club Log', lotw: 'LoTW',
|
||||
hrdlog: 'HRDLog.net', eqsl: 'eQSL.cc', hamlog: 'HAMLOG.online',
|
||||
};
|
||||
const label = LABELS[service] ?? service;
|
||||
showToast(`Uploading ${ids.length} QSO${ids.length > 1 ? 's' : ''} to ${label}…`);
|
||||
try { await UploadQSOsManual(service, ids as any); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
@@ -4208,21 +4219,38 @@ export default function App() {
|
||||
// fields (optional): a chosen ADIF-tag list — the "choose which fields" export.
|
||||
// Empty = the standard/full behaviour (all fields, includeAppFields=false here).
|
||||
async function exportFilteredADIF(fields: string[] = []) {
|
||||
return exportFilteredADIFAs(false, fields);
|
||||
}
|
||||
// The same filtered export, keeping the APP_OPSLOG_* tags — see
|
||||
// exportSelectedADIFFull for why the award references need them.
|
||||
async function exportFilteredADIFFull() {
|
||||
return exportFilteredADIFAs(true, []);
|
||||
}
|
||||
async function exportFilteredADIFAs(includeApp: boolean, fields: string[]) {
|
||||
try {
|
||||
const path = await SaveADIFFile();
|
||||
if (!path) return;
|
||||
const f = buildActiveFilter();
|
||||
const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any, fields);
|
||||
const r = await ExportADIFFiltered(path, includeApp, { ...f, limit: 0, offset: 0 } as any, fields);
|
||||
showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
// Right-click "Export selected to ADIF": only the highlighted rows.
|
||||
async function exportSelectedADIF(ids: number[], fields: string[] = []) {
|
||||
return exportSelectedADIFAs(ids, false, fields);
|
||||
}
|
||||
// The same export, keeping the APP_OPSLOG_* tags. Award references are the
|
||||
// reason it exists: RDA@KR-04 lives in APP_OPSLOG_AWARDREFS, and a standard
|
||||
// export — rightly — leaves every APP_ tag behind.
|
||||
async function exportSelectedADIFFull(ids: number[]) {
|
||||
return exportSelectedADIFAs(ids, true, []);
|
||||
}
|
||||
async function exportSelectedADIFAs(ids: number[], includeApp: boolean, fields: string[]) {
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
const path = await SaveADIFFile();
|
||||
if (!path) return;
|
||||
const r = await ExportADIFSelected(path, false, ids as any, fields);
|
||||
const r = await ExportADIFSelected(path, includeApp, ids as any, fields);
|
||||
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
@@ -4269,8 +4297,14 @@ export default function App() {
|
||||
if (deletingIds.length === 0) return;
|
||||
const ids = deletingIds;
|
||||
try {
|
||||
if (ids.length === 1) await DeleteQSO(ids[0]);
|
||||
else await DeleteQSOs(ids as any);
|
||||
// Report what was actually removed rather than assuming it matched what
|
||||
// was asked: a delete that silently removes nothing looks exactly like one
|
||||
// that worked, since the rows leave the grid when it reloads either way.
|
||||
if (ids.length === 1) { await DeleteQSO(ids[0]); showToast(t('toast.deletedOne')); }
|
||||
else {
|
||||
const n = await DeleteQSOs(ids as any);
|
||||
showToast(t('toast.deletedN', { n: Number(n ?? 0), asked: ids.length }));
|
||||
}
|
||||
setDeletingIds([]);
|
||||
setSelectedId(null);
|
||||
setSelectedIds([]);
|
||||
@@ -5214,9 +5248,21 @@ export default function App() {
|
||||
};
|
||||
const geoDetailRow = (
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex flex-col flex-1 min-w-0"><Label className="mb-1 h-3.5">{t('field.cnty')}</Label>
|
||||
<Input value={details.cnty ?? ''}
|
||||
{/* The badge sits INSIDE the field: this row is already tight, and a
|
||||
county name is short enough to leave room at its right. It answers the
|
||||
question at the moment the operator is deciding whether to call — the
|
||||
same answer the Info tab gives, one tab away from where they are. */}
|
||||
<div className="flex flex-col flex-1 min-w-0 relative"><Label className="mb-1 h-3.5">{t('field.cnty')}</Label>
|
||||
<Input value={details.cnty ?? ''} className={cn(entryNewCounty && 'pr-12')}
|
||||
onChange={(e) => setDetails((d) => ({ ...d, cnty: e.target.value }))} />
|
||||
{entryNewCounty && (
|
||||
<span
|
||||
title={t('detp.newCountyTip')}
|
||||
className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
|
||||
>
|
||||
{t('detp.newCounty')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col w-[64px] shrink-0"><Label className="mb-1 h-3.5">{t('field.cqz')}</Label>
|
||||
<Input value={details.cqz ?? ''} className="font-mono"
|
||||
@@ -5974,7 +6020,7 @@ export default function App() {
|
||||
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined}
|
||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFull={exportSelectedADIFFull} onExportSelectedFields={exportSelectedFields}
|
||||
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
</div>
|
||||
);
|
||||
@@ -6031,9 +6077,9 @@ export default function App() {
|
||||
onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onBulkEdit={openBulkEdit}
|
||||
onExportSelected={exportSelectedADIF}
|
||||
onExportSelected={exportSelectedADIF} onExportSelectedFull={exportSelectedADIFFull}
|
||||
onExportSelectedFields={exportSelectedFields}
|
||||
onExportFiltered={exportFilteredADIF}
|
||||
onExportFiltered={exportFilteredADIF} onExportFilteredFull={exportFilteredADIFFull}
|
||||
onDelete={(ids) => setDeletingIds(ids)}
|
||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? ""), dxcc: Number(r.dxcc ?? 0) } : null)}
|
||||
@@ -7430,9 +7476,9 @@ export default function App() {
|
||||
onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onBulkEdit={openBulkEdit}
|
||||
onExportSelected={exportSelectedADIF}
|
||||
onExportSelected={exportSelectedADIF} onExportSelectedFull={exportSelectedADIFFull}
|
||||
onExportSelectedFields={exportSelectedFields}
|
||||
onExportFiltered={exportFilteredADIF}
|
||||
onExportFiltered={exportFilteredADIF} onExportFilteredFull={exportFilteredADIFFull}
|
||||
onExportCabrilloSelected={exportSelectedCabrillo}
|
||||
onExportCabrilloFiltered={exportFilteredCabrillo}
|
||||
onDelete={(ids) => setDeletingIds(ids)}
|
||||
@@ -7763,7 +7809,7 @@ export default function App() {
|
||||
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFull={exportSelectedADIFFull} onExportSelectedFields={exportSelectedFields}
|
||||
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ const LABELS: Record<string, string> = {
|
||||
|
||||
// The channels a rule can be scoped to. "worked" is the catch-all — it means
|
||||
// nothing on ANY channel — so narrowing it would say nothing.
|
||||
const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
|
||||
const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz', 'hamlog'] as const;
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
|
||||
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com', hamlog: 'HAMLOG.online',
|
||||
};
|
||||
|
||||
// MatrixColorsSection recolours the band/mode matrix — the PH/CW/DIG grid in the
|
||||
|
||||
@@ -68,7 +68,8 @@ const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
|
||||
const NO_FIELD = '__none__';
|
||||
const CONFIRM_SRC = [
|
||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'hamlog', label: 'HAMLOG.online' },
|
||||
{ id: 'custom', label: 'Custom' },
|
||||
];
|
||||
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||
|
||||
@@ -48,6 +48,12 @@ const FIELDS: FieldDef[] = [
|
||||
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
// HAMLOG.online: no promoted column, written into extras_json (see
|
||||
// qso.bulkEditableExtras). Status and date, like every other service here.
|
||||
{ id: 'hamlog_sent', label: 'bulk.fHamlogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
|
||||
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||
// My station / operator
|
||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||
|
||||
@@ -33,6 +33,8 @@ type FieldType = 'text' | 'number' | 'date' | 'adifdate';
|
||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||
// When the record entered the logbook — the way to isolate what an import added.
|
||||
{ value: 'created_at', label: 'fltb.fCreated', type: 'date' },
|
||||
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
||||
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
||||
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
||||
@@ -86,6 +88,12 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||
// HAMLOG.online: no promoted column, filtered through extras_json (see
|
||||
// qso.filterableExtras).
|
||||
{ value: 'hamlog_sent', label: 'fltb.fHamlogSent', type: 'text' },
|
||||
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
|
||||
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
|
||||
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
|
||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||
GetFlexRSTChase, SaveFlexRSTChase, SetFlexRSTChaseEnabled,
|
||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
||||
@@ -304,6 +305,25 @@ function powerLevelLabel(pl?: string): string {
|
||||
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
||||
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
// Split-pile-up chaser (see flexrstchase.go). Held here so the button can
|
||||
// paint its state without polling the backend for it.
|
||||
const [rstChase, setRstChase] = useState<{ enabled: boolean; markers: string; offset_hz: number; split_only: boolean }>(
|
||||
{ enabled: false, markers: '599', offset_hz: 0, split_only: true });
|
||||
const [rstChaseOffsetText, setRstChaseOffsetText] = useState('0');
|
||||
// The marker text is edited as raw text and only parsed on blur: it is a
|
||||
// comma-separated list, and normalising it on every keystroke would eat the
|
||||
// comma the moment it is typed.
|
||||
const [rstChaseMarkersText, setRstChaseMarkersText] = useState('599');
|
||||
// The marker field is summoned by right-clicking the button, not parked on
|
||||
// the row: it is set once to agree with SDC and then never touched, while the
|
||||
// row it was sitting in is the busiest in the panel.
|
||||
const [rstChaseEditing, setRstChaseEditing] = useState(false);
|
||||
useEffect(() => {
|
||||
GetFlexRSTChase().then((c: any) => {
|
||||
if (!c) return;
|
||||
setRstChase(c); setRstChaseOffsetText(String(c.offset_hz ?? 0)); setRstChaseMarkersText(String(c.markers ?? '599'));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
const [st, setSt] = useState<FlexState>(ZERO);
|
||||
// Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT
|
||||
// block) collapse behind a button so the RECEIVE card doesn't grow tall — only
|
||||
@@ -652,6 +672,59 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
st.split ? 'bg-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}>
|
||||
SPLIT
|
||||
</button>
|
||||
{/* The split chaser. On the panel and not in a settings page:
|
||||
it is switched on when a DXpedition appears and off when it is
|
||||
in the log, which is done mid-QSO with one hand. The offset
|
||||
sits beside it because it is the one number retuned while
|
||||
chasing — everything else about the feature is configured once. */}
|
||||
<button type="button" disabled={off}
|
||||
title={t('flxp.rstChaseHint', { m: rstChase.markers })}
|
||||
onContextMenu={(e) => { e.preventDefault(); setRstChaseEditing(true); }}
|
||||
onClick={() => { const on = !rstChase.enabled; setRstChase({ ...rstChase, enabled: on }); SetFlexRSTChaseEnabled(on).catch(() => {}); }}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
rstChase.enabled ? 'bg-success text-success-foreground border-success shadow-[0_0_12px] shadow-success/50' : 'bg-card text-success border-success hover:bg-success-muted')}>
|
||||
{rstChase.markers.split(',')[0].trim() || '599'}
|
||||
</button>
|
||||
{/* The marker is whatever SDC was told to write — "599", "5NN", or
|
||||
several. It has to agree with the skimmer or nothing ever fires,
|
||||
so it is edited here rather than in a settings page — but only
|
||||
when asked for, since it is set once and then left alone. */}
|
||||
{rstChaseEditing && (
|
||||
<input type="text" autoFocus value={rstChaseMarkersText}
|
||||
onChange={(e) => setRstChaseMarkersText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
if (e.key === 'Escape') { setRstChaseMarkersText(rstChase.markers); setRstChaseEditing(false); }
|
||||
}}
|
||||
onBlur={() => {
|
||||
const v = rstChaseMarkersText.trim() || '599';
|
||||
setRstChaseMarkersText(v);
|
||||
setRstChaseEditing(false);
|
||||
if (v === rstChase.markers) return;
|
||||
const next = { ...rstChase, markers: v };
|
||||
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
|
||||
}}
|
||||
title={t('flxp.rstChaseMarkerHint')}
|
||||
className="w-16 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono uppercase" />
|
||||
)}
|
||||
{rstChase.enabled && (
|
||||
<label className="flex items-center gap-1 text-[11px] text-muted-foreground whitespace-nowrap ml-2">
|
||||
{t('flxp.rstChaseOffset')}
|
||||
<input type="number" step={10} value={rstChaseOffsetText}
|
||||
onChange={(e) => setRstChaseOffsetText(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Kept as text while typing: normalising every keystroke
|
||||
// makes a minus sign impossible to enter.
|
||||
const n = Math.round(Number(rstChaseOffsetText));
|
||||
const v = Number.isFinite(n) ? n : 0;
|
||||
setRstChaseOffsetText(String(v));
|
||||
const next = { ...rstChase, offset_hz: v };
|
||||
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
|
||||
}}
|
||||
className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono" />
|
||||
Hz
|
||||
</label>
|
||||
)}
|
||||
{st.split && !!st.tx_freq_hz && (
|
||||
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
|
||||
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
|
||||
|
||||
@@ -366,7 +366,10 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||
<div ref={worldRef} className="absolute inset-0" />
|
||||
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
|
||||
<div className="absolute top-1 left-12 z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
|
||||
{/* Clear of the zoom buttons, not merely next to them: at left-12 the
|
||||
first basemap sat a few pixels from the − button and operators kept
|
||||
hitting Light when they meant to zoom out. */}
|
||||
<div className="absolute top-1 left-[4.5rem] z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
|
||||
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
@@ -41,6 +41,7 @@ const SERVICES = [
|
||||
{ v: 'hrdlog', label: 'HRDLog.net' },
|
||||
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||
{ v: 'lotw', label: 'LoTW' },
|
||||
{ v: 'hamlog', label: 'HAMLOG.online' },
|
||||
{ v: 'pota', label: 'POTA hunter log' },
|
||||
{ v: 'paper', label: 'Paper QSL' },
|
||||
];
|
||||
@@ -349,6 +350,34 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
// HAMLOG.online has no download verb — their agent protocol only uploads —
|
||||
// so confirmations come back as an ADIF exported from their site. It is read
|
||||
// here rather than through the ordinary ADIF import because that one matches
|
||||
// on the UTC minute and INSERTS whatever fails to match: a few hundred copies
|
||||
// of contacts already in the log. This path only ever stamps QSOs it finds.
|
||||
async function importHamlogCfm() {
|
||||
const path = await OpenADIFFile();
|
||||
if (!path) return;
|
||||
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
|
||||
try { await ImportHamlogConfirmations(path); }
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
// The unmatched half of an import. Offered as a file because a few hundred
|
||||
// discrepancies are a list to work through, not something to read in a log
|
||||
// window.
|
||||
async function exportHamlogUnmatched() {
|
||||
try {
|
||||
const path = await SaveADIFFile();
|
||||
if (!path) return;
|
||||
const n = await ExportHamlogUnmatched(path);
|
||||
setLogLines((p) => [...p, `Exported ${n} unmatched confirmation(s) → ${path}`]);
|
||||
setShowLog(true);
|
||||
} catch (e: any) {
|
||||
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setShowLog(true);
|
||||
}
|
||||
}
|
||||
|
||||
function viewResults() {
|
||||
setShowLog(false);
|
||||
if (logAction === 'upload') selectRequired();
|
||||
@@ -665,11 +694,25 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
{service !== 'pota' && service !== 'paper' && (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{service === 'hamlog' ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
||||
title={t('qslm.hamlogImportTitle')}>
|
||||
<DownloadCloud className="size-3.5" /> {t('qslm.hamlogImportCfm')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportHamlogUnmatched} disabled={busy}
|
||||
title={t('qslm.hamlogUnmatchedTitle')}>
|
||||
<UploadCloud className="size-3.5 rotate-180" /> {t('qslm.hamlogUnmatched')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||
title={t('qslm.downloadTitle')}>
|
||||
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Date window */}
|
||||
{service !== 'hamlog' && (<>
|
||||
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
|
||||
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
|
||||
<SelectValue />
|
||||
@@ -693,6 +736,7 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||
{t('qslm.addNotFound')}
|
||||
</label>
|
||||
</>)}
|
||||
</div>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
|
||||
|
||||
@@ -18,8 +18,11 @@ type Props = {
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
// Same rows, but keeping the APP_OPSLOG_* tags — award references above all.
|
||||
onExportSelectedFull?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportFilteredFull?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
@@ -31,6 +34,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||
{ service: 'lotw', name: 'LoTW' },
|
||||
{ service: 'hamlog', name: 'HAMLOG.online' },
|
||||
];
|
||||
|
||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||
@@ -39,7 +43,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||
// which used to dismiss the menu the instant it appeared.)
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportFiltered, onExportFilteredFull, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
const { t } = useI18n();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||
@@ -189,6 +193,20 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* The same rows WITH the OpsLog fields.
|
||||
The export above is standard ADIF — what another logger should be
|
||||
given — and it drops every APP_ tag, award references included. A
|
||||
backup, or a move to another OpsLog, needs them: RDA@KR-04 lives in
|
||||
APP_OPSLOG_AWARDREFS and nowhere else. */}
|
||||
{onExportSelectedFull && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportSelectedFull(menu.ids); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-info" />
|
||||
<span>{t('qctx.exportSelectedAdifFull', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportSelectedFields && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
@@ -207,6 +225,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
<span>{t('qctx.exportFilteredAdif')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportFilteredFull && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportFilteredFull(); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-info" />
|
||||
<span>{t('qctx.exportFilteredAdifFull')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportCabrilloSelected && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
|
||||
@@ -85,6 +85,19 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
||||
// the channel picker and the status table alongside the rest.
|
||||
const OPSLOG_CONF = 'OPSLOG';
|
||||
|
||||
// HAMLOG.online. Out of CONFIRMATIONS for the same reason as the OpsLog card —
|
||||
// it is stored in the ADIF extras, not in QSO columns — but it is an upload
|
||||
// service like QRZ.com or Club Log, so it gets the ordinary sent/received/date
|
||||
// editor rather than a special one. The received key is THEIR field name, the
|
||||
// one their ADIF export writes; see award.HamlogQSLKey.
|
||||
const HAMLOG_CONF = 'HAMLOG';
|
||||
const HAMLOG_KEYS = {
|
||||
sent: 'APP_OPSLOG_HAMLOG_SENT',
|
||||
sentDate: 'APP_OPSLOG_HAMLOG_SENT_DATE',
|
||||
rcvd: 'APP_HAMLOG_QSO_CFM',
|
||||
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
||||
};
|
||||
|
||||
// Colour-coded status cell for the confirmation grid.
|
||||
function StatusCell({ value }: { value?: string }) {
|
||||
const { t } = useI18n();
|
||||
@@ -715,6 +728,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
||||
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
||||
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
||||
// The extras-backed channel reads and writes the same way, one
|
||||
// level down. Save merges the extras rather than replacing them,
|
||||
// so an emptied field keeps its stored value — which is why the
|
||||
// statuses are written as explicit Y/N and never removed.
|
||||
const exVal = (k: string) => String(draft.extras?.[k] ?? '');
|
||||
const exPut = (k: string, v: any) => {
|
||||
const next = { ...(draft.extras ?? {}) };
|
||||
next[k] = String(v ?? '');
|
||||
set('extras', next as any);
|
||||
};
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
{/* Left: edit one confirmation channel at a time */}
|
||||
@@ -731,11 +754,24 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
generic sent/received/date grid, which has no field
|
||||
to bind to. */}
|
||||
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
||||
<SelectItem value={HAMLOG_CONF}>HAMLOG.online</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{confSel === OPSLOG_CONF ? (
|
||||
{confSel === HAMLOG_CONF ? (
|
||||
<>
|
||||
<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.received')}</Label><QslSelect value={exVal(HAMLOG_KEYS.rcvd)} onChange={(v) => exPut(HAMLOG_KEYS.rcvd, v)} /></div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.sentDate)} onChange={(v) => exPut(HAMLOG_KEYS.sentDate, v)} /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.rcvdDate)} onChange={(v) => exPut(HAMLOG_KEYS.rcvdDate, v)} /></div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||
</p>
|
||||
</>
|
||||
) : confSel === OPSLOG_CONF ? (
|
||||
/* OpsLog's own card. "Sent" is stamped when the card
|
||||
actually goes out, so it is shown, not offered: ticking
|
||||
it by hand would record something that never happened.
|
||||
@@ -827,6 +863,12 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -70,8 +70,10 @@ type Props = {
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportSelectedFull?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportFilteredFull?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
@@ -207,6 +209,14 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
// App-specific: operator marked a QSL as RECEIVED for this QSO (drives PSE/TNX).
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_rcvd'), colId: 'opslog_qsl_card_rcvd', headerName: t('rqg.c.opslog_qsl_card_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_QSL_RCVD'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
// HAMLOG.online. No promoted column — the ADIF standard names a field for
|
||||
// hamlog.EU and none for hamlog.ONLINE — so these read the extras, exactly
|
||||
// like the OpsLog card columns above. Hidden by default: a column per
|
||||
// service, shown to everyone, is how a grid becomes unreadable.
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_sent'), colId: 'hamlog_sent', headerName: t('rqg.h.hamlog_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMLOG_SENT'] || 'N'; }, defaultVisible: false },
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
|
||||
@@ -309,7 +319,7 @@ const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
return rest;
|
||||
});
|
||||
|
||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
|
||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportFiltered, onExportFilteredFull, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -741,8 +751,10 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportSelectedFull={onExportSelectedFull}
|
||||
onExportSelectedFields={onExportSelectedFields}
|
||||
onExportFiltered={onExportFiltered}
|
||||
onExportFilteredFull={onExportFilteredFull}
|
||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||
onDelete={onDelete}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase,
|
||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources,
|
||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||
GetPSUSettings, SavePSUSettings,
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { EventsOn, BrowserOpenURL } from '../../wailsjs/runtime/runtime';
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
@@ -1708,6 +1708,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
eqsl_sent: string; eqsl_rcvd: string;
|
||||
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
|
||||
qrzcom_confirmed: string;
|
||||
hamlog_status: string; hamlog_confirmed: string;
|
||||
};
|
||||
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
||||
qsl_sent: '', qsl_rcvd: '',
|
||||
@@ -1715,6 +1716,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
eqsl_sent: '', eqsl_rcvd: '',
|
||||
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
||||
qrzcom_confirmed: '',
|
||||
hamlog_status: '', hamlog_confirmed: '',
|
||||
});
|
||||
|
||||
// External services (logbook upload). One block per service; only QRZ is
|
||||
@@ -1730,7 +1732,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
|
||||
on_air?: boolean;
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; delete_remote?: boolean };
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; delete_remote?: boolean };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||
@@ -1738,7 +1740,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
auto_upload: false, upload_mode: 'immediate', on_air: false,
|
||||
});
|
||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), delete_remote: false,
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), delete_remote: false,
|
||||
});
|
||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [qrzTesting, setQrzTesting] = useState(false);
|
||||
@@ -1852,6 +1854,20 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [rdaUseCurrent, setRdaUseCurrent] = useState(true);
|
||||
const [rdaMsg, setRdaMsg] = useState<string | null>(null);
|
||||
useEffect(() => { RDADatabaseCount().then((n) => setRdaCount(Number(n) || 0)).catch(() => {}); }, []);
|
||||
// Measuring the two sources against each other, before deciding which wins.
|
||||
//
|
||||
// Read-only on purpose: the question "is HAMLOG's district independent
|
||||
// evidence or a copy of the same database?" has to be answered with the
|
||||
// operator's own log, and answering it must not modify that log.
|
||||
const [rdaCmp, setRdaCmp] = useState<any>(null);
|
||||
const [rdaCmpBusy, setRdaCmpBusy] = useState(false);
|
||||
const runRDACompare = async () => {
|
||||
setRdaCmpBusy(true); setRdaCmp(null);
|
||||
try { setRdaCmp(await CompareRDASources()); }
|
||||
catch (e: any) { setRdaMsg(String(e?.message ?? e)); }
|
||||
finally { setRdaCmpBusy(false); }
|
||||
};
|
||||
|
||||
const runRDABackfill = async () => {
|
||||
setRdaBusy(true); setRdaMsg(null);
|
||||
try {
|
||||
@@ -1872,6 +1888,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||
const [cloudlogTest, setCloudlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [cloudlogTesting, setCloudlogTesting] = useState(false);
|
||||
const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [hamlogTesting, setHamlogTesting] = useState(false);
|
||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [eqslTesting, setEqslTesting] = useState(false);
|
||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||
@@ -1879,7 +1897,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// 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 —
|
||||
// a choice now, not a workaround.
|
||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'pota'>('qrz');
|
||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'pota'>('qrz');
|
||||
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||||
const [potaToken, setPotaToken] = useState('');
|
||||
const [potaBusy, setPotaBusy] = useState(false);
|
||||
@@ -5204,9 +5222,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t('conf.note')}
|
||||
</div>
|
||||
{/* Clublog */}
|
||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||
<Label className="text-sm font-medium pb-1.5">Clublog</Label>
|
||||
@@ -5237,6 +5252,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
|
||||
</div>
|
||||
</div>
|
||||
{/* HAMLOG.online */}
|
||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||
<Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label>
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||||
{renderSelect('hamlog_status', FULL_OPTIONS)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
|
||||
{renderSelect('hamlog_confirmed', FULL_OPTIONS)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -5420,6 +5447,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{ k: 'eqsl', label: 'EQSL', ready: true },
|
||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
|
||||
{ k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true },
|
||||
{ k: 'pota', label: 'POTA', ready: true },
|
||||
];
|
||||
|
||||
@@ -5489,6 +5517,30 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
}
|
||||
|
||||
const hamlog = extSvc.hamlog ?? emptyExtCfg();
|
||||
const setHamlog = (patch: Partial<ExtServiceCfg>) =>
|
||||
setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));
|
||||
|
||||
// Checking the KEY, not the connection.
|
||||
//
|
||||
// HAMLOG answers KEYSTATUS with the callsign the key belongs to, which no
|
||||
// other service here does — so the answer is shown as the callsign rather
|
||||
// than a bare "OK". A key pasted from a club account or a second station is
|
||||
// then visible immediately, which is exactly the mistake a green tick would
|
||||
// have hidden.
|
||||
async function testHamlog() {
|
||||
setHamlogTesting(true);
|
||||
setHamlogTest(null);
|
||||
try {
|
||||
const call = await CheckHamlogKey(hamlog.api_key ?? '');
|
||||
setHamlogTest({ ok: true, msg: call ? t('es.hamlogKeyOkCall', { call }) : t('es.hamlogKeyOk') });
|
||||
} catch (e: any) {
|
||||
setHamlogTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
setHamlogTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const cloudlog = extSvc.cloudlog;
|
||||
const setCloudlog = (patch: Partial<ExtServiceCfg>) =>
|
||||
setExtSvc((s) => ({ ...s, cloudlog: { ...s.cloudlog, ...patch } }));
|
||||
@@ -5790,6 +5842,60 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : extSvcTab === 'hamlog' ? (
|
||||
<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.apiKey')}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={hamlog.api_key}
|
||||
onChange={(e) => setHamlog({ api_key: e.target.value })}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground -mt-1">
|
||||
{t('es.hamlogHint')}{' '}
|
||||
<button type="button" className="underline hover:text-foreground"
|
||||
onClick={() => BrowserOpenURL('https://hamlog.online/account/agent.php')}>
|
||||
hamlog.online/account/agent.php
|
||||
</button>
|
||||
</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={hamlog.auto_upload}
|
||||
onCheckedChange={(c) => setHamlog({ 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={hamlog.upload_mode === 'delayed' ? 'delayed' : 'immediate'}
|
||||
onValueChange={(v) => setHamlog({ 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={testHamlog} disabled={hamlogTesting}>
|
||||
<UploadCloud className="size-3.5" /> {hamlogTesting ? t('es.testing') : t('es.hamlogCheckKey')}
|
||||
</Button>
|
||||
{hamlogTest && (
|
||||
<span className={cn('text-xs', hamlogTest.ok ? 'text-success' : 'text-danger')}>
|
||||
{hamlogTest.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : extSvcTab === 'cloudlog' ? (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
@@ -7128,6 +7234,56 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('rda.neverOverwrites')}</p>
|
||||
</div>
|
||||
|
||||
{/* Comparing the two sources. Read-only: it answers a question about the
|
||||
log without touching it. */}
|
||||
<div className="rounded-md border border-border p-3 space-y-2">
|
||||
<div className="text-xs font-medium">{t('rda.cmpTitle')}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" variant="secondary" onClick={runRDACompare} disabled={rdaCmpBusy}>
|
||||
{rdaCmpBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||||
{t('rda.cmpRun')}
|
||||
</Button>
|
||||
{rdaCmp && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('rda.cmpDone', {
|
||||
s: rdaCmp.scanned ?? 0, a: rdaCmp.agree ?? 0, d: rdaCmp.disagree ?? 0,
|
||||
l: rdaCmp.only_log ?? 0, b: rdaCmp.only_db ?? 0,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* The disagreements themselves. A count alone would say "there is a
|
||||
problem" and leave the operator with no way to look at it. */}
|
||||
{rdaCmp?.conflicts?.length > 0 && (
|
||||
<div className="max-h-56 overflow-auto rounded border border-border">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
|
||||
<tr>
|
||||
<th className="py-1 px-2 font-medium">{t('rda.cmpCall')}</th>
|
||||
<th className="py-1 pr-2 font-medium">{t('rda.cmpDate')}</th>
|
||||
<th className="py-1 pr-2 font-medium">{t('rda.cmpFromLog')}</th>
|
||||
<th className="py-1 pr-2 font-medium">{t('rda.cmpFromDb')}</th>
|
||||
<th className="py-1 pr-2 font-medium">{t('rda.cmpKind')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rdaCmp.conflicts.map((c: any) => (
|
||||
<tr key={c.qso_id} className="border-b border-border/30">
|
||||
<td className="py-1 px-2 font-mono font-semibold">{c.callsign}</td>
|
||||
<td className="py-1 pr-2 font-mono">{c.date}</td>
|
||||
<td className="py-1 pr-2 font-mono">{c.from_log}{c.confirmed ? ' ✓' : ''}</td>
|
||||
<td className="py-1 pr-2 font-mono">{c.from_db}</td>
|
||||
<td className="py-1 pr-2 text-muted-foreground">
|
||||
{c.dated ? t('rda.cmpDated') : t('rda.cmpCurrent')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,9 +73,17 @@ export function WinkeyerPanel({
|
||||
|
||||
const connected = status.connected;
|
||||
|
||||
// Send-on-type only exists where the engine can key one character at a time:
|
||||
// the WinKeyer and Flex CWX. The stored preference survives an engine change,
|
||||
// though, and an Icom or Yaesu operator inherited a hidden switch that was on
|
||||
// — the checkbox is not shown for them, so nothing keyed as they typed AND
|
||||
// Enter sent nothing either, since it believes the text has already gone.
|
||||
// Macros kept working, which is exactly how it looked from the outside.
|
||||
const liveType = sendOnType && (source === 'winkeyer' || source === 'flex');
|
||||
|
||||
function sendText() {
|
||||
const t = cwText.trim();
|
||||
if (t && !sendOnType) onSend(t); // in send-on-type the text already went out
|
||||
if (t && !liveType) onSend(t); // in send-on-type the text already went out
|
||||
setCwText('');
|
||||
}
|
||||
|
||||
@@ -83,7 +91,7 @@ export function WinkeyerPanel({
|
||||
// WinKeyer backspace for each deleted char (removes it from the buffer if it
|
||||
// hasn't been keyed yet). Only end-of-string edits are mirrored live.
|
||||
function onCwChange(v: string) {
|
||||
if (sendOnType && connected) {
|
||||
if (liveType && connected) {
|
||||
const old = cwText;
|
||||
if (v.length > old.length && v.startsWith(old)) {
|
||||
onSendRaw(v.slice(old.length));
|
||||
@@ -200,13 +208,13 @@ export function WinkeyerPanel({
|
||||
value={cwText}
|
||||
onChange={(e) => onCwChange(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
|
||||
placeholder={sendOnType ? t('wkp.phLive') : t('wkp.phEnter')}
|
||||
placeholder={liveType ? t('wkp.phLive') : t('wkp.phEnter')}
|
||||
disabled={!connected}
|
||||
className="font-mono uppercase"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
|
||||
<Send className="size-3.5" /> {sendOnType ? t('wkp.clear') : t('wkp.send')}
|
||||
<Send className="size-3.5" /> {liveType ? t('wkp.clear') : t('wkp.send')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title={t('wkp.abort')}>
|
||||
<Square className="size-3.5" /> {t('wkp.stop')}
|
||||
|
||||
@@ -41,6 +41,7 @@ type Props = {
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportSelectedFull?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
@@ -58,7 +59,7 @@ function fmtDate(s: any): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -279,6 +280,7 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportSelectedFull={onExportSelectedFull}
|
||||
onExportSelectedFields={onExportSelectedFields}
|
||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||
onDelete={onDelete}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lu
|
||||
import {
|
||||
GetYaesuState, RefreshYaesuPanel,
|
||||
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
||||
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
||||
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuAntenna, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
||||
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
||||
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
@@ -17,7 +17,7 @@ type YaesuState = {
|
||||
transmitting: boolean; split: boolean;
|
||||
s_meter: number; power_meter: number; swr_meter: number;
|
||||
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
|
||||
agc?: string; preamp: number; att: number;
|
||||
agc?: string; preamp: number; att: number; antenna: number;
|
||||
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
|
||||
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
|
||||
};
|
||||
@@ -26,7 +26,7 @@ const ZERO: YaesuState = {
|
||||
available: false, transmitting: false, split: false,
|
||||
s_meter: 0, power_meter: 0, swr_meter: 0,
|
||||
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
|
||||
preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
||||
preamp: 0, att: 0, antenna: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
||||
};
|
||||
|
||||
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
|
||||
@@ -74,6 +74,10 @@ function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null {
|
||||
// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and
|
||||
// AMP2 add gain. Presenting it as a toggle would hide the middle position.
|
||||
const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }];
|
||||
// Three jacks is the most any of these rigs has (FTDX101); an FTDX10 answers
|
||||
// with 1 or 2 and the third button simply never gets used. A rig with a single
|
||||
// socket answers nothing at all and reports 0, and the row is not drawn.
|
||||
const ANTENNAS = [{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }, { v: '3', l: 'ANT3' }];
|
||||
const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }];
|
||||
// The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle.
|
||||
const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||
@@ -424,6 +428,16 @@ export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
||||
<Row label="ATT">
|
||||
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
|
||||
</Row>
|
||||
{/* Only on a rig that HAS a choice: 0 means the AN command went
|
||||
unanswered, which is what a single-socket FT-891 does. And the
|
||||
choice is remembered for the band it was made on — no table to
|
||||
fill in anywhere, the backend learns it here. */}
|
||||
{view.antenna > 0 && (
|
||||
<Row label="ANT">
|
||||
<Segmented value={String(view.antenna)} options={ANTENNAS}
|
||||
onChange={(v) => push('antenna', parseInt(v, 10), () => SetYaesuAntenna(parseInt(v, 10)))} />
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Noise + filter */}
|
||||
|
||||
+17
-19
File diff suppressed because one or more lines are too long
@@ -26,7 +26,7 @@ export type RowColorSettings = {
|
||||
// otherwise keep striping underneath whatever we do per row.
|
||||
const ZEBRA_DEFAULT = 'color-mix(in srgb, var(--muted) 40%, var(--card))';
|
||||
|
||||
export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
|
||||
export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz', 'hamlog'] as const;
|
||||
|
||||
// The two QSO fields behind each channel. QRZ.com and Club Log call theirs an
|
||||
// "upload status" rather than a QSL flag, but they carry the same Y / R letters.
|
||||
@@ -37,6 +37,24 @@ const FIELDS: Record<string, { sent: string; rcvd: string }> = {
|
||||
qrz: { sent: 'qrzcom_qso_upload_status', rcvd: 'qrzcom_qso_download_status' },
|
||||
};
|
||||
|
||||
// HAMLOG.online has no column of its own: the ADIF standard names a field for
|
||||
// hamlog.EU and none for hamlog.ONLINE, so its state lives in the extras — see
|
||||
// internal/award/award.go, which reads the same keys for award confirmations.
|
||||
// One vocabulary, two readers.
|
||||
const HAMLOG_SENT = 'APP_OPSLOG_HAMLOG_SENT';
|
||||
const HAMLOG_RCVD = ['APP_HAMLOG_QSO_CFM', 'APP_OPSLOG_HAMLOG_QSL', 'APP_HAMLOG_QSL', 'APP_HAMLOGONLINE_QSL', 'HAMLOG_QSL_RCVD'];
|
||||
|
||||
// hamlogState reads a row's extras. Any value that is not an explicit "no"
|
||||
// counts, because their export could carry a date or a match id rather than Y.
|
||||
function hamlogState(q: any, which: 'sent' | 'rcvd'): boolean {
|
||||
const e = (q?.extras ?? {}) as Record<string, string>;
|
||||
const keys = which === 'sent' ? [HAMLOG_SENT] : HAMLOG_RCVD;
|
||||
return keys.some((k) => {
|
||||
const v = String(e[k] ?? '').trim();
|
||||
return v !== '' && v.toUpperCase() !== 'N' && v.toUpperCase() !== 'NO';
|
||||
});
|
||||
}
|
||||
|
||||
// ADIF QSL fields are single letters. Y is the only one that means "yes";
|
||||
// R (requested) and Q (queued) mean it has not gone out yet — a different state,
|
||||
// and the one an operator looks for when deciding what to send.
|
||||
@@ -55,14 +73,18 @@ function ruleMatches(q: any, r: RowColorRule): boolean {
|
||||
const cs = chansOf(r);
|
||||
switch (r.id) {
|
||||
case 'confirmed':
|
||||
return cs.some((c) => yes(q[FIELDS[c]?.rcvd]));
|
||||
return cs.some((c) => (c === 'hamlog' ? hamlogState(q, 'rcvd') : yes(q[FIELDS[c]?.rcvd])));
|
||||
case 'sent':
|
||||
return cs.some((c) => yes(q[FIELDS[c]?.sent]));
|
||||
return cs.some((c) => (c === 'hamlog' ? hamlogState(q, 'sent') : yes(q[FIELDS[c]?.sent])));
|
||||
case 'to_send':
|
||||
return cs.some((c) => owed(q[FIELDS[c]?.sent]));
|
||||
// Nothing to request from HAMLOG: a QSO is either uploaded or it is not,
|
||||
// there is no "card asked for" state to be owed.
|
||||
return cs.some((c) => c !== 'hamlog' && owed(q[FIELDS[c]?.sent]));
|
||||
case 'worked':
|
||||
// The catch-all: nothing sent, nothing asked for, nothing back.
|
||||
return !CHANNELS.some((c) => yes(q[FIELDS[c].rcvd]) || yes(q[FIELDS[c].sent]) || owed(q[FIELDS[c].sent]));
|
||||
return !CHANNELS.some((c) => (c === 'hamlog'
|
||||
? hamlogState(q, 'rcvd') || hamlogState(q, 'sent')
|
||||
: yes(q[FIELDS[c].rcvd]) || yes(q[FIELDS[c].sent]) || owed(q[FIELDS[c].sent])));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.26.6';
|
||||
export const APP_VERSION = '0.26.10';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+22
@@ -117,6 +117,8 @@ export function ChatAvailable():Promise<boolean>;
|
||||
|
||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||
|
||||
export function CheckHamlogKey(arg1:string):Promise<string>;
|
||||
|
||||
export function ClearLookupCache():Promise<void>;
|
||||
|
||||
export function CloseAutostartPrograms():Promise<void>;
|
||||
@@ -125,6 +127,8 @@ export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<ma
|
||||
|
||||
export function CompactDatabase(arg1:string):Promise<main.CompactResult>;
|
||||
|
||||
export function CompareRDASources():Promise<main.RDACompareResult>;
|
||||
|
||||
export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef>>;
|
||||
|
||||
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
|
||||
@@ -229,6 +233,8 @@ export function ExportCabrilloFiltered(arg1:string,arg2:qso.QueryFilter):Promise
|
||||
|
||||
export function ExportCabrilloSelected(arg1:string,arg2:Array<number>):Promise<main.CabrilloResult>;
|
||||
|
||||
export function ExportHamlogUnmatched(arg1:string):Promise<number>;
|
||||
|
||||
export function FilterFields():Promise<Array<string>>;
|
||||
|
||||
export function FindDuplicates(arg1:number):Promise<Array<main.DuplicateGroup>>;
|
||||
@@ -371,6 +377,8 @@ export function FlexTune(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexZoomForSpot(arg1:string,arg2:number):Promise<void>;
|
||||
|
||||
export function ForgetYaesuBandAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function GetACOMStatus():Promise<acom.Status>;
|
||||
|
||||
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||
@@ -463,6 +471,8 @@ export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||
|
||||
export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||
|
||||
export function GetFlexRSTChase():Promise<main.FlexRSTChase>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetFlexZoom():Promise<main.FlexZoomSettings>;
|
||||
@@ -585,6 +595,8 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function GetWorkedCallVariants():Promise<boolean>;
|
||||
|
||||
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
|
||||
|
||||
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||
|
||||
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
|
||||
@@ -685,6 +697,8 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<numbe
|
||||
|
||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||
|
||||
export function ImportHamlogConfirmations(arg1:string):Promise<main.HamlogCfmResult>;
|
||||
|
||||
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||
|
||||
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
||||
@@ -979,6 +993,8 @@ export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Prom
|
||||
|
||||
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
|
||||
|
||||
export function SaveFlexRSTChase(arg1:main.FlexRSTChase):Promise<void>;
|
||||
|
||||
export function SaveFlexZoom(arg1:main.FlexZoomSettings):Promise<void>;
|
||||
|
||||
export function SaveFolderSync(arg1:main.FolderSyncConfig):Promise<void>;
|
||||
@@ -1081,6 +1097,8 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetFlexRSTChaseEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function SetLinkedAmps(arg1:Array<string>):Promise<void>;
|
||||
@@ -1111,6 +1129,8 @@ export function SetYaesuAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuAGC(arg1:string):Promise<void>;
|
||||
|
||||
export function SetYaesuAntenna(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuAtt(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuBand(arg1:string):Promise<void>;
|
||||
@@ -1239,6 +1259,8 @@ export function WinkeyerTraceEnabled():Promise<boolean>;
|
||||
|
||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||
|
||||
export function YaesuApplyBandAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function YaesuSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function YaesuStopCW():Promise<void>;
|
||||
|
||||
@@ -174,6 +174,10 @@ export function CheckForUpdate() {
|
||||
return window['go']['main']['App']['CheckForUpdate']();
|
||||
}
|
||||
|
||||
export function CheckHamlogKey(arg1) {
|
||||
return window['go']['main']['App']['CheckHamlogKey'](arg1);
|
||||
}
|
||||
|
||||
export function ClearLookupCache() {
|
||||
return window['go']['main']['App']['ClearLookupCache']();
|
||||
}
|
||||
@@ -190,6 +194,10 @@ export function CompactDatabase(arg1) {
|
||||
return window['go']['main']['App']['CompactDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function CompareRDASources() {
|
||||
return window['go']['main']['App']['CompareRDASources']();
|
||||
}
|
||||
|
||||
export function ComputeQSOAwardRefs(arg1) {
|
||||
return window['go']['main']['App']['ComputeQSOAwardRefs'](arg1);
|
||||
}
|
||||
@@ -398,6 +406,10 @@ export function ExportCabrilloSelected(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExportCabrilloSelected'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ExportHamlogUnmatched(arg1) {
|
||||
return window['go']['main']['App']['ExportHamlogUnmatched'](arg1);
|
||||
}
|
||||
|
||||
export function FilterFields() {
|
||||
return window['go']['main']['App']['FilterFields']();
|
||||
}
|
||||
@@ -682,6 +694,10 @@ export function FlexZoomForSpot(arg1, arg2) {
|
||||
return window['go']['main']['App']['FlexZoomForSpot'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ForgetYaesuBandAntenna(arg1) {
|
||||
return window['go']['main']['App']['ForgetYaesuBandAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function GetACOMStatus() {
|
||||
return window['go']['main']['App']['GetACOMStatus']();
|
||||
}
|
||||
@@ -866,6 +882,10 @@ export function GetFlexBandPower() {
|
||||
return window['go']['main']['App']['GetFlexBandPower']();
|
||||
}
|
||||
|
||||
export function GetFlexRSTChase() {
|
||||
return window['go']['main']['App']['GetFlexRSTChase']();
|
||||
}
|
||||
|
||||
export function GetFlexState() {
|
||||
return window['go']['main']['App']['GetFlexState']();
|
||||
}
|
||||
@@ -1110,6 +1130,10 @@ export function GetWorkedCallVariants() {
|
||||
return window['go']['main']['App']['GetWorkedCallVariants']();
|
||||
}
|
||||
|
||||
export function GetYaesuBandAntennas() {
|
||||
return window['go']['main']['App']['GetYaesuBandAntennas']();
|
||||
}
|
||||
|
||||
export function GetYaesuState() {
|
||||
return window['go']['main']['App']['GetYaesuState']();
|
||||
}
|
||||
@@ -1310,6 +1334,10 @@ export function ImportAwards() {
|
||||
return window['go']['main']['App']['ImportAwards']();
|
||||
}
|
||||
|
||||
export function ImportHamlogConfirmations(arg1) {
|
||||
return window['go']['main']['App']['ImportHamlogConfirmations'](arg1);
|
||||
}
|
||||
|
||||
export function InspectAwardImport() {
|
||||
return window['go']['main']['App']['InspectAwardImport']();
|
||||
}
|
||||
@@ -1898,6 +1926,10 @@ export function SaveFlexBandPower(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
|
||||
}
|
||||
|
||||
export function SaveFlexRSTChase(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexRSTChase'](arg1);
|
||||
}
|
||||
|
||||
export function SaveFlexZoom(arg1) {
|
||||
return window['go']['main']['App']['SaveFlexZoom'](arg1);
|
||||
}
|
||||
@@ -2102,6 +2134,10 @@ export function SetDVKLabel(arg1, arg2) {
|
||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SetFlexRSTChaseEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetFlexRSTChaseEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetKenwoodKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||
}
|
||||
@@ -2162,6 +2198,10 @@ export function SetYaesuAGC(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuAGC'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuAntenna(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuAtt(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuAtt'](arg1);
|
||||
}
|
||||
@@ -2418,6 +2458,10 @@ export function WorkedBefore(arg1, arg2) {
|
||||
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function YaesuApplyBandAntenna(arg1) {
|
||||
return window['go']['main']['App']['YaesuApplyBandAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function YaesuSendCW(arg1) {
|
||||
return window['go']['main']['App']['YaesuSendCW'](arg1);
|
||||
}
|
||||
|
||||
@@ -1160,6 +1160,7 @@ export namespace cat {
|
||||
squelch: number;
|
||||
agc?: string;
|
||||
preamp: number;
|
||||
antenna: number;
|
||||
att: number;
|
||||
nb: boolean;
|
||||
nr: boolean;
|
||||
@@ -1194,6 +1195,7 @@ export namespace cat {
|
||||
this.squelch = source["squelch"];
|
||||
this.agc = source["agc"];
|
||||
this.preamp = source["preamp"];
|
||||
this.antenna = source["antenna"];
|
||||
this.att = source["att"];
|
||||
this.nb = source["nb"];
|
||||
this.nr = source["nr"];
|
||||
@@ -1367,6 +1369,7 @@ export namespace extsvc {
|
||||
hrdlog: ServiceConfig;
|
||||
eqsl: ServiceConfig;
|
||||
cloudlog: ServiceConfig;
|
||||
hamlog: ServiceConfig;
|
||||
delete_remote: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -1381,6 +1384,7 @@ export namespace extsvc {
|
||||
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
||||
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
||||
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
|
||||
this.delete_remote = source["delete_remote"];
|
||||
}
|
||||
|
||||
@@ -2533,6 +2537,24 @@ export namespace main {
|
||||
this.body = source["body"];
|
||||
}
|
||||
}
|
||||
export class FlexRSTChase {
|
||||
enabled: boolean;
|
||||
markers: string;
|
||||
offset_hz: number;
|
||||
split_only: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new FlexRSTChase(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.markers = source["markers"];
|
||||
this.offset_hz = source["offset_hz"];
|
||||
this.split_only = source["split_only"];
|
||||
}
|
||||
}
|
||||
export class FlexZoomSettings {
|
||||
enabled: boolean;
|
||||
cw_khz: number;
|
||||
@@ -2695,6 +2717,28 @@ export namespace main {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class HamlogCfmResult {
|
||||
total: number;
|
||||
confirmed: number;
|
||||
matched: number;
|
||||
by_class: number;
|
||||
unmatched: number;
|
||||
samples: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new HamlogCfmResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.total = source["total"];
|
||||
this.confirmed = source["confirmed"];
|
||||
this.matched = source["matched"];
|
||||
this.by_class = source["by_class"];
|
||||
this.unmatched = source["unmatched"];
|
||||
this.samples = source["samples"];
|
||||
}
|
||||
}
|
||||
export class ModePreset {
|
||||
name: string;
|
||||
default_rst_sent?: string;
|
||||
@@ -3028,6 +3072,8 @@ export namespace main {
|
||||
hrdlog_status: string;
|
||||
qrzcom_status: string;
|
||||
qrzcom_confirmed: string;
|
||||
hamlog_status: string;
|
||||
hamlog_confirmed: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QSLDefaults(source);
|
||||
@@ -3045,6 +3091,8 @@ export namespace main {
|
||||
this.hrdlog_status = source["hrdlog_status"];
|
||||
this.qrzcom_status = source["qrzcom_status"];
|
||||
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
||||
this.hamlog_status = source["hamlog_status"];
|
||||
this.hamlog_confirmed = source["hamlog_confirmed"];
|
||||
}
|
||||
}
|
||||
export class QSLEmailTemplates {
|
||||
@@ -3221,6 +3269,79 @@ export namespace main {
|
||||
this.team_last60 = source["team_last60"];
|
||||
}
|
||||
}
|
||||
export class RDAConflict {
|
||||
qso_id: number;
|
||||
callsign: string;
|
||||
date: string;
|
||||
band: string;
|
||||
mode: string;
|
||||
from_log: string;
|
||||
from_db: string;
|
||||
dated: boolean;
|
||||
confirmed: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RDAConflict(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.qso_id = source["qso_id"];
|
||||
this.callsign = source["callsign"];
|
||||
this.date = source["date"];
|
||||
this.band = source["band"];
|
||||
this.mode = source["mode"];
|
||||
this.from_log = source["from_log"];
|
||||
this.from_db = source["from_db"];
|
||||
this.dated = source["dated"];
|
||||
this.confirmed = source["confirmed"];
|
||||
}
|
||||
}
|
||||
export class RDACompareResult {
|
||||
scanned: number;
|
||||
both_known: number;
|
||||
agree: number;
|
||||
disagree: number;
|
||||
only_log: number;
|
||||
only_db: number;
|
||||
neither: number;
|
||||
conflicts: RDAConflict[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RDACompareResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.scanned = source["scanned"];
|
||||
this.both_known = source["both_known"];
|
||||
this.agree = source["agree"];
|
||||
this.disagree = source["disagree"];
|
||||
this.only_log = source["only_log"];
|
||||
this.only_db = source["only_db"];
|
||||
this.neither = source["neither"];
|
||||
this.conflicts = this.convertValues(source["conflicts"], RDAConflict);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
export class RelayAutoRule {
|
||||
device_id: string;
|
||||
relay: number;
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
package main
|
||||
|
||||
// Reading confirmations back from a HAMLOG.online ADIF export.
|
||||
//
|
||||
// Their agent protocol only uploads — it has no verb for asking what has been
|
||||
// confirmed — so the return path is a file: their site exports an ADIF, and the
|
||||
// operator feeds it back here.
|
||||
//
|
||||
// That file must NOT be imported the ordinary way. A plain import matches a
|
||||
// record to a local QSO on callsign + UTC MINUTE + band + mode, and their
|
||||
// export is rebuilt from their own database: a minute of rounding, SSB where
|
||||
// the log says USB, and the key no longer matches. "Update duplicates" then
|
||||
// does what it is told — no duplicate found, so it inserts — and a few hundred
|
||||
// copies of contacts the operator already had land in the log. That is exactly
|
||||
// what happened once, and it is why this path exists instead.
|
||||
//
|
||||
// So: match only, never insert. Confirmations are stamped onto the QSOs already
|
||||
// in the log, and anything that cannot be matched is REPORTED rather than
|
||||
// added, because an unmatched confirmation is a question about the log (a
|
||||
// minute off, a portable call) and not a contact to create.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/adif"
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/award"
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// hamlogUnmatchedMax bounds what is kept for export. A whole log's worth of
|
||||
// unmatched records means the file belongs to another station, not that the
|
||||
// operator wants 50 000 of them written back out.
|
||||
const hamlogUnmatchedMax = 20000
|
||||
|
||||
// HamlogCfmResult is what the import did.
|
||||
type HamlogCfmResult struct {
|
||||
Total int `json:"total"` // records read from the file
|
||||
Confirmed int `json:"confirmed"` // records carrying HAMLOG's confirmation flag
|
||||
Matched int `json:"matched"` // local QSOs stamped
|
||||
ByClass int `json:"by_class"` // matched on mode CLASS rather than exact mode
|
||||
Unmatched int `json:"unmatched"` // confirmations with no local QSO
|
||||
// Samples names a few unmatched contacts, so "12 unmatched" can be looked
|
||||
// into rather than merely worried about. The full list is kept for export —
|
||||
// see ExportHamlogUnmatched — because 395 of them is not a sample-sized
|
||||
// problem: it is a list to work through.
|
||||
Samples []string `json:"samples"`
|
||||
}
|
||||
|
||||
// hamlogCfmSamples caps the reported list — enough to see the pattern, not so
|
||||
// many that the dialog becomes a log file.
|
||||
const hamlogCfmSamples = 30
|
||||
|
||||
// ImportHamlogConfirmations stamps the confirmations from a HAMLOG.online ADIF
|
||||
// export onto the matching local QSOs. It inserts nothing.
|
||||
func (a *App) ImportHamlogConfirmations(path string) (HamlogCfmResult, error) {
|
||||
var res HamlogCfmResult
|
||||
if a.qso == nil {
|
||||
return res, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return res, fmt.Errorf("empty path")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
ctx := a.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
// The same two indexes the LoTW download uses: the exact key, then the
|
||||
// mode-CLASS key for the contacts whose mode was written differently at the
|
||||
// other end (FT8 exported as DATA, SSB where the log says USB).
|
||||
keyIDs, err := a.qso.DedupeKeyIDs(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("read local log: %w", err)
|
||||
}
|
||||
classIDs, _ := a.qso.DedupeClassKeyIDs(ctx)
|
||||
|
||||
emit := func(line string) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
||||
}
|
||||
}
|
||||
emit("Reading " + path + "…")
|
||||
|
||||
// What already counts towards an award, so each confirmation can be flagged
|
||||
// NEW. LoTW and paper QSL are the two award-valid sources; a HAMLOG
|
||||
// confirmation is "new" when it lands on a slot neither of them holds — which
|
||||
// is the only sense in which it changes anything.
|
||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
|
||||
var items []ConfirmationItem
|
||||
var unmatched []qso.QSO
|
||||
|
||||
perr := adif.Parse(f, func(rec adif.Record) error {
|
||||
q, ok := adif.RecordToQSO(rec)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
res.Total++
|
||||
// Only the records they actually confirmed. The export carries the whole
|
||||
// log, and stamping "confirmed" on all of it would turn every uploaded
|
||||
// contact into a confirmed one.
|
||||
if !hamlogRecordConfirmed(rec) {
|
||||
return nil
|
||||
}
|
||||
res.Confirmed++
|
||||
|
||||
minute := q.QSODate.UTC().Format("2006-01-02T15:04")
|
||||
id, found := keyIDs[qso.DedupeKey(q.Callsign, minute, q.Band, q.Mode)]
|
||||
if !found {
|
||||
// id 0 means the class key is ambiguous — several local QSOs share
|
||||
// it — and guessing between them would stamp the wrong one.
|
||||
if cid, ok := classIDs[qso.DedupeClassKey(q.Callsign, minute, q.Band, q.Mode)]; ok && cid != 0 {
|
||||
id, found = cid, true
|
||||
res.ByClass++
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
res.Unmatched++
|
||||
if len(res.Samples) < hamlogCfmSamples {
|
||||
res.Samples = append(res.Samples, fmt.Sprintf("%s · %s · %s · %s",
|
||||
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04Z"), q.Band, q.Mode))
|
||||
}
|
||||
if len(unmatched) < hamlogUnmatchedMax {
|
||||
unmatched = append(unmatched, q)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
date := hamlogCfmDate(rec)
|
||||
if e := a.qso.SetExtra(ctx, id, award.HamlogQSLKey, "Y"); e != nil {
|
||||
return nil
|
||||
}
|
||||
_ = a.qso.SetExtra(ctx, id, hamlogQSLDateKey, date)
|
||||
// A confirmed contact is by definition one they hold, so the sent side
|
||||
// is true whether or not OpsLog is what uploaded it — a log uploaded
|
||||
// from their website would otherwise read "never sent, yet confirmed".
|
||||
_ = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y")
|
||||
res.Matched++
|
||||
|
||||
// Feed the Results view, the same rows the LoTW download produces: an
|
||||
// import that only prints counts leaves the operator with no way to see
|
||||
// WHICH contacts were confirmed, which is the reason they ran it.
|
||||
a.enrichContactedFromCty(&q) // country/dxcc, for the entity flags
|
||||
it := ConfirmationItem{
|
||||
Callsign: q.Callsign,
|
||||
QSODate: q.QSODate.UTC().Format(time.RFC3339),
|
||||
Band: q.Band,
|
||||
Mode: q.Mode,
|
||||
Country: q.Country,
|
||||
}
|
||||
if q.DXCC != nil && *q.DXCC != 0 {
|
||||
n := *q.DXCC
|
||||
it.NewDXCC = !sets.DXCC[n]
|
||||
it.NewBand = !sets.Band[qso.BandKey(n, q.Band)]
|
||||
it.NewMode = !sets.Mode[qso.ModeClassKey(n, q.Mode)]
|
||||
it.NewSlot = !sets.Slot[qso.SlotClassKey(n, q.Band, q.Mode)]
|
||||
// Fold it in, so a repeat inside the same file isn't flagged twice.
|
||||
sets.DXCC[n] = true
|
||||
sets.Band[qso.BandKey(n, q.Band)] = true
|
||||
sets.Mode[qso.ModeClassKey(n, q.Mode)] = true
|
||||
sets.Slot[qso.SlotClassKey(n, q.Band, q.Mode)] = true
|
||||
}
|
||||
items = append(items, it)
|
||||
return nil
|
||||
})
|
||||
if perr != nil {
|
||||
return res, perr
|
||||
}
|
||||
a.invalidateAwardStats() // confirmations move award counts
|
||||
// Kept for ExportHamlogUnmatched. Held rather than written now: the operator
|
||||
// decides whether a list of 395 is worth a file.
|
||||
a.hamlogUnmatchedMu.Lock()
|
||||
a.hamlogUnmatched = unmatched
|
||||
a.hamlogUnmatchedMu.Unlock()
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
|
||||
}
|
||||
applog.Printf("hamlog cfm import: %d records, %d confirmed, %d matched (%d by mode class), %d unmatched",
|
||||
res.Total, res.Confirmed, res.Matched, res.ByClass, res.Unmatched)
|
||||
emit(fmt.Sprintf("%d records read, %d confirmed by HAMLOG.online, %d matched in the log (%d by mode class), %d unmatched",
|
||||
res.Total, res.Confirmed, res.Matched, res.ByClass, res.Unmatched))
|
||||
for _, s := range res.Samples {
|
||||
emit(" unmatched: " + s)
|
||||
}
|
||||
if res.Unmatched > len(res.Samples) {
|
||||
emit(fmt.Sprintf(" …and %d more — use \"Export unmatched (ADIF)…\" to get the whole list.",
|
||||
res.Unmatched-len(res.Samples)))
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": res.Matched, "total": res.Confirmed})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ExportHamlogUnmatched writes the confirmations the last import could not
|
||||
// place onto a QSO.
|
||||
//
|
||||
// They are the interesting half of the result: each one is a contact HAMLOG
|
||||
// believes it holds and this log does not agree about — a minute of drift, a
|
||||
// portable call, a band written differently, or a QSO genuinely missing. A
|
||||
// count cannot be worked through; a file can be opened, sorted and compared.
|
||||
//
|
||||
// Written as ADIF because that is what every other tool reads, and because it
|
||||
// can be handed straight back to an import once the discrepancies are settled.
|
||||
func (a *App) ExportHamlogUnmatched(path string) (int, error) {
|
||||
a.hamlogUnmatchedMu.Lock()
|
||||
rows := a.hamlogUnmatched
|
||||
a.hamlogUnmatchedMu.Unlock()
|
||||
if len(rows) == 0 {
|
||||
return 0, fmt.Errorf("nothing to export: the last import left no unmatched confirmations")
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return 0, fmt.Errorf("empty path")
|
||||
}
|
||||
recs := make([]string, 0, len(rows))
|
||||
for i := range rows {
|
||||
recs = append(recs, adif.FullRecordADIF(rows[i]))
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(adif.BatchRecordsADIF(recs)), 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
applog.Printf("hamlog cfm import: exported %d unmatched confirmation(s) to %s", len(rows), path)
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
// hamlogQSLDateKey stamps WHEN the confirmation was read back. Their export
|
||||
// carries no confirmation date of its own, so this is the import date — which
|
||||
// is honest about what it knows, unlike borrowing the QSO date.
|
||||
const hamlogQSLDateKey = "APP_OPSLOG_HAMLOG_QSL_DATE"
|
||||
|
||||
// hamlogRecordConfirmed reads their flag off a raw ADIF record. The primary key
|
||||
// is the one their own export writes; the alternates are the names OpsLog and
|
||||
// other tools have used, so a file that went through another logger still reads.
|
||||
func hamlogRecordConfirmed(rec adif.Record) bool {
|
||||
keys := append([]string{award.HamlogQSLKey}, award.HamlogAltKeys()...)
|
||||
for _, k := range keys {
|
||||
v := strings.ToUpper(strings.TrimSpace(rec[strings.ToLower(k)]))
|
||||
if v != "" && v != "N" && v != "NO" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hamlogCfmDate is the date to stamp: the import date, in ADIF form.
|
||||
func hamlogCfmDate(rec adif.Record) string {
|
||||
// If a future export ever carries one, take it rather than today's date.
|
||||
for _, k := range []string{"app_hamlog_qso_cfm_date", "qslrdate"} {
|
||||
if v := strings.TrimSpace(rec[k]); len(v) == 8 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return time.Now().UTC().Format("20060102")
|
||||
}
|
||||
@@ -168,7 +168,48 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
twoSrc := r.twoSrc
|
||||
r.mu.Unlock()
|
||||
|
||||
// Capture goroutine(s) feed the per-source queues.
|
||||
// The radio side is either CAPTURED from a sound device or PUSHED in from
|
||||
// somewhere else. Over the network there is no sound device at all: an
|
||||
// IC-705 reached by LAN streams its receive audio over the Icom protocol,
|
||||
// and the operator's audio settings can only offer the PC's own microphone
|
||||
// and speakers. fromDev == PushedSource says "someone else will feed me",
|
||||
// and PushRX is how they do it.
|
||||
if fromDev == PushedSource {
|
||||
LogSink("recorder: radio audio is pushed in (network), not captured from a device")
|
||||
} else {
|
||||
r.startRadioCapture(fromDev, stop)
|
||||
}
|
||||
if twoSrc {
|
||||
r.startMicCapture(micDev, stop)
|
||||
}
|
||||
r.finishStart(fromDev, micDev, twoSrc, stop)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PushedSource is the "device" name that means the radio audio arrives through
|
||||
// PushRX instead of a capture stream.
|
||||
const PushedSource = "\x00pushed"
|
||||
|
||||
// PushRX feeds one chunk of 16-bit mono PCM from a non-device source.
|
||||
//
|
||||
// Safe to call when nothing is recording — the samples are dropped, which is
|
||||
// what should happen: the network stream runs whenever the rig is connected,
|
||||
// and the recorder only wants it between BeginQSO and the save.
|
||||
func (r *Recorder) PushRX(pcm []byte) {
|
||||
r.mu.Lock()
|
||||
running := r.running
|
||||
r.mu.Unlock()
|
||||
if !running || len(pcm) == 0 {
|
||||
return
|
||||
}
|
||||
sm := bytesToInt16(pcm)
|
||||
r.srcMu.Lock()
|
||||
r.bufA = append(r.bufA, sm...)
|
||||
r.lastA = time.Now()
|
||||
r.srcMu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Recorder) startRadioCapture(fromDev string, stop chan struct{}) {
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
@@ -187,7 +228,9 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err)
|
||||
}
|
||||
}()
|
||||
if twoSrc {
|
||||
}
|
||||
|
||||
func (r *Recorder) startMicCapture(micDev string, stop chan struct{}) {
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
@@ -202,8 +245,10 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
LogSink("recorder: capture from %q failed: %v", DeviceName(micDev), err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// finishStart logs what is being recorded and arms the silence watchdog.
|
||||
func (r *Recorder) finishStart(fromDev, micDev string, twoSrc bool, stop chan struct{}) {
|
||||
// Name the devices being recorded FROM, once, at the start.
|
||||
//
|
||||
// A station with two radios has two sets of endpoints, and the recorder will
|
||||
@@ -262,7 +307,6 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// mixTick drains the source queues, mixes what's available, and appends to the
|
||||
|
||||
+65
-2
@@ -109,7 +109,7 @@ type Def struct {
|
||||
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
|
||||
|
||||
// --- Confirmation ---
|
||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
|
||||
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|hamlog|custom
|
||||
Validate []string `json:"validate,omitempty"` // validated/granted sources
|
||||
// The "custom" source, for confirmations OpsLog has no dedicated column for:
|
||||
// ConfirmField names a QSO field or an ADIF extras key, ConfirmValue the
|
||||
@@ -344,7 +344,7 @@ func Migrate(defs []Def) ([]Def, bool) {
|
||||
func Fields() []string {
|
||||
return []string{
|
||||
"dxcc", "cqz", "ituz", "prefix", "callsign",
|
||||
"state", "us_county", "cont", "country", "grid", "grid4",
|
||||
"state", "county", "us_county", "cont", "country", "grid", "grid4",
|
||||
"iota", "sota_ref", "pota_ref", "wwff",
|
||||
"name", "qth", "address", "comment", "note",
|
||||
}
|
||||
@@ -1373,6 +1373,13 @@ func fieldRaw(field string, q *qso.QSO) string {
|
||||
return q.Callsign
|
||||
case "state":
|
||||
return q.State
|
||||
case "county":
|
||||
// CNTY as it stands, with nothing prepended. us_county below answers a
|
||||
// different question — it keys the county to its state, because two US
|
||||
// states each have a Jefferson County. Outside the United States that
|
||||
// prefixing is wrong: an RDA district (RO-19) and a Japanese city code
|
||||
// are already unique, and a state is not what qualifies them.
|
||||
return q.County
|
||||
case "us_county":
|
||||
return USCountyKey(q.State, q.County)
|
||||
case "cont":
|
||||
@@ -1481,6 +1488,18 @@ func confirmed(q *qso.QSO, sources []string, d *Def) bool {
|
||||
if isYes(q.QRZComDownloadStatus) {
|
||||
return true
|
||||
}
|
||||
case "hamlog":
|
||||
// HAMLOG.online, a first-class source rather than something to express
|
||||
// through "custom" — its confirmations feed its own award programme
|
||||
// and operators reach for it the same way they reach for LoTW.
|
||||
//
|
||||
// Read from the ADIF EXTRAS, not a column: the ADIF standard names a
|
||||
// field for hamlog.EU (HAMLOGEU_QSO_UPLOAD_STATUS) and none for
|
||||
// hamlog.ONLINE, and borrowing the other site's field would write a
|
||||
// falsehood into every exported log.
|
||||
if hamlogConfirmed(q) {
|
||||
return true
|
||||
}
|
||||
case "custom":
|
||||
if customConfirmed(q, d) {
|
||||
return true
|
||||
@@ -1490,6 +1509,46 @@ func confirmed(q *qso.QSO, sources []string, d *Def) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// HamlogQSLKey is where a HAMLOG.online confirmation is recorded.
|
||||
//
|
||||
// THEIR field name, taken from a real export:
|
||||
//
|
||||
// <APP_HAMLOG_QSO_CFM:1>Y
|
||||
//
|
||||
// Using the site's own key rather than one of ours is what makes an import
|
||||
// simply work: a log downloaded from HAMLOG carries its confirmations into
|
||||
// OpsLog with nothing to rename and no mapping to configure. The guesses this
|
||||
// replaced (APP_HAMLOG_QSL and friends) were all wrong, which is the argument
|
||||
// for reading a real file before naming a field.
|
||||
const HamlogQSLKey = "APP_HAMLOG_QSO_CFM"
|
||||
|
||||
// hamlogAltKeys are older or hand-written spellings still honoured on read.
|
||||
// APP_OPSLOG_HAMLOG_QSL is the one OpsLog itself wrote before a real export was
|
||||
// available; a log stamped with it keeps counting.
|
||||
var hamlogAltKeys = []string{"APP_OPSLOG_HAMLOG_QSL", "APP_HAMLOG_QSL", "APP_HAMLOGONLINE_QSL", "HAMLOG_QSL_RCVD"}
|
||||
|
||||
// hamlogConfirmed reports whether a QSO carries a HAMLOG.online confirmation.
|
||||
//
|
||||
// Any non-empty value counts, except an explicit "N": their export could carry
|
||||
// a date, a Y, or a match id, and demanding one of them would silently confirm
|
||||
// nothing on the two shapes we guessed wrong.
|
||||
func hamlogConfirmed(q *qso.QSO) bool {
|
||||
if q == nil || q.Extras == nil {
|
||||
return false
|
||||
}
|
||||
for _, k := range append([]string{HamlogQSLKey}, hamlogAltKeys...) {
|
||||
v := strings.TrimSpace(q.Extras[k])
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(v, "N") || strings.EqualFold(v, "NO") {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// customConfirmed answers the operator-defined confirmation source: the field
|
||||
// named by ConfirmField, optionally required to hold one of ConfirmValue's
|
||||
// comma-separated values.
|
||||
@@ -1580,3 +1639,7 @@ func sortedBands(m map[string]int) []string {
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// HamlogAltKeys exposes the alternate spellings so a reader outside this package
|
||||
// (the confirmations import) accepts exactly what the award engine accepts.
|
||||
func HamlogAltKeys() []string { return append([]string(nil), hamlogAltKeys...) }
|
||||
|
||||
@@ -64,3 +64,61 @@ func TestConfirmedSources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HAMLOG.online is a named confirmation source, not something to express through
|
||||
// "custom": an award ticks it the way it ticks LoTW.
|
||||
func TestHamlogConfirmationSource(t *testing.T) {
|
||||
def := Def{Confirm: []string{"hamlog"}, Validate: []string{"hamlog"}}
|
||||
yes := func(extras map[string]string) bool {
|
||||
return Confirmed(&qso.QSO{Extras: extras}, def, def.Confirm)
|
||||
}
|
||||
if !yes(map[string]string{HamlogQSLKey: "Y"}) {
|
||||
t.Error("our own key did not confirm")
|
||||
}
|
||||
// Their export's field name is unpublished, so the plausible shapes count too.
|
||||
if !yes(map[string]string{"APP_HAMLOG_QSL": "20260823"}) {
|
||||
t.Error("a date in an alternative key did not confirm")
|
||||
}
|
||||
// A value is not a confirmation when it says no.
|
||||
if yes(map[string]string{HamlogQSLKey: "N"}) {
|
||||
t.Error(`"N" was taken as a confirmation`)
|
||||
}
|
||||
if yes(map[string]string{HamlogQSLKey: " "}) {
|
||||
t.Error("blank was taken as a confirmation")
|
||||
}
|
||||
if yes(nil) || yes(map[string]string{"SOMETHING_ELSE": "Y"}) {
|
||||
t.Error("confirmed with nothing to confirm it")
|
||||
}
|
||||
// And it does not leak into an award that never asked for it.
|
||||
other := Def{Confirm: []string{"lotw"}}
|
||||
if Confirmed(&qso.QSO{Extras: map[string]string{HamlogQSLKey: "Y"}}, other, other.Confirm) {
|
||||
t.Error("a hamlog confirmation counted for an award that only accepts LoTW")
|
||||
}
|
||||
}
|
||||
|
||||
// The field name is theirs, taken from a real export:
|
||||
//
|
||||
// <APP_HAMLOG_QSO_CFM:1>Y
|
||||
//
|
||||
// Pinned as a test because it was GUESSED wrong first — an import that carries
|
||||
// its confirmations into OpsLog with nothing to rename is the whole point, and
|
||||
// a renamed constant would break it in silence.
|
||||
func TestHamlogUsesTheSitesOwnFieldName(t *testing.T) {
|
||||
if HamlogQSLKey != "APP_HAMLOG_QSO_CFM" {
|
||||
t.Fatalf("HamlogQSLKey = %q — their export writes APP_HAMLOG_QSO_CFM", HamlogQSLKey)
|
||||
}
|
||||
def := Def{Confirm: []string{"hamlog"}}
|
||||
// The record as HAMLOG exports it.
|
||||
q := &qso.QSO{Extras: map[string]string{
|
||||
"APP_HAMLOG_R150COUNTRY": "Russia",
|
||||
"APP_HAMLOG_QSO_CFM": "Y",
|
||||
}}
|
||||
if !Confirmed(q, def, def.Confirm) {
|
||||
t.Error("a real HAMLOG export did not confirm")
|
||||
}
|
||||
// Anything OpsLog stamped before that export existed keeps counting.
|
||||
old := &qso.QSO{Extras: map[string]string{"APP_OPSLOG_HAMLOG_QSL": "Y"}}
|
||||
if !Confirmed(old, def, def.Confirm) {
|
||||
t.Error("a QSO stamped with the older key stopped counting")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// A district lives in CNTY as-is; the US award field keys it to the state
|
||||
// because county names repeat across states. Confusing the two silently breaks
|
||||
// whichever award is not American.
|
||||
func TestCountyFieldIsRawWhileUSCountyIsKeyed(t *testing.T) {
|
||||
q := &qso.QSO{State: "TX", County: "RO-19"}
|
||||
if got := fieldRaw("county", q); got != "RO-19" {
|
||||
t.Fatalf("county = %q, want RO-19", got)
|
||||
}
|
||||
if got := fieldRaw("us_county", q); got == "RO-19" {
|
||||
t.Fatalf("us_county should key the county to its state, got %q", got)
|
||||
}
|
||||
var found bool
|
||||
for _, f := range Fields() {
|
||||
if f == "county" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("county missing from the award field list")
|
||||
}
|
||||
}
|
||||
@@ -487,6 +487,8 @@ type FlexController interface {
|
||||
// keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
|
||||
ZoomPan(bandwidthMHz, freqMHz float64, centre bool) error
|
||||
SetTXSlice(int) error // make slice idx the transmitter (tx=1)
|
||||
// SetTXSliceFrequency moves the TRANSMIT slice only — split pile-up chasing.
|
||||
SetTXSliceFrequency(int64) error
|
||||
SetSplit(bool) error
|
||||
SetNB(bool) error
|
||||
SetNBLevel(int) error
|
||||
|
||||
+127
-4
@@ -60,6 +60,9 @@ type Flex struct {
|
||||
txRawLogged bool // log the first raw transmit status once (field-name audit)
|
||||
|
||||
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||
// foreignSpotSeen counts what probeForeignSpot has already reported, so a
|
||||
// skimmer posting all evening cannot turn the log into its own transcript.
|
||||
foreignSpotSeen int
|
||||
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
||||
@@ -92,6 +95,16 @@ type Flex struct {
|
||||
// the spot, since the radio's own notification carries only an index. The host
|
||||
// wires this to fill the entry form and to size the panadapter. Set before Connect.
|
||||
OnSpotClick func(callsign string, freqHz int64, mode string)
|
||||
|
||||
// OnForeignSpot is called for every spot posted to the radio by a program
|
||||
// OTHER than OpsLog, with the spot's callsign field and its frequency.
|
||||
//
|
||||
// A CW skimmer posts what it decodes, so the marker a DX operator's report
|
||||
// leaves on the panadapter arrives here — that is what the split chaser acts
|
||||
// on. Deliberately raw: this package reports what the radio said and does not
|
||||
// decide what a marker looks like, because the marker text is configured in
|
||||
// the skimmer, by the operator, and only they know what they chose.
|
||||
OnForeignSpot func(callsign string, freqHz int64)
|
||||
}
|
||||
|
||||
// panView is one panadapter's visible window, in MHz.
|
||||
@@ -278,11 +291,18 @@ func (f *Flex) Connect() error {
|
||||
f.send("sub pan all") // panadapter centre/bandwidth, so a zoom knows where the display already is
|
||||
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
|
||||
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
||||
if f.spotsEnabled {
|
||||
// Subscribe so the radio pushes existing spots (we learn their indices),
|
||||
// then wipe the panadapter so stale spots from a previous session or
|
||||
// another logger are cleared before we start adding our own.
|
||||
// Always subscribed, even when OpsLog draws no spots of its own: the feed is
|
||||
// read-only and it is how the spots posted by OTHER programs arrive — a CW
|
||||
// skimmer's decoded reports, which the split chaser acts on. Tying the
|
||||
// subscription to our own overlay meant a station using SDC and no OpsLog
|
||||
// spots heard nothing at all.
|
||||
f.send("sub spot all")
|
||||
if f.spotsEnabled {
|
||||
// Wipe the panadapter so stale spots from a previous session or another
|
||||
// logger are cleared before we start adding our own. Only when the
|
||||
// overlay is ours to manage: "spot clear" removes EVERY spot on the
|
||||
// radio, a skimmer's included, and taking those away from an operator
|
||||
// who never asked us to draw anything would be pure vandalism.
|
||||
go f.clearSpotsOnConnect(conn)
|
||||
}
|
||||
return nil
|
||||
@@ -883,6 +903,10 @@ func (f *Flex) handleStatus(payload string) {
|
||||
f.spotIdx[idx] = true
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if !removed {
|
||||
f.probeForeignSpot(payload)
|
||||
f.reportForeignSpot(payload)
|
||||
}
|
||||
}
|
||||
debugLog.Printf("Flex: status %s", payload)
|
||||
}
|
||||
@@ -1377,6 +1401,105 @@ func (f *Flex) ZoomPan(bandwidthMHz, freqMHz float64, centre bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// foreignSpotProbeMax bounds the probe below. Enough lines to see what another
|
||||
// program writes into a spot, few enough that a running skimmer — which posts
|
||||
// hundreds an hour — cannot fill the log file with them.
|
||||
const foreignSpotProbeMax = 60
|
||||
|
||||
// probeForeignSpot records, verbatim, the spots this radio receives from
|
||||
// programs OTHER than OpsLog.
|
||||
//
|
||||
// A CW skimmer (SDC, for one) posts what it decodes as panadapter spots, the
|
||||
// exchange included: chasing a split pileup means finding the "5NN" the DX just
|
||||
// sent and moving the transmit slice there. Acting on that requires knowing
|
||||
// exactly what the spot looks like — which field carries the text, whether the
|
||||
// report is the callsign or the comment, what source names the skimmer — and no
|
||||
// amount of reasoning substitutes for reading real lines off a real radio.
|
||||
//
|
||||
// So this logs and does nothing else. It is deliberately not a feature.
|
||||
func (f *Flex) probeForeignSpot(payload string) {
|
||||
if strings.Contains(payload, "source=OpsLog") {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
if f.foreignSpotSeen >= foreignSpotProbeMax {
|
||||
f.mu.Unlock()
|
||||
return
|
||||
}
|
||||
f.foreignSpotSeen++
|
||||
n := f.foreignSpotSeen
|
||||
f.mu.Unlock()
|
||||
debugLog.Printf("flex: foreign spot %d/%d: %s", n, foreignSpotProbeMax, payload)
|
||||
if n == foreignSpotProbeMax {
|
||||
debugLog.Printf("flex: that is enough foreign spots to go on — no more will be logged this session")
|
||||
}
|
||||
}
|
||||
|
||||
// reportForeignSpot hands another program's spot to OnForeignSpot.
|
||||
//
|
||||
// Off the reader goroutine, like OnSpotClick: the handler tunes the radio, and
|
||||
// a command sent from inside the reader would deadlock against the socket it is
|
||||
// reading.
|
||||
func (f *Flex) reportForeignSpot(payload string) {
|
||||
if strings.Contains(payload, "source=OpsLog") {
|
||||
return
|
||||
}
|
||||
handler := f.OnForeignSpot
|
||||
if handler == nil {
|
||||
return
|
||||
}
|
||||
var call string
|
||||
var hz int64
|
||||
for _, kv := range strings.Fields(payload) {
|
||||
eq := strings.IndexByte(kv, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
switch kv[:eq] {
|
||||
case "callsign":
|
||||
call = kv[eq+1:]
|
||||
case "rx_freq":
|
||||
if mhz, err := strconv.ParseFloat(kv[eq+1:], 64); err == nil {
|
||||
hz = int64(math.Round(mhz * 1e6))
|
||||
}
|
||||
}
|
||||
}
|
||||
if call == "" || hz <= 0 {
|
||||
return
|
||||
}
|
||||
go handler(call, hz)
|
||||
}
|
||||
|
||||
// SetTXSliceFrequency tunes the TRANSMIT slice, leaving the receive slice where
|
||||
// it is. That distinction is the whole point in split: the operator listens to
|
||||
// the DX on one slice and moves the other around the pile-up.
|
||||
func (f *Flex) SetTXSliceFrequency(hz int64) error {
|
||||
if hz <= 0 {
|
||||
return fmt.Errorf("flex: invalid frequency")
|
||||
}
|
||||
f.mu.Lock()
|
||||
idx := -1
|
||||
for i, s := range f.slices {
|
||||
if s != nil && s.inUse && s.tx {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx >= 0 && f.slices[idx] != nil {
|
||||
f.slices[idx].freqHz = hz // optimistic, like SetFrequency
|
||||
}
|
||||
connected := f.conn != nil
|
||||
f.mu.Unlock()
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("flex: no transmit slice")
|
||||
}
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
f.send(fmt.Sprintf("slice t %d %.6f", idx, float64(hz)/1e6))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendSpot renders a cluster spot on the panadapter via "spot add". Spots carry
|
||||
// a lifetime so the radio expires them on its own (the API has no "spot clear").
|
||||
// Per the SmartSDR API, spaces inside a field value are encoded as 0x7F.
|
||||
|
||||
@@ -189,7 +189,7 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I
|
||||
}
|
||||
p, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err)
|
||||
return nil, fmt.Errorf("open %s @ %d baud: %w%s", b.portName, b.baud, err, busyHint(b.portName, err))
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ func (k *Kenwood) Connect() error {
|
||||
if k.host != "" {
|
||||
return fmt.Errorf("kenwood: connect %s: %w", k.host, err)
|
||||
}
|
||||
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
|
||||
return fmt.Errorf("kenwood: open %s @ %d baud: %w%s", k.portName, k.baud, err, busyHint(k.portName, err))
|
||||
}
|
||||
p.SetReadTimeout(300 * time.Millisecond)
|
||||
k.port = p
|
||||
|
||||
@@ -500,10 +500,30 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
// deliberately; a spot click is a request to change frequency, not to change
|
||||
// VFO. On SUB the direct FreqB write below does the whole job.
|
||||
onSubVFO := vfo == "B" || vfo == "BB" || vfo == "BA"
|
||||
// …and NEVER on a Yaesu.
|
||||
//
|
||||
// On Yaesu the call is useless at best: an FT-891 logged "SetSimplexMode OK"
|
||||
// on every spot click while FreqA never moved (see below — the direct property
|
||||
// write is what actually tunes these rigs). On an FT-2000 it is worse than
|
||||
// useless. From a log of one QSY, before and after the call:
|
||||
//
|
||||
// Vfo="AB"(0x80) Split=0x10000 (off) → the operator's state
|
||||
// Vfo="BA"(0x100) Split=0x8000 (ON) → after SetSimplexMode
|
||||
//
|
||||
// The rig-agnostic "receive and transmit HERE, simplex" method turned split ON
|
||||
// and moved reception to VFO B, which is exactly what the operator reported:
|
||||
// OpsLog showing B instead of A. OpsLog was not misreading the radio — it was
|
||||
// reading it correctly after having moved it itself.
|
||||
//
|
||||
// Restricted to Yaesu because that is where the evidence is: on Icom the call
|
||||
// is authoritative and the direct write is the unreliable one.
|
||||
isYaesu := isYaesuRig(rigType)
|
||||
simplexOK := false
|
||||
switch {
|
||||
case onSubVFO:
|
||||
debugLog.Printf("OmniRig.SetFrequency: on VFO %q — skipping SetSimplexMode so the rig stays on SUB", vfo)
|
||||
case isYaesu:
|
||||
debugLog.Printf("OmniRig.SetFrequency: %q is a Yaesu — skipping SetSimplexMode (a no-op on some, and on an FT-2000 it turns split on and jumps to VFO B); the direct write below does the work", rigType)
|
||||
default:
|
||||
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
|
||||
simplexOK = true
|
||||
|
||||
@@ -125,3 +125,24 @@ func TestOmniRigWriteTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetSimplexMode is skipped on Yaesu, and that is not a preference.
|
||||
//
|
||||
// On an FT-891 it returned OK on every spot click while the rig never moved. On
|
||||
// an FT-2000 it did something worse — a log of one QSY shows the state going
|
||||
// from Vfo="AB" split off to Vfo="BA" split ON, so the operator's reception
|
||||
// jumped to VFO B and OpsLog, reading the radio correctly, displayed B. The
|
||||
// direct FreqA/Freq write does the tuning on these rigs.
|
||||
func TestYaesuRigsAreRecognisedForTheSimplexSkip(t *testing.T) {
|
||||
for _, yes := range []string{"FT-2000", "FT-891", "FTDX10", "ftdx101", " FT-991A "} {
|
||||
if !isYaesuRig(yes) {
|
||||
t.Errorf("%q not recognised as a Yaesu — SetSimplexMode would still be called on it", yes)
|
||||
}
|
||||
}
|
||||
// Everything else keeps the call: on Icom it is the authoritative one.
|
||||
for _, no := range []string{"IC-7610", "TS-590", "K3", "", "Flex-6600"} {
|
||||
if isYaesuRig(no) {
|
||||
t.Errorf("%q wrongly treated as a Yaesu", no)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package cat
|
||||
|
||||
// "Serial port busy" is a true statement that helps nobody.
|
||||
//
|
||||
// A COM port has exactly one owner, and when a rig's port is refused the owner
|
||||
// is almost always another program on the same desktop — most often OmniRig,
|
||||
// which stays resident once any application has activated it and keeps the port
|
||||
// of the rig configured in it. An operator switching from OmniRig to a native
|
||||
// backend therefore hits this the moment they save: OpsLog is now asking for a
|
||||
// port OmniRig never let go of. The message named none of that.
|
||||
//
|
||||
// It also happens the other way round, and the wording says so rather than
|
||||
// accusing: a digital application (WSJT-X, JTDX, MSHV) configured on the rig's
|
||||
// port directly, rather than through OpsLog's CAT sharing, holds it just as
|
||||
// firmly.
|
||||
|
||||
import "strings"
|
||||
|
||||
// busyHint returns a sentence to append to an open error, or "" when the error
|
||||
// is not about the port being taken.
|
||||
//
|
||||
// Deliberately not a wrapped error: this is advice for a human reading a log,
|
||||
// and it must not change how any caller compares the error.
|
||||
func busyHint(port string, err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
busy := strings.Contains(msg, "busy") ||
|
||||
strings.Contains(msg, "access is denied") ||
|
||||
strings.Contains(msg, "denied") ||
|
||||
strings.Contains(msg, "in use")
|
||||
if !busy {
|
||||
return ""
|
||||
}
|
||||
return " — another program holds " + port +
|
||||
". OmniRig is the usual one (it stays running and keeps the port of the rig configured in it, so a native backend can never have it);" +
|
||||
" a digital application pointed straight at the rig instead of at OpsLog's CAT sharing does the same. Close it, then reconnect."
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func (x *Xiegu) Connect() error {
|
||||
}
|
||||
p, err := serial.Open(x.portName, &serial.Mode{BaudRate: x.baud})
|
||||
if err != nil {
|
||||
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
|
||||
return fmt.Errorf("xiegu: open %s @ %d baud: %w%s", x.portName, x.baud, err, busyHint(x.portName, err))
|
||||
}
|
||||
p.SetReadTimeout(200 * time.Millisecond)
|
||||
// Deassert DTR and RTS.
|
||||
|
||||
@@ -159,7 +159,7 @@ func (y *Yaesu) Connect() error {
|
||||
}
|
||||
p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud})
|
||||
if err != nil {
|
||||
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
|
||||
return fmt.Errorf("yaesu: open %s @ %d baud: %w%s", y.portName, y.baud, err, busyHint(y.portName, err))
|
||||
}
|
||||
// The modem lines are only touched when the operator asks for it — see the
|
||||
// note on Kenwood.lowerLines. Both defaults break somebody's station.
|
||||
|
||||
@@ -50,6 +50,10 @@ type YaesuTXState struct {
|
||||
Squelch int `json:"squelch"` // 0-100
|
||||
AGC string `json:"agc,omitempty"`
|
||||
Preamp int `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2
|
||||
// Antenna is the selected jack, 1-3, or 0 when the rig has no AN command —
|
||||
// an FT-891 or FT-991A has a single socket and answers nothing. 0 is what
|
||||
// tells the panel to draw no selector at all rather than a dead one.
|
||||
Antenna int `json:"antenna"`
|
||||
Att int `json:"att"` // 0=off, else dB
|
||||
NB bool `json:"nb"`
|
||||
NR bool `json:"nr"`
|
||||
@@ -80,6 +84,7 @@ type YaesuController interface {
|
||||
SetYaesuSquelch(int) error
|
||||
SetYaesuAGC(string) error
|
||||
SetYaesuPreamp(int) error
|
||||
SetYaesuAntenna(int) error
|
||||
SetYaesuAtt(int) error
|
||||
SetYaesuNB(bool) error
|
||||
SetYaesuNR(bool) error
|
||||
@@ -239,6 +244,24 @@ func (y *Yaesu) readPanelSettings() {
|
||||
if v, ok := y.askNum("PA0;", "PA0", 1); ok {
|
||||
y.panel.Preamp = v
|
||||
}
|
||||
// ANTENNA. "AN0;" → "AN01;" — the first digit is the receiver (0 = main,
|
||||
// 1 = sub on an FTDX101), the second the jack. A rig with one socket does
|
||||
// not implement it and simply says nothing, which askNum reports as
|
||||
// not-ok — and 0 then means "no antenna switching here".
|
||||
if v, ok := y.askNum("AN0;", "AN0", 1); ok {
|
||||
if y.panel.Antenna == 0 {
|
||||
debugLog.Printf("yaesu: antenna select available (AN0; → %d)", v)
|
||||
}
|
||||
y.panel.Antenna = v
|
||||
} else {
|
||||
// Said once, at the first read: an operator who expects the ANT row and
|
||||
// does not get it should find the reason in the log rather than wonder
|
||||
// whether OpsLog forgot the feature.
|
||||
if y.panel.Antenna != -1 {
|
||||
debugLog.Printf("yaesu: no answer to AN0; — this rig has no antenna selection, the ANT row stays hidden")
|
||||
}
|
||||
y.panel.Antenna = -1
|
||||
}
|
||||
if v, ok := y.askNum("RA0;", "RA0", 1); ok {
|
||||
y.panel.Att = yaesuAttDB(v)
|
||||
}
|
||||
@@ -356,6 +379,17 @@ func (y *Yaesu) SetYaesuPreamp(n int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("PA0%d;", clampInt(n, 0, 2)))
|
||||
}
|
||||
|
||||
// SetYaesuAntenna selects an antenna jack (1-3) on the main receiver.
|
||||
//
|
||||
// Verified against the FTDX10 command set; the FTDX101 adds a third jack and a
|
||||
// sub receiver, which is the "0" in AN0 — the day someone drives a sub receiver
|
||||
// from here it becomes a parameter rather than a constant. A rig without the
|
||||
// command ignores it, and readPanelSettings then keeps Antenna at 0, so the
|
||||
// control never appears in the first place.
|
||||
func (y *Yaesu) SetYaesuAntenna(n int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("AN0%d;", clampInt(n, 1, 3)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuAtt(db int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("RA0%d;", yaesuAttCode(db)))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseYaesuFreq(t *testing.T) {
|
||||
cases := []struct {
|
||||
@@ -380,3 +383,26 @@ func TestYaesuModeVFOSuffix(t *testing.T) {
|
||||
t.Errorf("setting CW on main sends %q, want MD03;", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// The antenna command, as the FTDX10 reference gives it: AN + receiver + jack.
|
||||
// The jack is clamped rather than trusted — a panel bug that sent AN09 would be
|
||||
// answered by the rig with silence, and the operator would be left wondering
|
||||
// which antenna they were on.
|
||||
func TestYaesuAntennaCommand(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in int
|
||||
want string
|
||||
}{
|
||||
{1, "AN01;"},
|
||||
{2, "AN02;"},
|
||||
{3, "AN03;"},
|
||||
{0, "AN01;"}, // below range → the first jack
|
||||
{9, "AN03;"}, // above range → the last
|
||||
{-1, "AN01;"},
|
||||
} {
|
||||
got := fmt.Sprintf("AN0%d;", clampInt(tc.in, 1, 3))
|
||||
if got != tc.want {
|
||||
t.Errorf("antenna %d → %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ const (
|
||||
// ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract,
|
||||
// only the instance URL differs, so one service handles both.
|
||||
ServiceCloudlog Service = "cloudlog"
|
||||
// ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO.
|
||||
ServiceHamlog Service = "hamlog"
|
||||
)
|
||||
|
||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||
@@ -130,6 +132,7 @@ type ExternalServices struct {
|
||||
HRDLog ServiceConfig `json:"hrdlog"`
|
||||
EQSL ServiceConfig `json:"eqsl"`
|
||||
Cloudlog ServiceConfig `json:"cloudlog"`
|
||||
Hamlog ServiceConfig `json:"hamlog"`
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HAMLOG.online — a cloud logbook whose confirmations feed its own award
|
||||
// programme, so operators want their contacts there as they make them.
|
||||
//
|
||||
// # Where this protocol comes from
|
||||
//
|
||||
// HAMLOG publishes no API documentation. What follows is read from THEIR OWN
|
||||
// client, the HAMLOG Agent (github.com/hamlogonline/Agent, Hamlog/hamlog_api.py)
|
||||
// — the authoritative source short of asking them, and the same code their own
|
||||
// users run:
|
||||
//
|
||||
// POST https://hamlog.online/api/agent/ (JSON in, JSON out)
|
||||
//
|
||||
// {"KEYSTATUS": {"APIKEY": k}} → {"STATUS":"OK","CALLSIGN":…,"EXPIRES":…}
|
||||
// {"ADIFADD": {"APIKEY": k, "ADIFDATA": rec}} → {"STATUS":"OK"}
|
||||
// {"QSOADD": {"APIKEY": k, "DATA": {…}}} → field map, keys upper-cased
|
||||
// {"LOGOUT": {"APIKEY": k}}
|
||||
//
|
||||
// A failure answers {"ERROR": "…"} with no STATUS, so success is "STATUS is
|
||||
// exactly OK" rather than "no error field" — an unknown reply shape must not
|
||||
// read as an accepted QSO.
|
||||
//
|
||||
// ADIFADD is the verb used here: OpsLog already builds a full ADIF record for
|
||||
// every other service, and sending the same bytes keeps one representation of
|
||||
// a contact instead of two.
|
||||
//
|
||||
// The operator gets their key from https://hamlog.online/account/agent.php.
|
||||
const (
|
||||
hamlogAPIEndpoint = "https://hamlog.online/api/agent/"
|
||||
hamlogKeyPage = "https://hamlog.online/account/agent.php"
|
||||
)
|
||||
|
||||
// hamlogReply is the shape both success and failure share.
|
||||
type hamlogReply struct {
|
||||
Status string `json:"STATUS"`
|
||||
Error string `json:"ERROR"`
|
||||
Callsign string `json:"CALLSIGN"`
|
||||
Expires any `json:"EXPIRES"` // seconds since the epoch; string or number depending on the verb
|
||||
}
|
||||
|
||||
// hamlogPost sends one verb and decodes the reply.
|
||||
func hamlogPost(ctx context.Context, client *http.Client, endpoint string, body map[string]any) (hamlogReply, error) {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return hamlogReply{}, fmt.Errorf("hamlog: encode request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return hamlogReply{}, fmt.Errorf("hamlog: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return hamlogReply{}, fmt.Errorf("hamlog: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
var r hamlogReply
|
||||
if jerr := json.Unmarshal(raw, &r); jerr != nil {
|
||||
// Not JSON at all — a proxy error page, a maintenance notice. Report what
|
||||
// arrived rather than "invalid character '<'", which tells an operator
|
||||
// nothing about their own setup.
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200]
|
||||
}
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return hamlogReply{}, fmt.Errorf("hamlog: unexpected reply: %s", msg)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// hamlogFailure turns a reply into a human-readable reason, or "" on success.
|
||||
func hamlogFailure(r hamlogReply) string {
|
||||
if strings.EqualFold(strings.TrimSpace(r.Status), "OK") {
|
||||
return ""
|
||||
}
|
||||
if e := strings.TrimSpace(r.Error); e != "" {
|
||||
return e
|
||||
}
|
||||
return "rejected"
|
||||
}
|
||||
|
||||
// UploadHamlog pushes one ADIF record to HAMLOG.online.
|
||||
func UploadHamlog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||
return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord)
|
||||
}
|
||||
|
||||
func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||
key := strings.TrimSpace(cfg.APIKey)
|
||||
if key == "" {
|
||||
return UploadResult{}, fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage)
|
||||
}
|
||||
rec := strings.TrimSpace(adifRecord)
|
||||
if rec == "" {
|
||||
return UploadResult{}, fmt.Errorf("hamlog: empty ADIF record")
|
||||
}
|
||||
r, err := hamlogPost(ctx, client, endpoint, map[string]any{
|
||||
"ADIFADD": map[string]any{"APIKEY": key, "ADIFDATA": rec},
|
||||
})
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
if reason := hamlogFailure(r); reason != "" {
|
||||
return UploadResult{OK: false, Message: reason}, nil
|
||||
}
|
||||
return UploadResult{OK: true}, nil
|
||||
}
|
||||
|
||||
// CheckHamlogKey validates an API key and reports the callsign it belongs to.
|
||||
//
|
||||
// Worth its own call because HAMLOG offers what no other service here does: the
|
||||
// key can be checked BEFORE the first QSO, and the answer names the account. An
|
||||
// operator who pasted the key of another callsign — or one that has expired —
|
||||
// finds out in the settings panel rather than through a week of silent refusals.
|
||||
func CheckHamlogKey(ctx context.Context, client *http.Client, key string) (callsign string, err error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage)
|
||||
}
|
||||
r, perr := hamlogPost(ctx, client, hamlogAPIEndpoint, map[string]any{
|
||||
"KEYSTATUS": map[string]any{"APIKEY": key},
|
||||
})
|
||||
if perr != nil {
|
||||
return "", perr
|
||||
}
|
||||
if reason := hamlogFailure(r); reason != "" {
|
||||
return "", fmt.Errorf("hamlog: %s", reason)
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(r.Callsign)), nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The verb, the key and the record must arrive in the shape HAMLOG's own agent
|
||||
// sends — this is read from their client, not from documentation, so the test
|
||||
// pins it rather than trusting a memory of it.
|
||||
func TestUploadHamlogRequestShape(t *testing.T) {
|
||||
var got map[string]map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method %s, want POST", r.Method)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Errorf("Content-Type %q", ct)
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &got)
|
||||
_, _ = w.Write([]byte(`{"STATUS":"OK"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "<call:5>F4BPO <eor>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.OK {
|
||||
t.Fatalf("upload not OK: %+v", res)
|
||||
}
|
||||
add, ok := got["ADIFADD"]
|
||||
if !ok {
|
||||
t.Fatalf("no ADIFADD verb in %v", got)
|
||||
}
|
||||
if add["APIKEY"] != "KEY123" {
|
||||
t.Errorf("APIKEY = %v", add["APIKEY"])
|
||||
}
|
||||
if add["ADIFDATA"] != "<call:5>F4BPO <eor>" {
|
||||
t.Errorf("ADIFDATA = %v", add["ADIFDATA"])
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal must be reported as a refusal. Their failure shape carries ERROR
|
||||
// and no STATUS, so "no error field" would have read an unknown reply as an
|
||||
// accepted QSO — which is how a contact goes missing without anyone noticing.
|
||||
func TestHamlogFailureIsNotSuccess(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
body string
|
||||
wantOK bool
|
||||
wantSaid string
|
||||
}{
|
||||
{`{"STATUS":"OK"}`, true, ""},
|
||||
{`{"ERROR":"Invalid API key"}`, false, "Invalid API key"},
|
||||
{`{"STATUS":"FAILED"}`, false, "rejected"},
|
||||
{`{}`, false, "rejected"}, // an empty object is not an acceptance
|
||||
} {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "K"}, "<eor>")
|
||||
srv.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.body, err)
|
||||
}
|
||||
if res.OK != tc.wantOK {
|
||||
t.Errorf("%s → OK=%v, want %v", tc.body, res.OK, tc.wantOK)
|
||||
}
|
||||
if !tc.wantOK && res.Message != tc.wantSaid {
|
||||
t.Errorf("%s → message %q, want %q", tc.body, res.Message, tc.wantSaid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing leaves without a key, and the message says where to get one.
|
||||
func TestUploadHamlogNeedsAKey(t *testing.T) {
|
||||
_, err := UploadHamlog(context.Background(), nil, ServiceConfig{}, "<eor>")
|
||||
if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) {
|
||||
t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage)
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
||||
cfg.HRDLog = cfg.HRDLog.normalised()
|
||||
cfg.EQSL = cfg.EQSL.normalised()
|
||||
cfg.Cloudlog = cfg.Cloudlog.normalised()
|
||||
cfg.Hamlog = cfg.Hamlog.normalised()
|
||||
m.cfg = cfg
|
||||
|
||||
// Summary of what is armed, written at startup and on every settings save.
|
||||
@@ -152,6 +153,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
||||
}{
|
||||
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
|
||||
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
|
||||
{"hamlog", cfg.Hamlog},
|
||||
} {
|
||||
if s.cfg.AutoUpload {
|
||||
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
|
||||
@@ -227,6 +229,14 @@ func (m *Manager) OnQSOLogged(id int64) {
|
||||
m.route(ServiceCloudlog, id, c)
|
||||
}
|
||||
}
|
||||
// HAMLOG.online — one API key and nothing else to get wrong.
|
||||
if h := cfg.Hamlog; h.AutoUpload {
|
||||
if h.APIKey == "" {
|
||||
m.logf("extsvc: hamlog auto-upload is ON but no API key is set (QSO %d not sent)", id)
|
||||
} else {
|
||||
m.route(ServiceHamlog, id, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// route sends a logged QSO down the configured timing path: queue it for the
|
||||
@@ -277,6 +287,9 @@ func (m *Manager) onCloseServices() []Service {
|
||||
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
|
||||
out = append(out, ServiceCloudlog)
|
||||
}
|
||||
if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" {
|
||||
out = append(out, ServiceHamlog)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -323,6 +336,8 @@ func (m *Manager) FlushOnClose() int {
|
||||
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
|
||||
case ServiceCloudlog:
|
||||
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
|
||||
case ServiceHamlog:
|
||||
uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog)
|
||||
}
|
||||
}
|
||||
return uploaded
|
||||
@@ -644,6 +659,16 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
||||
return false, false
|
||||
}
|
||||
res, err = UploadCloudlog(ctx, m.deps.Client, cfg, record)
|
||||
case ServiceHamlog:
|
||||
// The station callsign is whatever the QSO carries: HAMLOG files the
|
||||
// contact under the account the API key belongs to, and KEYSTATUS is how
|
||||
// the operator checks that account is the right one.
|
||||
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 = UploadHamlog(ctx, m.deps.Client, cfg, record)
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -24,3 +24,29 @@ func TestBandIsNotBulkEditableAlone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A log uploaded to HAMLOG.online BY HAND has to be markable afterwards, or
|
||||
// OpsLog offers to send every one of those contacts again. Both directions live
|
||||
// in extras — the ADIF names a field for hamlog.EU and none for hamlog.ONLINE —
|
||||
// so they must be reachable through the extras path, not the column one.
|
||||
func TestHamlogFieldsAreBulkEditable(t *testing.T) {
|
||||
for field, want := range map[string]string{
|
||||
"hamlog_sent": "APP_OPSLOG_HAMLOG_SENT",
|
||||
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
|
||||
// Their field name — see award.HamlogQSLKey. Editing it by hand and
|
||||
// importing their ADIF must land in the SAME place.
|
||||
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
|
||||
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
|
||||
} {
|
||||
if got := BulkExtraKey(field); got != want {
|
||||
t.Errorf("BulkExtraKey(%q) = %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
// And they are NOT columns: a mapping that also claimed a column would write
|
||||
// to a table that has none.
|
||||
for _, col := range []string{"hamlog_sent", "hamlog_rcvd", "APP_OPSLOG_HAMLOG_SENT"} {
|
||||
if BulkEditable(col) {
|
||||
t.Errorf("%q is offered as a bulk-editable COLUMN", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,3 +55,24 @@ func TestNotInKeepsNulls(t *testing.T) {
|
||||
t.Errorf("sql = %q, want the column wrapped in IFNULL", sql)
|
||||
}
|
||||
}
|
||||
|
||||
// Every HAMLOG field an operator can bulk-edit must also be filterable: the
|
||||
// question "which contacts have never gone to HAMLOG" is what you ask BEFORE
|
||||
// uploading a backlog, and a field that can be written but not read back is
|
||||
// half a feature.
|
||||
func TestHamlogFieldsAreFilterable(t *testing.T) {
|
||||
filterable := map[string]bool{}
|
||||
for _, f := range FilterableFields() {
|
||||
filterable[f] = true
|
||||
}
|
||||
for _, f := range []string{"hamlog_sent", "hamlog_sent_date", "hamlog_rcvd", "hamlog_rcvd_date"} {
|
||||
if !filterable[f] {
|
||||
t.Errorf("%s is bulk-editable but not filterable", f)
|
||||
}
|
||||
}
|
||||
// The received key must be THEIR name, or an imported log filters differently
|
||||
// from a stamped one — the exact split the confirmation source exists to avoid.
|
||||
if got := filterableExtras["hamlog_rcvd"]; got != "APP_HAMLOG_QSO_CFM" {
|
||||
t.Errorf("hamlog_rcvd filters on %q, want APP_HAMLOG_QSO_CFM", got)
|
||||
}
|
||||
}
|
||||
|
||||
+64
-1
@@ -909,6 +909,19 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
||||
// column and there is no owner_callsign column.
|
||||
var bulkEditableExtras = map[string]string{
|
||||
"owner_callsign": "OWNER_CALLSIGN",
|
||||
// HAMLOG.online has no promoted column — the ADIF standard defines a field
|
||||
// for hamlog.EU and none for hamlog.ONLINE. Bulk-editable because a log
|
||||
// uploaded to their site BY HAND has to be markable afterwards: without it
|
||||
// OpsLog would offer to send every one of those contacts again.
|
||||
//
|
||||
// A STATUS and a DATE in each direction, like every other service: one field
|
||||
// holding a date and standing in for the flag looked clever and read as a
|
||||
// riddle — "which of these two is the status?" was the first thing an
|
||||
// operator asked of it.
|
||||
"hamlog_sent": "APP_OPSLOG_HAMLOG_SENT",
|
||||
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
|
||||
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
|
||||
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
|
||||
}
|
||||
|
||||
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
|
||||
@@ -1278,11 +1291,16 @@ var filterableColumns = map[string]bool{
|
||||
"my_wwff_ref": true, "my_street": true, "my_city": true, "my_postal_code": true,
|
||||
"my_rig": true, "my_antenna": true, "my_sig": true, "my_sig_info": true,
|
||||
"tx_pwr": true, "comment": true, "notes": true,
|
||||
// When the record entered THIS logbook, as opposed to when the contact was
|
||||
// made. The question it answers is "what did that import actually add?" —
|
||||
// unanswerable otherwise, since an import of old contacts carries old QSO
|
||||
// dates and hides inside the log.
|
||||
"created_at": true,
|
||||
}
|
||||
|
||||
// dateColumns are stored as full ISO timestamps; a filter on a bare YYYY-MM-DD
|
||||
// value compares on the date part (see conditionSQL) so day filters are exact.
|
||||
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
|
||||
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true, "created_at": true}
|
||||
|
||||
// numericColumns are the filterable columns holding numbers rather than text.
|
||||
//
|
||||
@@ -1303,6 +1321,15 @@ var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
// ADIF/Extras key; the SQL expression uses json_extract.
|
||||
var filterableExtras = map[string]string{
|
||||
"owner_callsign": "OWNER_CALLSIGN",
|
||||
// HAMLOG.online, in both directions and with its dates. Filterable for the
|
||||
// same reason it is bulk-editable: without it there is no way to ask "which
|
||||
// contacts have never gone there", which is the question an operator asks
|
||||
// before uploading a backlog. The received key is THEIR field name — see
|
||||
// award.HamlogQSLKey — so an imported log filters the same as a stamped one.
|
||||
"hamlog_sent": "APP_OPSLOG_HAMLOG_SENT",
|
||||
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
|
||||
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
|
||||
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
|
||||
}
|
||||
|
||||
// FilterableFields returns the whitelist (for the frontend to build its field
|
||||
@@ -3750,3 +3777,39 @@ func (r *Repo) IDByDedupeKey(ctx context.Context, callsign, qsoDateMinute, band,
|
||||
}
|
||||
return id, u.String, true, nil
|
||||
}
|
||||
|
||||
// ListMissingExtra returns the QSOs that do NOT carry an ADIF extras key.
|
||||
//
|
||||
// For a service with no status column of its own — HAMLOG.online, whose field
|
||||
// the ADIF standard does not define — this is the backlog: everything never
|
||||
// sent. Matched with LIKE on the extras JSON, which is a full scan; that is
|
||||
// acceptable here because it answers a button the operator presses, not a hot
|
||||
// path, and the alternative is a promoted column for a field no other program
|
||||
// would ever read.
|
||||
func (r *Repo) ListMissingExtra(ctx context.Context, key string) ([]QSO, error) {
|
||||
key = strings.ToUpper(strings.TrimSpace(key))
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("missing extras key")
|
||||
}
|
||||
// The JSON is written by encoding/json, so a present key always appears as
|
||||
// "KEY": — quoted, followed by a colon. A value containing that text cannot
|
||||
// produce a false positive: it would be escaped inside its own quotes.
|
||||
like := "%\"" + key + "\":%"
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+selectCols+` FROM qso
|
||||
WHERE COALESCE(extras_json,'') NOT LIKE ?
|
||||
ORDER BY qso_date DESC, id DESC`, like)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list missing extra: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]QSO, 0, 64)
|
||||
for rows.Next() {
|
||||
q, err := scanQSO(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -143,3 +143,37 @@ func TestIDByDedupeKeyRecognisesTheSameContact(t *testing.T) {
|
||||
t.Error("an empty key matched a row")
|
||||
}
|
||||
}
|
||||
|
||||
// A service with no status column of its own — HAMLOG.online, whose field the
|
||||
// ADIF standard does not define — finds its backlog by the ABSENCE of an extras
|
||||
// key. The LIKE has to match the key, and only the key: a QSO whose comment
|
||||
// happens to contain the same text must not be mistaken for one already sent.
|
||||
func TestListMissingExtraMatchesTheKeyNotItsText(t *testing.T) {
|
||||
r := openRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
sent := addQSO(t, r, "F4BPO", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
notSent, err := r.Add(ctx, QSO{
|
||||
Callsign: "F1TRF", QSODate: time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC),
|
||||
Band: "40m", Mode: "CW",
|
||||
Comment: "mentions APP_OPSLOG_HAMLOG_SENT in the text",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.SetExtra(ctx, sent, "APP_OPSLOG_HAMLOG_SENT", "20260823"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := r.ListMissingExtra(ctx, "APP_OPSLOG_HAMLOG_SENT")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != notSent {
|
||||
ids := make([]int64, len(got))
|
||||
for i, q := range got {
|
||||
ids[i] = q.ID
|
||||
}
|
||||
t.Fatalf("backlog = %v, want just %d (the one never sent)", ids, notSent)
|
||||
}
|
||||
}
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
// Comparing the two sources of a Russian district, BEFORE deciding which wins.
|
||||
//
|
||||
// A QSO can carry an RDA district from two places now:
|
||||
//
|
||||
// - the offline RDA database (internal/rda), compiled by the award's own
|
||||
// administrators, which knows where a callsign was on a GIVEN DAY;
|
||||
// - the CNTY field of a log downloaded from HAMLOG.online, whose export
|
||||
// writes the district there (<CNTY:5>RO-19 on a real record).
|
||||
//
|
||||
// Whether that second source is EVIDENCE or merely a COPY is not a matter of
|
||||
// opinion, and it decides everything downstream: if HAMLOG's value always
|
||||
// equals the database's, there is nothing to arbitrate and a precedence rule
|
||||
// would be ceremony around a redundancy. If they genuinely differ on some
|
||||
// contacts, then two independent records disagree about a QSO that counts for
|
||||
// an award, and that has to be surfaced rather than silently resolved.
|
||||
//
|
||||
// So: measure first. This reports agreements, disagreements, and the contacts
|
||||
// where only one side has an answer — and it changes nothing in the log.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/award"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/rda"
|
||||
)
|
||||
|
||||
// rdaDistrictRe is the shape of an RDA reference: two letters, a dash, two
|
||||
// digits (RO-19, KO-05). Used to tell a real district in CNTY from a US county
|
||||
// name or whatever else a callbook dropped in that field.
|
||||
var rdaDistrictRe = regexp.MustCompile(`^[A-Z]{2}-[0-9]{2}$`)
|
||||
|
||||
// RDAConflict is one contact where the two sources disagree.
|
||||
type RDAConflict struct {
|
||||
QSOID int64 `json:"qso_id"`
|
||||
Callsign string `json:"callsign"`
|
||||
Date string `json:"date"`
|
||||
Band string `json:"band"`
|
||||
Mode string `json:"mode"`
|
||||
// FromLog is the district the imported log carries (CNTY).
|
||||
FromLog string `json:"from_log"`
|
||||
// FromDB is what the offline RDA database answers for that callsign on that
|
||||
// day, and Dated says whether it was a dated activity record (a fact) or the
|
||||
// callsign's current district (an assumption).
|
||||
FromDB string `json:"from_db"`
|
||||
Dated bool `json:"dated"`
|
||||
// Confirmed is whether the QSO is confirmed on HAMLOG — a confirmed contact's
|
||||
// CNTY has been through their matching of two logs, which is what makes it
|
||||
// worth weighing against the database at all.
|
||||
Confirmed bool `json:"confirmed"`
|
||||
}
|
||||
|
||||
// RDACompareResult is the measurement.
|
||||
type RDACompareResult struct {
|
||||
Scanned int `json:"scanned"` // Russian QSOs examined
|
||||
BothKnown int `json:"both_known"` // a district from each source
|
||||
Agree int `json:"agree"`
|
||||
Disagree int `json:"disagree"`
|
||||
OnlyLog int `json:"only_log"` // CNTY has it, the database does not
|
||||
OnlyDB int `json:"only_db"` // the database has it, the log does not
|
||||
Neither int `json:"neither"`
|
||||
// Conflicts lists the disagreements, newest first, capped so a large log
|
||||
// cannot produce a dialog nobody can read. Count is in Disagree regardless.
|
||||
Conflicts []RDAConflict `json:"conflicts"`
|
||||
}
|
||||
|
||||
// rdaCompareMax bounds the returned list. The COUNT is always exact; it is the
|
||||
// enumeration that is capped, because a hundred rows is already more than
|
||||
// anyone reviews in one sitting.
|
||||
const rdaCompareMax = 200
|
||||
|
||||
// CompareRDASources measures the two sources against each other. Read-only.
|
||||
func (a *App) CompareRDASources() (RDACompareResult, error) {
|
||||
var res RDACompareResult
|
||||
if a.qso == nil {
|
||||
return res, fmt.Errorf("db not initialized")
|
||||
}
|
||||
rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: 1_000_000})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
for i := range rows {
|
||||
q := &rows[i]
|
||||
// The same three entities the award covers — see BackfillRDA.
|
||||
if q.DXCC == nil {
|
||||
continue
|
||||
}
|
||||
switch *q.DXCC {
|
||||
case 15, 54, 126:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
res.Scanned++
|
||||
|
||||
fromLog := strings.ToUpper(strings.TrimSpace(q.County))
|
||||
if !rdaDistrictRe.MatchString(fromLog) {
|
||||
fromLog = "" // a county name, a stray value: not a district
|
||||
}
|
||||
var fromDB string
|
||||
var dated bool
|
||||
if m, ok := rda.Lookup(q.Callsign, q.QSODate); ok {
|
||||
fromDB, dated = m.District, m.Dated
|
||||
}
|
||||
|
||||
switch {
|
||||
case fromLog == "" && fromDB == "":
|
||||
res.Neither++
|
||||
case fromLog == "":
|
||||
res.OnlyDB++
|
||||
case fromDB == "":
|
||||
res.OnlyLog++
|
||||
default:
|
||||
res.BothKnown++
|
||||
if fromLog == fromDB {
|
||||
res.Agree++
|
||||
continue
|
||||
}
|
||||
res.Disagree++
|
||||
if len(res.Conflicts) < rdaCompareMax {
|
||||
res.Conflicts = append(res.Conflicts, RDAConflict{
|
||||
QSOID: q.ID, Callsign: q.Callsign,
|
||||
Date: q.QSODate.UTC().Format("2006-01-02"),
|
||||
Band: q.Band, Mode: q.Mode,
|
||||
FromLog: fromLog, FromDB: fromDB, Dated: dated,
|
||||
Confirmed: q.Extras != nil && isHamlogConfirmed(q.Extras[award.HamlogQSLKey]),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
applog.Printf("rda compare: %d Russian QSOs — both %d (agree %d, disagree %d), only-log %d, only-db %d, neither %d",
|
||||
res.Scanned, res.BothKnown, res.Agree, res.Disagree, res.OnlyLog, res.OnlyDB, res.Neither)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// isHamlogConfirmed reads their Y — anything that is not an explicit no counts,
|
||||
// the same rule the award engine applies.
|
||||
func isHamlogConfirmed(v string) bool {
|
||||
v = strings.ToUpper(strings.TrimSpace(v))
|
||||
return v != "" && v != "N" && v != "NO"
|
||||
}
|
||||
+5
-5
@@ -315,11 +315,11 @@ func (a *App) syncPublishDeletes(ids []int64) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// One read for the whole set: see deleteSnapshot. Tombstones are written
|
||||
// before the rows go, because a QSO that never had a sync identity has to be
|
||||
// given one here — after the DELETE there would be nothing left to stamp.
|
||||
for _, q := range a.deleteSnapshot(ids) {
|
||||
id := q.ID
|
||||
// A contact never touched since the sync was switched on has no identity,
|
||||
// and giving it one now is what makes the deletion addressable at all.
|
||||
a.syncMu.Lock()
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.26.6"
|
||||
appVersion = "0.26.10"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user