Compare commits

...
9 Commits
Author SHA1 Message Date
rouggy ab8ecd65fe test(adif): assert FREQ_RX reaches the forwarded record too
The fix filled both halves of the receive side, but the test only proved
BAND_RX came out the other end. FREQ_RX is the half the question was
really about.
2026-08-31 22:30:32 +02:00
rouggy de0771d797 feat(cat): USB for digital modes, on every backend
A soundcard mode is USB with audio in the mic path, and that is what
most rigs need. Asking for the mode by NAME is the better answer on a
modern transceiver with a DATA position and the wrong one on an older
set, where the CAT layer resolves 'digital' to RTTY/FSK — OmniRig does
exactly that, per rig file, and there is no arguing with it from here.
The operator clicking an FT8 spot landed in FSK, which keys from a
mark/space generator and cannot pass FT8 at all.

Applied in SetCATMode, the one funnel every backend and every caller
goes through, so it holds for a spot click, the band map and the mode
selector alike. The QSO is still logged as FT8: this is what the radio
is put in, not what the contact was.
2026-08-31 22:26:12 +02:00
rouggy 22e4266d38 fix(editor): a corrected frequency corrects its band
Band and frequency have to agree — the log, every award and every upload
are read on the BAND — and an operator fixing a wrong frequency is not
also expecting to fix the band by hand. That is exactly how a QSO ends
up filed on 20m at 7 MHz. Both sides follow, TX and RX.

Only when the number lands in a known allocation, because half a
frequency is typed on the way to all of it and a band must never be
blanked by that. bandForMHz moves out of App.tsx into lib/bandplan,
where the two callers can share one answer instead of drifting.
2026-08-31 22:21:22 +02:00
rouggy e3332d1e27 fix(hamqth): the whole-log upload reports itself
It ran into a panel nobody was showing: the button never set showLog, so
the tab sat on 'Pick a service' while a 25000-QSO upload came and went.
Now it opens the console like every other action here, and the console
has something to say: the callsign it is scoped to and how many of the
logbook's QSOs that leaves (a two-callsign database sends one of them,
which is the whole explanation for a count that looks short), the
exported record count and file size, and HamQTH's own reply.

Plus the two facts that make the site's own number readable: HamQTH
accepts the file and imports it in the BACKGROUND, and it reports what
it made of each record by e-mail — never in the reply we get. An
operator comparing counts a minute later is not looking at a failure.
2026-08-31 22:11:51 +02:00
rouggy 5cebf163c5 feat(hamqth): the sent stamp reaches the columns, the filter and bulk edit
HAMLOG.online was wired into all three when it arrived; HamQTH shipped
with only its uploader and its editor row, so the log could not be asked
'which contacts have never gone there' — the question that precedes
every backlog upload — and a log uploaded to HamQTH by hand could not be
marked, leaving OpsLog offering to send every one of those contacts
again. Sent side only, both whitelists, both panels, two hidden-by-
default columns.
2026-08-31 22:04:42 +02:00
rouggy cfd85ff9c3 fix(adif): a logged QSO carries its receive side
The importer has always read an absent BAND_RX/FREQ_RX as 'not split, so
RX = TX'. The logging paths never did, so what a contact carried
depended on which door it came in through — and it shows outside
OpsLog: the record forwarded over UDP is written from the QSO as logged,
so a receiver that reads BAND_RX (Log4OM does) found nothing there.

Filled at AddQSO, the one funnel every path goes through, so the
database, the export and the forwarded copy agree. A genuine split
contact keeps what it was given — table-tested both ways.
2026-08-31 22:02:24 +02:00
rouggy c6f479750f chore(changelog): open 0.27.7 — two changes landed after the .6 tag
v0.27.6 was tagged at 88f35e2, and the disconnected-pill fix and the
HamQTH whole-log upload came after it: their entries were promising a
released build features it does not carry. They open 0.27.7, and the
.6 pill entry goes back to describing what actually shipped.
2026-08-31 21:57:41 +02:00
rouggy 386a8ad531 feat(hamqth): upload the whole log in one file
The per-QSO API is the only correct way to send a SELECTION, and at the
pace it must be driven a 14k backlog costs the better part of an hour.
HamQTH's other endpoint takes a whole log as one file — and REPLACES
what is on the site with it: its documentation says plainly that partial
uploads do not exist. So it is offered as its own deliberate act behind
a confirmation, never as the batch path behind 'send these', where it
would delete every QSO the operator had not selected.

Scoped to the callsign this profile uploads as, so a database holding
two operators' contacts cannot push one into the other's log; tar.gz
above 12 MB because the ceiling is 20 and a six-figure log passes it as
text; and every QSO not already stamped is marked sent afterwards, in
bulk, so the backlog list agrees with reality.
2026-08-31 20:07:46 +02:00
rouggy bcd7e409ba fix(cluster): a disconnected server keeps its pill
The pills listed live SESSIONS, so disconnecting one made it vanish —
along with the only way back. They now list every enabled server, and a
server with no session shows as disconnected: the state you click to
undo. The address, known only while a session exists, drops out of the
tooltip rather than reading ':0'.
2026-08-31 19:05:56 +02:00
17 changed files with 507 additions and 34 deletions
+189 -3
View File
@@ -116,7 +116,10 @@ const (
keyCATBackend = "cat.backend" // "omnirig" | "flex"
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
// Which VFO to believe when OmniRig names one. "" = trust the rig file.
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
// Put the RADIO in USB for a digital mode instead of asking for the mode by
// name. Every backend, because the reason is the radio, not the link.
keyCATDigiUSB = "cat.digi_usb"
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
@@ -473,7 +476,10 @@ type CATSettings struct {
// reports, "A"/"B" force one. Needed because that report is only as good as
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the
// operator worked on the main VFO, so OpsLog wrote to A and read B.
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
// DigiAsUSB puts the radio in USB when a digital mode is selected, rather
// than naming the mode. What the QSO is LOGGED as never changes.
DigiAsUSB bool `json:"digi_as_usb"`
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
@@ -2948,6 +2954,28 @@ func (a *App) reloadLookupProviders() {
// --- QSO bindings ---
// fillRXDefaults stamps the receive side when the contact was not split.
//
// The ADIF importer has always done this — an absent BAND_RX/FREQ_RX means RX
// equals TX — but the LOGGING paths did not, so what a QSO carried depended on
// which door it came through. It shows up outside OpsLog: the record forwarded
// to another logger over UDP is written from the QSO as logged, and a receiver
// reading BAND_RX (Log4OM does) found nothing there for contacts logged by a
// path that left it blank.
//
// Applied at AddQSO, the one funnel every path goes through — manual entry, the
// WSJT-X/UDP log, CW, contest, net control, the ADIF monitor — so the database,
// the export and the forwarded copy all say the same thing.
func fillRXDefaults(q *qso.QSO) {
if strings.TrimSpace(q.BandRX) == "" {
q.BandRX = q.Band
}
if q.FreqRXHz == nil && q.FreqHz != nil {
v := *q.FreqHz
q.FreqRXHz = &v
}
}
func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
@@ -2963,6 +2991,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}
}()
a.applyStationDefaults(&q, true)
fillRXDefaults(&q)
fillDistance(&q)
a.applyDXCCNumber(&q)
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
@@ -8212,7 +8241,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
if err != nil {
return CATSettings{}, err
}
@@ -8313,6 +8342,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
out.OmniRigVFO = v
}
out.DigiAsUSB = m[keyCATDigiUSB] == "1"
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
out.OmniRigNum = n
}
@@ -8421,6 +8451,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATBackend: s.Backend,
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
keyCATDigiUSB: boolStr(s.DigiAsUSB),
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort),
keyCATFlexSpots: flexSpots,
@@ -11460,6 +11491,135 @@ func (a *App) TestClublogUpload() (string, error) {
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
}
// UploadFullLogHamQTH replaces the HamQTH log with this one, in one request.
//
// The per-QSO API is the only correct way to send a SELECTION, and at the pace
// it has to be driven a full backlog costs the better part of an hour. This is
// the other endpoint HamQTH offers: a whole log as one file, which is why it
// only ever runs on an explicit "replace my HamQTH log" — the site keeps
// nothing that is not in the file.
//
// Scoped to the callsign this profile uploads as: a database holding two
// operators' contacts must not push one operator's QSOs into the other's log.
func (a *App) UploadFullLogHamQTH() error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
cfg := a.loadExternalServices().HamQTH
if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
return fmt.Errorf("set the HamQTH username and password first")
}
go a.runFullLogHamQTH(cfg)
return nil
}
func (a *App) runFullLogHamQTH(cfg extsvc.ServiceConfig) {
emit := func(line string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
}
}
done := func(n int) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": n, "total": n})
}
}
ctx := a.ctx
owner := a.uploadOwnerCall(extsvc.ServiceHamQTH)
// Written to a temp file rather than a buffer so the ordinary, tested
// exporter does the work — the same one the Export menu uses.
tmp, err := os.CreateTemp("", "opslog-hamqth-*.adi")
if err != nil {
emit("Export failed: " + err.Error())
done(0)
return
}
path := tmp.Name()
_ = tmp.Close()
defer os.Remove(path)
if owner != "" {
emit("Station callsign: " + owner + " — QSOs logged under another of your callsigns are NOT included.")
}
emit("Exporting the log…")
var res adif.ExportResult
if owner != "" {
// station_callsign empty OR the owner call — an old QSO logged before
// the field existed belongs to whoever is uploading now.
f := qso.QueryFilter{Match: "OR", Conditions: []qso.Condition{
{Field: "station_callsign", Op: "eq", Value: ""},
{Field: "station_callsign", Op: "eq", Value: owner},
}}
res, err = a.ExportADIFFiltered(path, false, f, nil)
} else {
res, err = a.ExportADIF(path, false, nil)
}
if err != nil {
emit("Export failed: " + err.Error())
done(0)
return
}
data, rerr := os.ReadFile(path)
if rerr != nil {
emit("Export failed: " + rerr.Error())
done(0)
return
}
total, _ := a.qso.Count(ctx)
if total > 0 && int64(res.Count) != total {
emit(fmt.Sprintf("%d of %d QSOs in this logbook match %s and will be sent.", res.Count, total, owner))
}
emit(fmt.Sprintf("Exported %d QSO(s), %d KB of ADIF.", res.Count, res.SizeKB))
emit(fmt.Sprintf("Uploading to HamQTH — this REPLACES the log there…"))
up, uerr := extsvc.UploadHamQTHFullLog(ctx, nil, cfg, string(data))
if uerr != nil || !up.OK {
msg := up.Message
if uerr != nil {
msg = uerr.Error()
}
emit("Upload failed: " + msg)
applog.Printf("hamqth: full-log upload failed: %s", msg)
done(0)
return
}
emit("HamQTH replied: " + up.Message)
// Said plainly, because the numbers will not agree for a while and an
// operator comparing them straight away has every reason to think the
// upload failed: HamQTH ACCEPTS the file here and imports it later, at its
// own pace. What it makes of each record is reported by E-MAIL, never in
// this reply — so a count that stops short means the site rejected records,
// and the mail says which.
emit("Accepted — HamQTH imports the file in the BACKGROUND, so its QSO count will lag for a while.")
emit("If the count stops short, HamQTH e-mails the ADIF errors to your account address — this reply cannot carry them.")
// Everything is on HamQTH now, so nothing is still waiting to be sent. Only
// the rows that are not already stamped need writing.
pending, lerr := a.qso.ListMissingExtra(ctx, hamqthSentKey)
if lerr != nil {
applog.Printf("hamqth: marking sent: %v", lerr)
} else if len(pending) > 0 {
ids := make([]int64, 0, len(pending))
for _, q := range pending {
ids = append(ids, q.ID)
}
date := time.Now().UTC().Format("20060102")
if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentKey, "Y"); e != nil {
applog.Printf("hamqth: marking sent: %v", e)
}
if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentDateKey, date); e != nil {
applog.Printf("hamqth: marking sent date: %v", e)
}
emit(fmt.Sprintf("Marked %d QSO(s) as sent to HamQTH.", len(ids)))
}
applog.Printf("hamqth: full-log upload OK (%d QSOs)", res.Count)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("HamQTH: %d QSO uploaded", res.Count))
}
done(res.Count)
}
// TestHamQTHUpload checks the HamQTH credentials against the callbook login —
// authenticated, and unable to touch the log.
func (a *App) TestHamQTHUpload() (string, error) {
@@ -14676,6 +14836,7 @@ func (a *App) SetCATMode(mode string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
mode = a.catModeForRadio(mode)
err := a.cat.SetMode(mode)
if err != nil {
applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err)
@@ -14683,6 +14844,31 @@ func (a *App) SetCATMode(mode string) error {
return err
}
// catModeForRadio translates a LOGGED mode into what the RADIO should be put
// in, for operators who have asked for USB on digital.
//
// The soundcard modes — FT8, FT4, PSK, JS8, Q65… — are all USB with audio in
// the microphone path, and that is what most rigs need. Asking for the mode by
// name is the better answer on a modern transceiver with a DATA/PKT position,
// and the wrong one on an older set where the CAT layer resolves "digital" to
// RTTY/FSK: the operator lands in FSK, which keys the radio from a mark/space
// generator and cannot pass FT8 at all. OmniRig does exactly this, per rig
// file, and there is no arguing with it from here — so the option sends what
// the radio can actually do.
//
// This is the RADIO's mode only. The QSO is still logged as FT8: the mode is
// what the contact WAS, not what the front panel says.
func (a *App) catModeForRadio(mode string) string {
if strings.TrimSpace(mode) == "" || a.settingOr(keyCATDigiUSB, "") != "1" {
return mode
}
if qso.ModeClass(mode) != "DIG" {
return mode
}
applog.Printf("cat: %s → USB (digital modes set the radio to USB)", strings.ToUpper(mode))
return "USB"
}
// ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ──
// These are no-ops / errors unless the active CAT backend is a FlexRadio.
+22
View File
@@ -1,4 +1,26 @@
[
{
"version": "0.27.7",
"date": "",
"en": [
"DX Cluster: a disconnected server keeps its pill, so it can be reconnected — disconnecting one used to make it vanish along with the only way back.",
"HamQTH: an “Upload the whole log” button in the QSL Manager — one file instead of one request per QSO, so a first sync takes seconds rather than the better part of an hour. It REPLACES the log held on HamQTH (the site has no partial upload), so it asks first, is scoped to the callsign this profile uploads as, and compresses a large log to stay under the 20 MB limit.",
"Outbound ADIF (forwarding a logged QSO to another logger such as Log4OM): the receive side is filled in when the contact was not split, so BAND_RX and FREQ_RX are present. The importer already did this; the logging paths did not, so what a QSO carried depended on which door it came in through.",
"HamQTH joins the places the other services already were: two Recent-QSOs columns (sent status and date), the QSO filter, and bulk edit — the last one so a log uploaded to HamQTH by hand can be marked as sent instead of being offered for upload all over again.",
"HamQTH whole-log upload: it reports itself in the console like every other action — the callsign it is scoped to, how many of the logbooks QSOs that leaves, the file size, and HamQTHs own reply — and says plainly that HamQTH imports the file in the background and e-mails any ADIF errors, so a site count that lags or stops short is explained rather than mysterious.",
"QSO editor: correcting a frequency now moves its band with it, TX and RX — a QSO fixed to 7.1 MHz no longer stays filed on 20m. The band is only touched when the frequency lands in a known allocation, so a half-typed number never blanks it.",
"CAT: an option to put the radio in USB for digital modes (Settings → CAT), for every backend. Clicking an FT8 spot on a rig whose CAT layer resolves “digital” to RTTY/FSK — OmniRig does, per rig file — landed the operator in FSK, which cannot pass FT8 at all. The QSO is still logged as FT8: only the radio changes."
],
"fr": [
"DX Cluster : un serveur déconnecté garde sa pastille et peut donc être reconnecté — le déconnecter le faisait disparaître avec le seul moyen dy revenir.",
"HamQTH : un bouton « Envoyer tout le log » dans le QSL Manager — un seul fichier au lieu dune requête par QSO, une première synchro passe de près dune heure à quelques secondes. Il REMPLACE le log stocké sur HamQTH (le site na pas denvoi partiel) : il demande donc confirmation, se limite à lindicatif du profil et compresse un gros log pour rester sous la limite de 20 Mo.",
"ADIF sortant (transfert dun QSO vers un autre log, Log4OM par exemple) : le côté réception est renseigné quand le contact n’était pas en split, donc BAND_RX et FREQ_RX sont présents. Limport le faisait déjà, pas les chemins de log — ce quun QSO transportait dépendait donc de la porte par laquelle il était entré.",
"HamQTH rejoint les endroits où les autres services étaient déjà : deux colonnes dans les QSO récents (statut et date denvoi), le filtre de QSO et l’édition groupée — cette dernière pour quun log envoyé à la main sur HamQTH puisse être marqué comme envoyé au lieu d’être reproposé à lenvoi.",
"Envoi du log complet HamQTH : il rend compte dans la console comme toutes les autres actions — lindicatif retenu, combien de QSO du journal cela représente, la taille du fichier et la réponse de HamQTH — et indique clairement que HamQTH importe le fichier en arrière-plan et envoie les erreurs ADIF par e-mail : un compteur en retard ou incomplet sur le site est ainsi expliqué au lieu d’être mystérieux.",
"Éditeur de QSO : corriger une fréquence déplace désormais sa bande avec elle, TX comme RX — un QSO corrigé à 7,1 MHz ne reste plus classé en 20m. La bande nest touchée que si la fréquence tombe dans une allocation connue : un nombre à moitié tapé ne lefface jamais.",
"CAT : une option pour mettre la radio en USB sur les modes numériques (Réglages → CAT), pour tous les backends. Cliquer un spot FT8 sur un poste dont la couche CAT traduit « numérique » par RTTY/FSK — cest le cas dOmniRig, selon le fichier radio — faisait basculer lopérateur en FSK, incapable de passer du FT8. Le QSO reste enregistré en FT8 : seule la radio change."
]
},
{
"version": "0.27.6",
"date": "",
+15 -18
View File
@@ -59,6 +59,7 @@ import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
import { formatDistance } from '@/lib/units';
import { bandForMHz } from '@/lib/bandplan';
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -432,21 +433,6 @@ function entryQSYCommand(text: string): { band?: string; hz?: number } | null {
return null;
}
function bandForMHz(mhz: number): string {
if (!mhz || isNaN(mhz)) return '';
const plan: [number, number, string][] = [
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
// Microwave, ADIF 3.1.7 ranges — kept in step with BandFromHz on the Go side.
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return '';
}
// modeAccent maps a mode to a theme-aware colour for the live-stations widget:
// CW gold, phone green, digital blue, unknown muted.
@@ -8124,12 +8110,23 @@ export default function App() {
>
Disconnect all
</Button>
{clusterServerStatuses.length === 0 && (
{clusterServers.filter((x) => x.enabled).length === 0 && (
<span className="text-xs text-muted-foreground italic">
No active sessions configure clusters in Settings DX Cluster.
</span>
)}
{clusterServerStatuses.map((s) => {
{clusterServers
.filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)
.map((srv) => {
// A disconnected server has no session to report, so it has no
// entry in the status list at all — the pill stands in for it
// as "disconnected", which is exactly the state you click to
// undo.
const live = clusterServerStatuses.find((x) => x.server_id === srv.id);
const s: ServerStatus = live ?? {
server_id: srv.id, name: srv.name, host: '', port: 0, state: 'disconnected',
};
const isMaster = clusterServers
.filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
@@ -8159,7 +8156,7 @@ export default function App() {
s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
'bg-muted text-muted-foreground border-border',
)}
title={`${s.name}${s.state.toUpperCase()}${s.retries ? ` #${s.retries}` : ''} · ${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}\n${up || busy ? t('clu.pillDisconnect') : t('clu.pillConnect')}`}
title={`${s.name}${s.state.toUpperCase()}${s.retries ? ` #${s.retries}` : ''}${s.host ? ` · ${s.host}:${s.port}` : ''}${s.error ? ' — ' + s.error : ''}\n${up || busy ? t('clu.pillDisconnect') : t('clu.pillConnect')}`}
>
{isMaster && <span className="text-warning" title="Master (commands go here)"></span>}
{s.name}
@@ -56,6 +56,8 @@ const FIELDS: FieldDef[] = [
{ 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' },
{ id: 'hamqth_sent', label: 'bulk.fHamqthSent', group: 'QSL / upload', kind: 'status' },
{ id: 'hamqth_sent_date', label: 'bulk.fHamqthSentDate', 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 },
@@ -96,6 +96,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ 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: 'hamqth_sent', label: 'fltb.fHamqthSent', type: 'text' },
{ value: 'hamqth_sent_date', label: 'fltb.fHamqthSentDate', type: 'adifdate' },
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
+20 -2
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadFullLogHamQTH, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -723,7 +723,25 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
{service !== 'pota' && service !== 'paper' && (
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex items-center gap-2 flex-wrap">
{service === 'hamlog' ? (
{service === 'hamqth' ? (
// HamQTH's file endpoint REPLACES the remote log — its own
// documentation is explicit that partial uploads do not exist. So
// it is offered as its own deliberate act, never as the batch path
// behind "send these": that would delete everything not selected.
<Button variant="outline" size="sm" disabled={busy}
title={t('qslm.hqFullTitle')}
onClick={async () => {
if (!window.confirm(t('qslm.hqFullConfirm'))) return;
// Same three lines every other action here runs: without
// setShowLog the whole upload reported itself into a panel
// nobody was showing, and the tab sat on "Pick a service".
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadFullLogHamQTH(); }
catch (e: any) { setBusy(false); setLogLines((l) => [...l, String(e?.message ?? e)]); }
}}>
<UploadCloud className="size-3.5" /> {t('qslm.hqFull')}
</Button>
) : service === 'hamlog' ? (
<>
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
title={t('qslm.hamlogImportTitle')}>
+18 -4
View File
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import { flagURL } from '@/lib/flags';
import { useI18n } from '@/lib/i18n';
import { bandForMHz } from '@/lib/bandplan';
import { titleCase, sentenceCase } from '@/lib/textCase';
import type { QSOForm } from '@/types';
@@ -312,6 +313,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
const splitHz = (hz?: number) => hz
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
: { khz: '', hz: '' };
// Correcting a frequency corrects its band. The pair has to agree — the log,
// every award and every upload are read on the BAND — and an operator fixing
// a wrong frequency is not also expecting to fix the band by hand, which is
// exactly how a QSO ends up filed on 20m at 7 MHz.
//
// Only when the number lands in a known allocation: half a frequency is typed
// on the way to all of it, and a band must never be blanked by that.
const syncBand = (khz: string, hz: string, field: 'band' | 'band_rx') => {
if (!khz.trim()) return;
const b = bandForMHz((parseInt(khz, 10) * 1000 + (parseInt(hz, 10) || 0)) / 1_000_000);
if (b) set(field, b as any);
};
const f0 = splitHz(draft.freq_hz);
const fr0 = splitHz(draft.freq_rx_hz);
const [freqKHz, setFreqKHz] = useState(f0.khz);
@@ -634,13 +648,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
<Input value={freqKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqKHz(v); syncBand(v, freqHz, 'band'); }} className="font-mono w-24" placeholder="kHz" />
<Input value={freqHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqHz(v); syncBand(freqKHz, v, 'band'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
<Input value={freqRxKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxKHz(v); syncBand(v, freqRxHz, 'band_rx'); }} className="font-mono w-24" placeholder="kHz" />
<Input value={freqRxHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxHz(v); syncBand(freqRxKHz, v, 'band_rx'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div>
</div>
@@ -219,6 +219,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
// HamQTH, the extras again — sent only, the site having no confirmations.
{ group: 'Uploads', label: t('rqg.c.hamqth_sent'), colId: 'hamqth_sent', headerName: t('rqg.h.hamqth_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMQTH_SENT'] || 'N'; }, defaultVisible: false },
{ group: 'Uploads', label: t('rqg.c.hamqth_sent_date'), colId: 'hamqth_sent_date', headerName: t('rqg.h.hamqth_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMQTH_SENT_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 },
+8 -1
View File
@@ -1612,7 +1612,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false, digi_as_usb: false,
});
// Brand + connection, derived from the stored backend rather than held
// separately: two sources for one fact drift apart the first time something
@@ -3840,6 +3840,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</label> </>
)}
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('cat.digiUsbHint')}>
<Checkbox
checked={!!catCfg.digi_as_usb}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, digi_as_usb: !!c }))}
/>
{t('cat.digiUsb')}
</label>
{catCfg.backend === 'omnirig' && (
<>
<label className="flex items-center gap-2 text-sm cursor-pointer">
+23
View File
@@ -111,3 +111,26 @@ export function bandRange(band: string): [number, number] | undefined {
export function bandSegments(band: string): Seg[] {
return plan().segments[band] ?? [];
}
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' when it falls
// outside every known allocation.
//
// Lives here rather than in a panel because more than one place has to answer
// the same question the same way: the entry form retunes on a typed frequency,
// and the QSO editor has to keep band and frequency agreeing when one of them
// is corrected. Kept in step with BandFromHz on the Go side.
export function bandForMHz(mhz: number): string {
if (!mhz || isNaN(mhz)) return '';
const plan: [number, number, string][] = [
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
// Microwave, ADIF 3.1.7 ranges.
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return '';
}
File diff suppressed because one or more lines are too long
+2
View File
@@ -1397,6 +1397,8 @@ export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
export function UploadCallsign(arg1:string):Promise<string>;
export function UploadFullLogHamQTH():Promise<void>;
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
+4
View File
@@ -2730,6 +2730,10 @@ export function UploadCallsign(arg1) {
return window['go']['main']['App']['UploadCallsign'](arg1);
}
export function UploadFullLogHamQTH() {
return window['go']['main']['App']['UploadFullLogHamQTH']();
}
export function UploadQSOsManual(arg1, arg2) {
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
}
+2
View File
@@ -2298,6 +2298,7 @@ export namespace main {
backend: string;
omnirig_rig: number;
omnirig_vfo: string;
digi_as_usb: boolean;
flex_host: string;
flex_port: number;
flex_spots: boolean;
@@ -2350,6 +2351,7 @@ export namespace main {
this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"];
this.omnirig_vfo = source["omnirig_vfo"];
this.digi_as_usb = source["digi_as_usb"];
this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"];
+139
View File
@@ -1,9 +1,13 @@
package extsvc
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
@@ -18,6 +22,20 @@ import (
// duplicate…, reason in the body), 403 wrong credentials, 500 server error.
const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php"
// hamqthFullLogURL takes a WHOLE log as a file. Note "whole": HamQTH's own
// documentation says "you always have to upload whole log. HamQTH doesn't
// support partial upload" — the file REPLACES what is on the site. That is why
// it is not the batch path for a selection, and why the caller must have said
// so out loud before we get here.
const hamqthFullLogURL = "https://www.hamqth.com/prg_log_upload.php"
// hamqthMaxUpload is the documented ceiling for one upload.
const hamqthMaxUpload = 20 << 20
// hamqthCompressAbove is where a plain .adi stops being sent as text. Well
// under the limit: the multipart envelope and the form fields ride along too.
const hamqthCompressAbove = 12 << 20
// hamqthLoginURL is the callbook session login — the one authenticated HamQTH
// endpoint that cannot change anything in the log, which is what the settings
// Test button must call.
@@ -92,6 +110,127 @@ func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, c
}
}
// UploadHamQTHFullLog replaces the account's log with the given ADIF.
//
// DESTRUCTIVE by design of the remote API, not by ours: everything on HamQTH
// for this callsign that is not in this file stops existing. The caller owns
// the confirmation.
//
// The file goes in the multipart field "f" (HamQTH's own curl example:
// curl -F [email protected] -F send_log=OK -F u=… -F p=…). A large log is sent as a
// tar.gz — one of the archive formats the site unpacks — because the ceiling is
// 20 MB and a six-figure log passes it as plain text.
func UploadHamQTHFullLog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifText string) (UploadResult, error) {
user := strings.TrimSpace(cfg.Username)
switch {
case user == "":
return UploadResult{}, fmt.Errorf("hamqth: username not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("hamqth: password not set")
case strings.TrimSpace(adifText) == "":
return UploadResult{}, fmt.Errorf("hamqth: nothing to upload")
}
payload := []byte(adifText)
name := "opslog.adi"
if len(payload) > hamqthCompressAbove {
gz, err := tarGzADIF(payload)
if err != nil {
return UploadResult{}, fmt.Errorf("hamqth: compressing the log: %w", err)
}
payload, name = gz, "opslog.tar.gz"
}
if len(payload) > hamqthMaxUpload {
return UploadResult{}, fmt.Errorf("hamqth: the log is %d MB compressed, over HamQTH's %d MB limit",
len(payload)>>20, hamqthMaxUpload>>20)
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
_ = mw.WriteField("u", user)
_ = mw.WriteField("p", cfg.Password)
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
_ = mw.WriteField("c", c)
}
_ = mw.WriteField("send_log", "OK")
fw, err := mw.CreateFormFile("f", name)
if err != nil {
return UploadResult{}, err
}
if _, err := fw.Write(payload); err != nil {
return UploadResult{}, err
}
if err := mw.Close(); err != nil {
return UploadResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hamqthFullLogURL, &body)
if err != nil {
return UploadResult{}, err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if client == nil {
// A whole log is a long POST on a slow uplink.
client = &http.Client{Timeout: 10 * time.Minute}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
msg := strings.TrimSpace(string(raw))
if looksLikeHTML(msg) {
msg = ""
}
if resp.StatusCode != http.StatusOK {
if msg != "" && len(msg) < 300 {
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
}
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
}
// The site answers in prose, and only its own refusals are worth reading
// back: the ADIF itself is validated later, in the background, and any
// complaint about it reaches the operator by e-mail rather than here.
low := strings.ToLower(msg)
switch {
case strings.Contains(low, "successfully"):
return UploadResult{OK: true, Message: msg}, nil
case strings.Contains(low, "wrong username"), strings.Contains(low, "password"):
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
case strings.Contains(low, "cannot upload log for this callsign"):
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
case msg == "":
// HTTP 200 with nothing to say: taken as accepted, and said so.
return UploadResult{OK: true, Message: "uploaded (no reply text)"}, nil
default:
return UploadResult{OK: false, Message: msg}, nil
}
}
// tarGzADIF wraps the ADIF as log.adi inside a tar.gz — the archive must carry
// a .adi/.adif member for HamQTH to find the log in it.
func tarGzADIF(adif []byte) ([]byte, error) {
var out bytes.Buffer
gz := gzip.NewWriter(&out)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{
Name: "opslog.adi", Mode: 0o644, Size: int64(len(adif)),
}); err != nil {
return nil, err
}
if _, err := tw.Write(adif); err != nil {
return nil, err
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
// TestHamQTH verifies the credentials against the callbook session login —
// authenticated, and unable to touch the log.
func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
+10
View File
@@ -995,6 +995,12 @@ var bulkEditableExtras = map[string]string{
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
// HamQTH, same story and SENT only — the site publishes no confirmations,
// so there is no received side to edit. Bulk-editable for the one case that
// matters: a log uploaded to HamQTH by hand, which OpsLog would otherwise
// offer to send all over again.
"hamqth_sent": "APP_OPSLOG_HAMQTH_SENT",
"hamqth_sent_date": "APP_OPSLOG_HAMQTH_SENT_DATE",
}
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
@@ -1403,6 +1409,10 @@ var filterableExtras = map[string]string{
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
// HamQTH — sent only, and filterable for the question that precedes every
// backlog upload: "which contacts have never gone there".
"hamqth_sent": "APP_OPSLOG_HAMQTH_SENT",
"hamqth_sent_date": "APP_OPSLOG_HAMQTH_SENT_DATE",
}
// FilterableFields returns the whitelist (for the frontend to build its field
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"strings"
"testing"
"hamlog/internal/adif"
"hamlog/internal/qso"
)
// A contact that was not split still has a receive side, and the record
// forwarded to another logger has to carry it: Log4OM reads BAND_RX.
func TestFillRXDefaults(t *testing.T) {
hz := int64(14074000)
q := qso.QSO{Callsign: "F4BPO", Band: "20m", FreqHz: &hz}
fillRXDefaults(&q)
if q.BandRX != "20m" {
t.Errorf("BandRX = %q, want 20m", q.BandRX)
}
if q.FreqRXHz == nil || *q.FreqRXHz != hz {
t.Errorf("FreqRXHz = %v, want %d", q.FreqRXHz, hz)
}
// Assert on the RECORD another logger reads, not merely on the struct:
// both halves of the receive side have to reach it.
rec := strings.ToUpper(adif.SingleRecordADIF(q))
if !strings.Contains(rec, "<BAND_RX:3>20M") {
t.Errorf("BAND_RX missing from the forwarded record:\n%s", rec)
}
if !strings.Contains(rec, "<FREQ_RX:9>14.074000") {
t.Errorf("FREQ_RX missing from the forwarded record:\n%s", rec)
}
}
// A genuine split contact keeps what it was given.
func TestFillRXDefaultsKeepsSplit(t *testing.T) {
tx, rx := int64(14195000), int64(14205000)
q := qso.QSO{Callsign: "F4BPO", Band: "20m", BandRX: "17m", FreqHz: &tx, FreqRXHz: &rx}
fillRXDefaults(&q)
if q.BandRX != "17m" || q.FreqRXHz == nil || *q.FreqRXHz != rx {
t.Errorf("split QSO was overwritten: band_rx=%q freq_rx=%v", q.BandRX, q.FreqRXHz)
}
}