Compare commits

..
14 Commits
Author SHA1 Message Date
rouggy c9d46cfab9 chore: release v0.26.8 2026-08-23 15:04:59 +02:00
rouggy c7140fd639 feat(export): filtered-view export can keep the OpsLog fields too
Same reasoning as the selected-rows export: ExportADIFFiltered was called
with includeAppFields=false, so a filtered export lost the award
references. A second entry, next to the standard one.
2026-08-23 15:01:32 +02:00
rouggy c887b77b5f feat(export): right-click export can keep the OpsLog fields
The right-click export always called ExportADIFSelected with
includeAppFields=false, so it wrote strictly standard ADIF. That is the
right default when the file is going to another logger, but it silently
drops every APP_ tag -- and an award reference such as RDA@KR-04 lives in
APP_OPSLOG_AWARDREFS and nowhere else. Backing up a selection, or moving
it to another OpsLog, lost the awards.

Adds a second entry rather than a flag on the first: the two exports
answer different questions, and a checkbox in a context menu would have
to be read before every export.
2026-08-23 14:58:01 +02:00
rouggy b3e398b8d6 feat(rda): measure the two district sources before arbitrating between them
A log imported from HAMLOG.online carries the Russian district in CNTY, and the
offline RDA database has its own answer for the same contact. Which wins is a
real question — but only if the two are independent. If HAMLOG's value is itself
a lookup in the same kind of database, there is nothing to arbitrate and a
precedence rule would be ceremony around a redundancy.

So this measures instead of deciding: Settings → RDA gains a read-only compare
that reports agreements, disagreements, and the contacts where only one side
knows, then lists the disagreements — callsign, date, both districts, whether
the database answer was a DATED record (the award administrators' own fact) or
the callsign's current district (an assumption), and whether the QSO is
confirmed on HAMLOG (their value has then been through a match of two logs).

It changes nothing in the log. What to do about a conflict is the next decision,
and it should be taken with the operator's own numbers in hand.

Also: the four HAMLOG fields become filterable, so 'never sent there' is a
filter rather than a guess. A field that can be written and not read back is
half a feature — pinned by a test against the bulk-editable list.
2026-08-23 14:30:48 +02:00
rouggy f4a72da118 fix(hamlog): read the site's own confirmation field, not a guessed one
A real record from their export settles it:

	<CALL:4>RL6M … <CNTY:5>RO-19 <APP_HAMLOG_R150COUNTRY:6>Russia
	<APP_HAMLOG_QSO_CFM:1>Y

The confirmation lives in APP_HAMLOG_QSO_CFM. The four names guessed before a
file was available — APP_HAMLOG_QSL and friends — were all wrong, which is the
argument for reading one rather than reasoning about it.

So that becomes the canonical key everywhere: the award source, the row colours,
the grid column and the bulk editor. Importing a log downloaded from HAMLOG now
carries its confirmations into OpsLog with nothing to rename. The older names,
including the APP_OPSLOG_HAMLOG_QSL that OpsLog itself wrote in the meantime,
are still honoured on read so nothing already stamped stops counting.
2026-08-23 13:43:10 +02:00
rouggy 086581851b feat(hamlog): confirmation defaults and grid columns; drop a settings paragraph
Preferences → Confirmations gains a HAMLOG.online row, sent and received, with
the same defaults every other service has: a new QSO can start at N so the
backlog is meaningful from the first contact. The defaults reach it through a
new setExtraDefault — fill() writes through a pointer to a string field and
these two live in the extras map, which no pointer reaches.

The QSO grid gains four HAMLOG columns (status and date, both directions),
hidden by default: a column per service shown to everyone is how a grid becomes
unreadable.

And the paragraph explaining what "Sent" means is gone from that page.
2026-08-23 13:25:20 +02:00
rouggy f8fdfab031 fix(hamlog): a status AND a date, like every other service
Bulk edit offered two HAMLOG fields, both dates, and the first question they
drew was the right one: which of these is the status? Collapsing the flag into
the date was neat in the storage and a riddle in the dialog, where the eight
fields above it are four status/date pairs.

So four keys now — APP_OPSLOG_HAMLOG_SENT / _SENT_DATE and _QSL / _QSL_DATE —
and four fields, in the shape an operator already reads for eQSL, QRZ, Club Log
and HRDLog. The stamp written after a successful upload follows: Y in the
status, the day in the date.

Nothing else changes: the QSL Manager's backlog still asks whether the sent key
is present, and the award engine still treats any non-N value in the QSL key as
a confirmation.
2026-08-23 13:14:05 +02:00
rouggy 3f82da3c18 feat(hamlog): mark an already-uploaded log through bulk edit
A log sent to hamlog.online by hand — an ADIF exported from OpsLog and dropped
on their site — had no way of being marked as sent afterwards. The QSL Manager
then listed every one of those contacts as backlog and offered to upload them a
second time.

Both HAMLOG keys join the bulk-editable extras, so a selection (or the whole
log) can be stamped in one pass. They are DATES rather than Y/N flags because
that is the shape the sent stamp already uses: the QSL Manager's backlog asks
whether the key is present, and a date also says when it went.

The test pins that they are reachable through the extras path and NOT offered as
columns — a mapping claiming both would write to a table that has neither.
2026-08-23 13:02:04 +02:00
rouggy cd557acf81 feat(hamlog): send to HAMLOG.online from the right-click menu
The upload targets in the QSO context menu gain HAMLOG.online, so a selection
goes there the same way it goes to QRZ or LoTW.

The confirmation toast now names the service from a map rather than a chain of
ternaries that stopped after three: hrdlog, eqsl and hamlog were echoed as their
internal ids, which is not what the operator clicked on.
2026-08-23 12:46:29 +02:00
rouggy 036dc53fe8 feat(hamlog): mark what has been sent, and a QSL Manager backlog
An upload now leaves a trace on the QSO: APP_OPSLOG_HAMLOG_SENT holds the day
it went. The date rather than a Y, so a log says WHEN — and so one shape serves
three readers: eligibility (a stamped QSO is not sent twice), the QSL Manager's
backlog, and the appearance rules' sent channel.

HAMLOG joins the QSL Manager's service list. It has no status column to select
on, so its backlog is the ABSENCE of that extras key — a LIKE over the extras
JSON, which is a full scan and is the right trade here: it answers a button an
operator presses, and the alternative is a promoted column for a field no other
program would ever read. The match is on the quoted key, so a QSO whose comment
merely mentions the text is not taken for one already sent — which is what the
test pins.
2026-08-23 12:40:09 +02:00
rouggy 73806cbd70 feat(hamlog): the settings panel, the key check, and a row-colour channel
HAMLOG.online is now reachable: Settings → External services → HAMLOG.ONLINE
takes the API key, the auto-upload switch and the timing, exactly like the six
services beside it, and a link opens the page where the key is issued.

The button next to it checks the KEY, not the connection. HAMLOG answers
KEYSTATUS with the callsign the key belongs to — something no other service
here offers — so the result reads "Key is valid — account F4BPO" rather than a
green tick. A key pasted from a club account or a second station is visible at
once, which is precisely the mistake a tick would have hidden.

No on-close timing, for the same reason as Cloudlog: there is no per-QSO upload
column to select a backlog from at shutdown, so the option would have done
nothing. Immediate or delayed, and a stored on_close falls back to immediate
rather than silently uploading nothing.

The appearance rules gain a HAMLOG.online channel too, so a row can be coloured
"confirmed" on that alone. It reads the ADIF extras — the standard names a field
for hamlog.EU and none for hamlog.ONLINE — using the same keys the award engine
reads, so the two can never disagree about what a confirmation is. "To send"
skips it: a QSO is either uploaded or not, there is no card to be owed.
2026-08-23 12:29:38 +02:00
rouggy ee148732dd feat: HAMLOG.online upload and confirmations, Yaesu antenna, a named port holder
HAMLOG.online is a seventh external service: one API key, one ADIF record per
QSO, immediate / delayed / on-close like the rest. They publish no API
documentation, so the protocol is read from THEIR OWN client — the HAMLOG Agent
(github.com/hamlogonline/Agent), which is the authoritative source short of
asking them:

	POST https://hamlog.online/api/agent/
	{"ADIFADD": {"APIKEY": k, "ADIFDATA": record}}   → {"STATUS":"OK"}
	{"KEYSTATUS": {"APIKEY": k}}                     → {"STATUS":"OK","CALLSIGN":…}

Success is STATUS == OK, not "no ERROR field": their failure carries ERROR and
no STATUS, and reading an unknown reply — a proxy page, a maintenance notice —
as an acceptance is how a contact goes missing without anyone noticing.

KEYSTATUS buys something no other service here offers: the key can be checked
BEFORE the first QSO, and the answer names the account. A key pasted from
another callsign is caught in the settings panel rather than through a week of
silent refusals.

Their confirmations are also an award source now, ticked like LoTW rather than
expressed through "custom". It reads the ADIF extras, not a column: the standard
names a field for hamlog.EU and none for hamlog.ONLINE, and borrowing the other
site's field would write a falsehood into every exported log. Three plausible
key names from their own export are accepted too, so nobody has to rename a
column by hand after an export.

Yaesu gains antenna selection (AN), remembered per band — the antenna picked on
a band comes back with it, with no table to fill in anywhere. Rigs with one
socket never answer AN and never show the row; the log says which case it is.

And a serial port that is refused now names its likely holder. OmniRig stays
resident once activated and keeps the port of the rig configured in it, so a
native backend never gets it — "Serial port busy" alone accused nobody, and an
FTDX10 spent a morning being blamed for it.
2026-08-23 12:18:27 +02:00
rouggy 3014796c1d chore: release v0.26.7 2026-08-23 10:43:51 +02:00
rouggy 405b0e24cf feat: network audio into the QSO recorder, and a Yaesu that stays on its VFO
An Icom reached over the LAN streams its receive audio through the Icom
protocol, and Windows sees no sound card for it at all — the audio settings
could only offer the PC's own microphone, so "From radio" had nothing right to
point at and the QSO recorder had nothing to record. The recorder now accepts a
PUSHED source: the decoded stream goes to the speakers and to the recorder
alike, with no virtual cable to set up. Which source it uses follows the CAT
backend, and it is restarted only when that answer changes, so an ordinary
settings save never cuts a recording in half.

OmniRig no longer sends SetSimplexMode to a Yaesu when tuning. It is a silent
no-op on some — an FT-891 logged OK on every spot click while FreqA never
moved — and actively harmful on others. From an FT-2000 log, one QSY:

	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" call turned split on and
moved reception to VFO B. OpsLog then displayed B — reading the radio correctly,
after having moved it itself. Icom is untouched: there the call is the
authoritative one and the direct write is unreliable.

Also:
  - the NEW county badge shows in the entry form itself, inside the field,
    where the operator is deciding whether to call.
  - the basemap buttons clear the zoom controls; Light sat a few pixels from
    the minus button and was being clicked by mistake.
  - French cluster status: DÉJÀ CTC reads DÉJÀ QSO.
2026-08-23 10:43:11 +02:00
45 changed files with 1629 additions and 97 deletions
View File
+232 -4
View File
@@ -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)
@@ -8321,7 +8338,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 +8365,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 +10529,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
)
if err != nil {
return out, err
@@ -10503,6 +10544,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 +10567,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 +10619,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 +10739,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 +10819,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 +10892,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 +10949,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 +11033,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 +11055,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 +12861,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 +12883,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 +12928,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 +15012,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 {
@@ -15000,8 +15127,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 +19974,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 +20442,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)
}
+1
View File
@@ -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
View File
@@ -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 {
+56
View File
@@ -1,4 +1,60 @@
[
{
"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": "",
+54 -14
View File
@@ -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)); }
}
@@ -5214,9 +5242,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 +6014,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 +6071,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 +7470,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 +7803,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>
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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 },
@@ -86,6 +86,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' },
+4 -1
View File
@@ -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}
@@ -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' },
];
+28 -1
View File
@@ -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"
+13 -1
View File
@@ -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: 'QSL', 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: 'QSL', 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: 'QSL', 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: 'QSL', 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}
+164 -8
View File
@@ -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>
);
}
+3 -1
View File
@@ -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}
+17 -3
View File
@@ -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 */}
File diff suppressed because one or more lines are too long
+27 -5
View File
@@ -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 -1
View File
@@ -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.8';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+12
View File
@@ -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>;
@@ -371,6 +375,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>;
@@ -585,6 +591,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>>;
@@ -1111,6 +1119,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 +1249,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>;
+24
View File
@@ -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);
}
@@ -682,6 +690,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']();
}
@@ -1110,6 +1122,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']();
}
@@ -2162,6 +2178,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 +2438,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);
}
+81
View File
@@ -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"];
}
@@ -3028,6 +3032,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 +3051,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 +3229,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;
+48 -4
View File
@@ -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
+53 -1
View File
@@ -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
@@ -1481,6 +1481,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 +1502,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.
+58
View File
@@ -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")
}
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
+20
View File
@@ -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
+21
View File
@@ -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)
}
}
}
+39
View File
@@ -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."
}
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+34
View File
@@ -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)))
}
+27 -1
View File
@@ -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)
}
}
}
+3
View File
@@ -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
+149
View File
@@ -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
}
+87
View File
@@ -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)
}
}
+25
View File
@@ -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
}
+26
View File
@@ -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)
}
}
}
+21
View File
@@ -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)
}
}
+58
View File
@@ -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 "".
@@ -1303,6 +1316,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 +3772,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()
}
+34
View File
@@ -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
View File
@@ -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"
}
+1 -1
View File
@@ -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.8"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.