Compare commits

...
8 Commits
Author SHA1 Message Date
rouggy 1b7f8ec9c1 docs: one entry for the distance work, not two 2026-08-11 20:33:08 +02:00
rouggy 75a2f73992 fix(distance): compute it where QSOs are made, not in a settings button
The button was the wrong shape twice over. A maintenance chore does not belong
beside the options an operator actually chooses, and nobody should have to be
told their log is missing a field before it gets filled in.

So the distance is computed where QSOs come from: on the logging path and on the
ADIF import. fillDistance is its own function rather than part of
applyStationDefaults, because the import only applies those when the operator
ticks a box — and a distance is not a station default. It is derived from the
QSO's own two grids and is true whatever was chosen about profile fields.

What is already in the log is handled by a one-time migration at startup,
recorded by a settings key. In the background: on a large log over a remote
MySQL that is thousands of row updates and startup must not wait for a tidy-up.
Marked done only on SUCCESS, so a run cut short by a closed program tries again
next time rather than leaving half the log filled for ever.

An imported value still wins. It came from the log that made the contact, which
knew the real positions rather than two four-character squares.
2026-08-11 20:14:27 +02:00
rouggy deee8c4618 fix(rda): 1015 districts were on the wrong DXCC entity
Every one of the 2660 references was filed under European Russia. Corrected
against the RDA reference list: 991 are Asiatic Russia (15), and the 24 KA-
districts are Kaliningrad (126), which is not Russia for DXCC purposes at all.
1645 were already right.

dxcc_filter went from [15,54] to [15,54,126] with them. That filter decides
which QSOs are even considered, so leaving it alone would have made the 24
Kaliningrad districts unclaimable — a correction that quietly removes references
is worse than the error it fixes.

BA-27 (Mezhgorye) and BO-33 (Schebekino) are not in the reference list at all
and were left exactly as they were. Guessing an entity for a district nobody
lists is how a second wrong answer gets added to the first.

Only the dxcc field changed. Names differ in transliteration between the two
sources — Maykop against Maikop — and rewriting those was not asked for and
would churn 2660 lines to no purpose.
2026-08-11 19:55:37 +02:00
rouggy 92a5f30ac0 feat(db): fill in the distances nothing ever recorded
The published page derives a distance as it renders, which fixed the empty
column there but not the cause: the field is empty in the database, so it goes
out empty in every ADIF export and leaves the same gap in whoever imports it.

BackfillDistances computes it from the two locators for QSOs that have both.
Only where it is EMPTY: a stored distance came from the log that recorded the
contact, which knew the real positions, and two four-character squares are a
worse answer that must not overwrite a better one. Whole kilometres, for the
same reason the published column is.

In the Database panel rather than beside the county backfill, which lives under
US Counties: this one touches the whole logbook, whatever country the QSOs are in.
2026-08-11 19:49:03 +02:00
rouggy d3a405f4f6 fix(webpub): the published page hid columns it had room for
The page was capped at 1100px — a comfortable reading width, and the right
choice for the eight default columns. Now that all 123 fields can be published,
anything past about eight sat behind a horizontal scrollbar on a screen wide
enough to show the lot.

Worse, Windows hides overlay scrollbars until something moves, so a table that
scrolled looked exactly like a table with its right-hand columns missing. That
is what was reported, and the report was reasonable: nothing on screen said
otherwise.

The wrapper grows with its content now, up to the window, and keeps a minimum so
a two-column table does not collapse on a large display. The table sizes to what
its cells need rather than being squeezed to the container first — that squeeze
could wrap a callsign while empty space sat further along the same row. And the
scrollbar is drawn permanently, thin and in the page's own colours.
2026-08-11 19:45:00 +02:00
rouggy f7b9bfd0bc fix(webpub): the Distance column was empty for every QSO logged here
Nothing computes a distance when a QSO is logged. The DISTANCE field is only
ever filled by an ADIF import that carried one, so publishing it straight gave
an empty column to anyone whose log was made in OpsLog — which is everyone who
reported it.

It falls back to the two locators, which are on the QSO already. A stored
distance still wins: it came from the log that recorded the contact, which knew
more than two four-character squares do. No grids means an empty cell, not a
zero — an empty cell is honest, a zero is a claim.

Rounded to whole kilometres. The squares are tens of kilometres across and a
decimal would assert an accuracy nobody has.

The geometry moved to internal/geo on the way. It lived in package main, which
internal packages cannot import, so the PSK Reporter watcher already had its
maths injected from main and this would have been a third copy. A bearing that
disagrees with itself between two panels is a fault nobody reports, because each
screen looks perfectly plausible on its own.
2026-08-11 19:41:47 +02:00
rouggy 102097c5c4 feat(bulk): mode, submode and RST are repair fields, so allow them
They were excluded as "per-QSO", alongside callsign and date. That rule confused
two different things: bulk edit is not for describing QSOs, it is for REPAIRING
a batch of them — an import that mapped every contact to SSB, an ADIF that
carried no MODE at all. Refusing because a hundred rows should not normally
share a value left the operator editing a hundred rows by hand.

Setting the mode CLEARS the submode. A submode belongs to the mode it was
recorded under; left behind it contradicts the new one, and "FT8 with a submode
of USB" is not a thing — worse, the submode is what most ADIF readers believe.

Band is still refused on its own, and that half of the rule stands. It travels
with the frequency through BulkSetFrequency, which writes the pair: a band
contradicting its own frequency is invalid ADIF, and every export would carry
the contradiction out into the world. Callsign and date stay out too — they
identify the contact rather than describe it.
2026-08-11 19:36:56 +02:00
rouggy 3b90ef6b7a chore: open 0.24.6
Empty block at the top so the next change has somewhere to go. 0.24.5 keeps its
nine entries; the release script stamps the version constants.
2026-08-11 18:20:42 +02:00
13 changed files with 1405 additions and 1071 deletions
+126 -40
View File
@@ -40,6 +40,7 @@ import (
"hamlog/internal/dxcc" "hamlog/internal/dxcc"
"hamlog/internal/email" "hamlog/internal/email"
"hamlog/internal/extsvc" "hamlog/internal/extsvc"
"hamlog/internal/geo"
"hamlog/internal/integrations/udp" "hamlog/internal/integrations/udp"
"hamlog/internal/lookup" "hamlog/internal/lookup"
"hamlog/internal/lotwusers" "hamlog/internal/lotwusers"
@@ -746,48 +747,16 @@ type App struct {
udpLastMode string udpLastMode string
} }
// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the // gridToLatLon and haversineKm live in internal/geo, which the internal
// centre lat/lon in degrees. Returns ok=false on malformed input. // packages can import — package main cannot be imported by anything. These
func gridToLatLon(grid string) (lat, lon float64, ok bool) { // forward so there is exactly ONE implementation: the PSK Reporter watcher and
g := strings.ToUpper(strings.TrimSpace(grid)) // the web publisher measure the same path the cluster does, and a bearing that
if len(g) < 4 { // disagrees with itself between two panels is a fault nobody reports, because
return 0, 0, false // each screen looks plausible alone.
} func gridToLatLon(grid string) (lat, lon float64, ok bool) { return geo.GridToLatLon(grid) }
A := g[0] - 'A'
B := g[1] - 'A'
C := g[2] - '0'
D := g[3] - '0'
if A > 17 || B > 17 || C > 9 || D > 9 {
return 0, 0, false
}
lon = -180 + float64(A)*20 + float64(C)*2
lat = -90 + float64(B)*10 + float64(D)*1
if len(g) >= 6 {
E := g[4] - 'A'
F := g[5] - 'A'
if E <= 23 && F <= 23 {
lon += float64(E)*(5.0/60.0) + 2.5/60.0
lat += float64(F)*(2.5/60.0) + 1.25/60.0
return lat, lon, true
}
}
// 4-char locator: aim at the centre of the square.
lon += 1
lat += 0.5
return lat, lon, true
}
// haversineKm returns the great-circle distance between two lat/lon pairs
// in kilometres. Standard Haversine, mean Earth radius 6371 km.
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 { func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0 return geo.HaversineKm(lat1, lon1, lat2, lon2)
rad := math.Pi / 180.0
dLat := (lat2 - lat1) * rad
dLon := (lon2 - lon1) * rad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return R * c
} }
// initialBearingDeg returns the initial great-circle bearing (azimuth) in // initialBearingDeg returns the initial great-circle bearing (azimuth) in
@@ -1407,6 +1376,8 @@ func (a *App) startup(ctx context.Context) {
// PSK Reporter, when the opening watch is on. After the operator's grid is // PSK Reporter, when the opening watch is on. After the operator's grid is
// known: without it there is no distance to measure and the feed stays down. // known: without it there is no distance to measure and the feed stays down.
a.startBandOpenFeed() a.startBandOpenFeed()
// One-time tidy-up of a field nothing used to record. Background, once.
a.backfillDistancesOnce()
fmt.Println("OpsLog: db ready at", a.dbPath) fmt.Println("OpsLog: db ready at", a.dbPath)
} }
@@ -2651,6 +2622,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
} }
}() }()
a.applyStationDefaults(&q, true) a.applyStationDefaults(&q, true)
fillDistance(&q)
a.applyDXCCNumber(&q) a.applyDXCCNumber(&q)
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
@@ -2994,6 +2966,27 @@ func (a *App) refineDistrictZones(q *qso.QSO) {
} }
} }
// fillDistance computes DISTANCE from the two locators when nothing supplied one.
//
// Its own function, NOT part of applyStationDefaults, because the import only
// applies those when the operator ticks the box — and a distance is not a
// station default. It is derived from the QSO's own two grids and is true
// whatever the operator chose about profile fields.
//
// Nothing recorded it before, so the field went out empty in every ADIF export
// and left the same gap in whoever imported the file: a hole that travels. An
// imported value always wins, having come from the log that made the contact,
// which knew the real positions rather than two four-character squares.
func fillDistance(q *qso.QSO) {
if q == nil || (q.Distance != nil && *q.Distance > 0) {
return
}
if km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid); ok && km > 0 {
v := math.Round(km)
q.Distance = &v
}
}
// applyStationDefaults fills any empty MY_* / station field on q with the // applyStationDefaults fills any empty MY_* / station field on q with the
// currently-active profile's values. Multi-profile support means a user // currently-active profile's values. Multi-profile support means a user
// can be /P with a different callsign + grid + SOTA ref than home — the // can be /P with a different callsign + grid + SOTA ref than home — the
@@ -6485,6 +6478,8 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
a.applyClublogException(q, true) // force: explicit import-time correction a.applyClublogException(q, true) // force: explicit import-time correction
} }
} }
// Unconditional: see fillDistance.
fillDistance(q)
if applyStation { if applyStation {
// Backfill empty MY_* descriptive fields from the active profile // Backfill empty MY_* descriptive fields from the active profile
// (identity fields left alone to keep mixed-call routing intact). // (identity fields left alone to keep mixed-call routing intact).
@@ -10368,6 +10363,97 @@ func (a *App) DownloadULSCounties() error {
} }
// BackfillUSCountiesResult summarises a bulk county/grid backfill over the log. // BackfillUSCountiesResult summarises a bulk county/grid backfill over the log.
// keyDistanceBackfilled marks the one-time distance fill as done.
//
// A migration, not a setting. It was briefly a button in Preferences, which was
// the wrong shape twice over: a maintenance chore does not belong beside the
// options an operator actually chooses, and nobody should have to be TOLD their
// log is missing a field before it gets filled in. It runs once, in the
// background, and never asks.
const keyDistanceBackfilled = "migr.distance_from_grids.v1"
// backfillDistancesOnce fills DISTANCE across the log the first time this
// version runs, then records that it is done.
//
// In the background: on a large log over a remote MySQL this is thousands of
// row updates, and startup must not wait for a tidy-up. Marked done only on
// success — a run cut short by a closed program should try again next time
// rather than leave half the log filled for ever.
func (a *App) backfillDistancesOnce() {
if a.settings == nil || a.qso == nil {
return
}
if v, _ := a.settings.GetGlobal(a.ctx, keyDistanceBackfilled); v == "1" {
return
}
go func() {
res, err := a.BackfillDistances()
if err != nil {
applog.Printf("distance backfill: %v — will try again next start", err)
return
}
_ = a.settings.SetGlobal(a.ctx, keyDistanceBackfilled, "1")
applog.Printf("distance backfill: done once for this log (%d filled)", res.Filled)
}()
}
// BackfillDistancesResult reports what a distance backfill did.
type BackfillDistancesResult struct {
Scanned int `json:"scanned"` // QSOs examined
Filled int `json:"filled"` // QSOs that gained a distance
NoGrid int `json:"no_grid"` // skipped: one of the two locators is missing
}
// BackfillDistances computes DISTANCE for past QSOs that have both locators and
// no distance recorded.
//
// Nothing has ever computed it when logging — the column is only filled by an
// ADIF import that carried one — so a log made in OpsLog has it empty
// throughout. The published web page works around that by deriving the distance
// as it renders, but the column still travels empty into every ADIF export, and
// that is what leaves the gap in someone else's log.
//
// Only fills what is EMPTY. A stored distance came from the log that recorded
// the contact, which knew the real positions; two four-character squares are a
// worse answer and must not overwrite a better one.
func (a *App) BackfillDistances() (BackfillDistancesResult, error) {
var res BackfillDistancesResult
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]
res.Scanned++
if q.Distance != nil && *q.Distance > 0 {
continue
}
km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid)
if !ok || km <= 0 {
res.NoGrid++
continue
}
// Whole kilometres: the squares are tens of kilometres across and a
// decimal would assert an accuracy the grids do not carry.
v := math.Round(km)
q.Distance = &v
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("backfill distance: QSO %d: %v", q.ID, err)
continue
}
res.Filled++
}
applog.Printf("backfill distance: %d scanned, %d filled, %d without both grids",
res.Scanned, res.Filled, res.NoGrid)
if res.Filled > 0 {
a.invalidateAwardStats()
}
return res, nil
}
type BackfillUSCountiesResult struct { type BackfillUSCountiesResult struct {
Scanned int `json:"scanned"` // US QSOs examined Scanned int `json:"scanned"` // US QSOs examined
County int `json:"county"` // QSOs that gained a county County int `json:"county"` // QSOs that gained a county
+16
View File
@@ -1,4 +1,20 @@
[ [
{
"version": "0.24.6",
"date": "",
"en": [
"Bulk edit can now set mode, submode and RST. They were excluded as per-QSO fields, which missed the point: bulk edit is for repairing a batch — an import that mapped every contact to SSB, an ADIF with no mode at all — and refusing meant editing a hundred rows one at a time. Setting the mode clears the submode, since one left over from the old mode contradicts the new one. Band stays with frequency, which already sets the two together.",
"Web publishing: the page now widens to fit the table. It was capped at a comfortable reading width, so with more than about eight columns the rest sat behind a scrollbar on a screen wide enough to show them all — and Windows hides that scrollbar until something moves, which made a table that scrolls look like a table missing columns. Columns also take the width their contents need instead of being squeezed to fit first.",
"Every QSO now carries its distance. Nothing ever recorded one, so the field went out empty in every ADIF export and left the Distance column blank on a published page. It is computed from the two locators when a contact is logged and when an ADIF is imported, and a one-time pass fills in the QSOs already in your log the first time this version runs — in the background, without asking. A distance the imported file supplied is always kept.",
"RDA: 1015 districts were filed under the wrong DXCC entity. Every reference sat on European Russia; 991 belong to Asiatic Russia and the 24 KA- districts to Kaliningrad, which is a separate entity altogether. Corrected against the reference list, and Kaliningrad added to the award filter so those 24 can be claimed at all."
],
"fr": [
"L édition groupée sait enfin régler le mode, le sous-mode et le RST. Ils étaient exclus comme champs propres à chaque QSO, ce qui manquait l essentiel : l édition groupée sert à RÉPARER un lot — un import qui a tout mis en SSB, un ADIF sans aucun mode — et refuser obligeait à corriger cent lignes une par une. Régler le mode efface le sous-mode, celui de l ancien mode contredisant le nouveau. La bande reste avec la fréquence, qui pose déjà les deux ensemble.",
"Publication web : la page s élargit désormais à la taille du tableau. Elle était bridée à une largeur de lecture confortable, donc au-delà de huit colonnes environ le reste passait derrière une barre de défilement sur un écran assez large pour tout montrer — et Windows masque cette barre tant que rien ne bouge, si bien qu un tableau qui défile ressemblait à un tableau amputé. Les colonnes prennent aussi la largeur qu il leur faut au lieu d être comprimées d abord.",
"Chaque QSO porte désormais sa distance. Rien ne l enregistrait, elle partait donc vide dans chaque export ADIF et laissait la colonne Distance blanche sur une page publiée. Elle est calculée depuis les deux locators à l enregistrement d un contact et à l import d un ADIF, et une passe unique complète les QSO déjà présents au premier lancement de cette version — en tâche de fond, sans rien demander. Une distance fournie par le fichier importé est toujours conservée.",
"RDA : 1015 districts étaient rangés sous la mauvaise entité DXCC. Toutes les références étaient sur la Russie européenne ; 991 relèvent de la Russie asiatique et les 24 districts KA- de Kaliningrad, qui est une entité à part entière. Corrigé d après la liste de référence, et Kaliningrad ajouté au filtre de l award pour que ces 24 puissent être revendiqués."
]
},
{ {
"version": "0.24.5", "version": "0.24.5",
"date": "", "date": "",
+11
View File
@@ -91,6 +91,17 @@ const FIELDS: FieldDef[] = [
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true }, { id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' }, { id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' }, { id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
// The contact itself — repair fields, not description fields. An import that
// mapped every QSO to SSB, or an ADIF with no MODE at all, is fixed here
// instead of one row at a time.
//
// Band is deliberately absent: it travels with the frequency below, because a
// band contradicting its own frequency is invalid ADIF and every export would
// carry the contradiction.
{ id: 'mode', label: 'bulk.fMode', group: 'The contact', kind: 'text', upper: true },
{ id: 'submode', label: 'bulk.fSubmode', group: 'The contact', kind: 'text', upper: true },
{ id: 'rst_sent', label: 'bulk.fRstSent', group: 'The contact', kind: 'text' },
{ id: 'rst_rcvd', label: 'bulk.fRstRcvd', group: 'The contact', kind: 'text' },
// Misc // Misc
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch // Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
// logged on a stale/default frequency after CAT dropped. // logged on a stale/default frequency after CAT dropped.
File diff suppressed because one or more lines are too long
+2
View File
@@ -88,6 +88,8 @@ export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Reco
export function AwardsFolder():Promise<string>; export function AwardsFolder():Promise<string>;
export function BackfillDistances():Promise<main.BackfillDistancesResult>;
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>; export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>; export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
+4
View File
@@ -118,6 +118,10 @@ export function AwardsFolder() {
return window['go']['main']['App']['AwardsFolder'](); return window['go']['main']['App']['AwardsFolder']();
} }
export function BackfillDistances() {
return window['go']['main']['App']['BackfillDistances']();
}
export function BackfillUSCounties() { export function BackfillUSCounties() {
return window['go']['main']['App']['BackfillUSCounties'](); return window['go']['main']['App']['BackfillUSCounties']();
} }
+16
View File
@@ -1931,6 +1931,22 @@ export namespace main {
this.to = source["to"]; this.to = source["to"];
} }
} }
export class BackfillDistancesResult {
scanned: number;
filled: number;
no_grid: number;
static createFrom(source: any = {}) {
return new BackfillDistancesResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.scanned = source["scanned"];
this.filled = source["filled"];
this.no_grid = source["no_grid"];
}
}
export class BackfillUSCountiesResult { export class BackfillUSCountiesResult {
scanned: number; scanned: number;
county: number; county: number;
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
// Package geo is the one place that turns Maidenhead locators into positions,
// and positions into distances and bearings.
//
// It exists because there were about to be three copies. These functions lived
// in package main, which the internal packages cannot import, so the PSK
// Reporter watcher had its geometry injected from main and the web publisher
// was about to grow its own. A bearing that disagrees with itself between two
// panels is the kind of fault nobody reports, because each screen looks
// plausible on its own.
package geo
import (
"math"
"strings"
)
// GridToLatLon parses a Maidenhead locator (4 or 6 characters) and returns the
// centre of that square in degrees. ok=false on malformed input.
func GridToLatLon(grid string) (lat, lon float64, ok bool) {
g := strings.ToUpper(strings.TrimSpace(grid))
if len(g) < 4 {
return 0, 0, false
}
A := g[0] - 'A'
B := g[1] - 'A'
C := g[2] - '0'
D := g[3] - '0'
if A > 17 || B > 17 || C > 9 || D > 9 {
return 0, 0, false
}
lon = -180 + float64(A)*20 + float64(C)*2
lat = -90 + float64(B)*10 + float64(D)*1
if len(g) >= 6 {
E := g[4] - 'A'
F := g[5] - 'A'
if E <= 23 && F <= 23 {
lon += float64(E)*(5.0/60.0) + 2.5/60.0
lat += float64(F)*(2.5/60.0) + 1.25/60.0
return lat, lon, true
}
}
// 4-character locator: aim at the centre of the square.
lon += 1
lat += 0.5
return lat, lon, true
}
// HaversineKm returns the great-circle distance between two positions in
// kilometres. Mean Earth radius 6371 km.
func HaversineKm(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0
rad := math.Pi / 180.0
dLat := (lat2 - lat1) * rad
dLon := (lon2 - lon1) * rad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}
// DistanceBetweenGrids is the distance in kilometres between two locators,
// ok=false when either cannot be parsed.
func DistanceBetweenGrids(a, b string) (km float64, ok bool) {
lat1, lon1, ok1 := GridToLatLon(a)
lat2, lon2, ok2 := GridToLatLon(b)
if !ok1 || !ok2 {
return 0, false
}
return HaversineKm(lat1, lon1, lat2, lon2), true
}
+26
View File
@@ -0,0 +1,26 @@
package qso
import "testing"
// Mode, submode and RST are repair fields: an import that mapped every contact
// to SSB, or an ADIF that carried no MODE, is fixed in one pass instead of one
// row at a time. They were excluded as "per-QSO", which confused describing a
// QSO with repairing a batch of them.
func TestModeAndRSTAreBulkEditable(t *testing.T) {
for _, col := range []string{"mode", "submode", "rst_sent", "rst_rcvd"} {
if !bulkEditableCols[col] {
t.Errorf("%s should be bulk-editable", col)
}
}
}
// Band must NOT be bulk-editable on its own: it travels with the frequency
// through BulkSetFrequency. A band contradicting its own frequency is invalid
// ADIF, and every export would carry the contradiction out into the world.
func TestBandIsNotBulkEditableAlone(t *testing.T) {
for _, col := range []string{"band", "freq_hz", "callsign", "qso_date"} {
if bulkEditableCols[col] {
t.Errorf("%s must not be bulk-editable on its own", col)
}
}
}
+37 -8
View File
@@ -734,13 +734,26 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
return nil return nil
} }
// bulkEditableCols whitelists the columns BulkSetField may write. Limited to // bulkEditableCols whitelists the columns BulkSetField may write.
// TEXT fields where setting one value across many QSOs is meaningful: the //
// per-service QSL/upload status fields, plus "my station"/operator fields that // Mostly TEXT fields where one value across many QSOs is meaningful: the
// are naturally constant across a run (grid, antenna, rig, address, …). It // per-service QSL/upload status fields, plus "my station"/operator fields
// deliberately excludes per-QSO fields (callsign, band, mode, date, RST, the // naturally constant across a run (grid, antenna, rig, address, …).
// contacted station's details) and numeric columns (power, zones, lat/lon), //
// which would be corrupted or meaningless if bulk-set to a single value. // Mode, submode and RST are here too, which the original rule excluded as
// "per-QSO". That rule confused two different things. Bulk edit is not for
// describing QSOs, it is for REPAIRING a batch — an import that mapped every
// contact to SSB, an ADIF with no MODE at all — and refusing to fix a hundred
// rows because a hundred rows should not normally share a value leaves the
// operator editing them one at a time.
//
// Band is NOT here, and frequency is not either: both go through
// BulkSetFrequency, which writes the pair together. A band that contradicts its
// own frequency is invalid ADIF, and every export would carry the contradiction.
//
// Still excluded, and this part of the rule stands: callsign and date, which
// identify the contact rather than describe it, and the numeric columns (power,
// zones, lat/lon) that are meaningless shared.
var bulkEditableCols = map[string]bool{ var bulkEditableCols = map[string]bool{
// QSL / upload status // QSL / upload status
"lotw_sent": true, "lotw_sent": true,
@@ -820,6 +833,14 @@ var bulkEditableCols = map[string]bool{
"iota": true, "iota": true,
"sig": true, "sig": true,
"sig_info": true, "sig_info": true,
// The contact itself. Repair fields: an import that mapped everything to SSB,
// or an ADIF that carried no MODE. Setting mode CLEARS submode (see
// BulkSetField) — a submode left over from the old mode contradicts the new
// one, and "FT8 / USB" is not a thing.
"mode": true,
"submode": true,
"rst_sent": true,
"rst_rcvd": true,
// Misc text // Misc text
"comment": true, "comment": true,
"notes": true, "notes": true,
@@ -843,8 +864,16 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
ph[i] = "?" ph[i] = "?"
args = append(args, id) args = append(args, id)
} }
set := column + " = ?, updated_at = ?"
if column == "mode" {
// A submode belongs to the mode it was recorded under. Left behind, it
// contradicts the new one — "FT8" with a submode of "USB" is not a thing,
// and it is the submode that most ADIF readers believe. Clearing it is the
// only outcome that leaves the row meaning what the operator asked for.
set += ", submode = ''"
}
res, err := r.db.ExecContext(ctx, res, err := r.db.ExecContext(ctx,
`UPDATE qso SET `+column+` = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`, `UPDATE qso SET `+set+` WHERE id IN (`+strings.Join(ph, ",")+`)`,
args...) args...)
if err != nil { if err != nil {
return 0, fmt.Errorf("bulk set %s: %w", column, err) return 0, fmt.Errorf("bulk set %s: %w", column, err)
+39 -4
View File
@@ -33,6 +33,7 @@ import (
"github.com/jlaffaye/ftp" "github.com/jlaffaye/ftp"
"hamlog/internal/geo"
"hamlog/internal/qso" "hamlog/internal/qso"
) )
@@ -93,6 +94,26 @@ func flt(p *float64) string {
return strconv.FormatFloat(*p, 'f', -1, 64) return strconv.FormatFloat(*p, 'f', -1, 64)
} }
// distanceKm is the path length for the Distance column.
//
// The stored DISTANCE field is only ever filled by an ADIF import that carried
// one — OpsLog does not compute it when logging — so publishing it straight gave
// an empty column for every QSO made here, which is how this was reported.
//
// So it falls back to the two locators, which are on the QSO already. Rounded to
// whole kilometres: the grids are squares tens of kilometres across, and a
// decimal on a figure that imprecise claims an accuracy nobody has.
func distanceKm(q *qso.QSO) string {
if q.Distance != nil && *q.Distance > 0 {
return flt(q.Distance)
}
km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid)
if !ok || km <= 0 {
return ""
}
return strconv.FormatFloat(km, 'f', 0, 64)
}
func stamp(t time.Time) string { func stamp(t time.Time) string {
if t.IsZero() { if t.IsZero() {
return "" return ""
@@ -218,7 +239,7 @@ var Columns = []Column{
{"my_sig_info", "My sig info", "My station", func(q *qso.QSO) string { return q.MySIGInfo }}, {"my_sig_info", "My sig info", "My station", func(q *qso.QSO) string { return q.MySIGInfo }},
{"wwff_ref", "WWFF", "Awards", func(q *qso.QSO) string { return q.WWFFRef }}, {"wwff_ref", "WWFF", "Awards", func(q *qso.QSO) string { return q.WWFFRef }},
{"my_wwff_ref", "My wwff ref", "My station", func(q *qso.QSO) string { return q.MyWWFFRef }}, {"my_wwff_ref", "My wwff ref", "My station", func(q *qso.QSO) string { return q.MyWWFFRef }},
{"distance", "Distance", "Location", func(q *qso.QSO) string { return flt(q.Distance) }}, {"distance", "Distance", "Location", distanceKm},
{"rx_pwr", "RX pwr", "QSO", func(q *qso.QSO) string { return flt(q.RXPower) }}, {"rx_pwr", "RX pwr", "QSO", func(q *qso.QSO) string { return flt(q.RXPower) }},
{"a_index", "A", "QSO", func(q *qso.QSO) string { return flt(q.AIndex) }}, {"a_index", "A", "QSO", func(q *qso.QSO) string { return flt(q.AIndex) }},
{"k_index", "K", "QSO", func(q *qso.QSO) string { return flt(q.KIndex) }}, {"k_index", "K", "QSO", func(q *qso.QSO) string { return flt(q.KIndex) }},
@@ -380,11 +401,25 @@ func renderHTML(cfg Config, cols []Column, qsos []qso.QSO, stationCall string) [
*{box-sizing:border-box} *{box-sizing:border-box}
body{margin:0;padding:1.5rem 1rem;background:var(--bg);color:var(--fg); body{margin:0;padding:1.5rem 1rem;background:var(--bg);color:var(--fg);
font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif} font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.wrap{max-width:1100px;margin:0 auto} /* The page grows with its table instead of being capped at a comfortable
READING width. 1100px suits eight columns and hides the rest behind a
scrollbar on a screen wide enough to show them all — which is how this was
reported. max-content lets a wide table use the window; min-width keeps a
narrow one from collapsing to nothing on a large display. */
.wrap{max-width:max-content;min-width:min(1100px,100%);margin:0 auto}
h1{margin:0 0 .25rem;font-size:1.35rem} h1{margin:0 0 .25rem;font-size:1.35rem}
.meta{margin:0 0 1rem;color:var(--mut);font-size:.8rem} .meta{margin:0 0 1rem;color:var(--mut);font-size:.8rem}
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px} /* A visible scrollbar. Windows hides overlay scrollbars until something moves,
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums} so a table that scrolls looks exactly like a table that is missing columns. */
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px;
scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.scroll::-webkit-scrollbar{height:10px}
.scroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
/* width:auto, not 100%: columns take the width their contents need. With
width:100% the browser squeezes them to fit the container first and only then
overflows, so a callsign could end up wrapped while empty space sat further
along the row. min-width keeps a two-column table filling the frame. */
table{border-collapse:collapse;width:auto;min-width:100%;font-variant-numeric:tabular-nums}
th,td{padding:.45rem .6rem;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap} th,td{padding:.45rem .6rem;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
th{position:sticky;top:0;background:var(--head);font-size:.72rem;letter-spacing:.05em; th{position:sticky;top:0;background:var(--head);font-size:.72rem;letter-spacing:.05em;
text-transform:uppercase;color:var(--mut);cursor:pointer;user-select:none} text-transform:uppercase;color:var(--mut);cursor:pointer;user-select:none}
+39
View File
@@ -0,0 +1,39 @@
package webpub
import (
"testing"
"hamlog/internal/qso"
)
// The Distance column was published empty for every QSO logged in OpsLog: the
// stored DISTANCE field is only ever filled by an ADIF import that carried one,
// and nothing computes it when logging. It falls back to the two locators.
func TestDistanceFallsBackToTheGrids(t *testing.T) {
// JN36 (French Alps) to IO91 (southern England): a few hundred kilometres.
got := distanceKm(&qso.QSO{MyGrid: "JN36DG", Grid: "IO91"})
if got == "" {
t.Fatal("no distance from two perfectly good locators")
}
if got == "0" {
t.Errorf("distance = %q", got)
}
}
// A stored distance wins: it came from the log that recorded the QSO, which knew
// more than two four-character squares do.
func TestStoredDistanceWins(t *testing.T) {
d := 1234.0
if got := distanceKm(&qso.QSO{Distance: &d, MyGrid: "JN36", Grid: "IO91"}); got != "1234" {
t.Errorf("distance = %q, want the stored 1234", got)
}
}
// No grids, no invention. An empty cell is honest; a zero is a claim.
func TestNoGridsNoDistance(t *testing.T) {
for _, q := range []qso.QSO{{}, {MyGrid: "JN36"}, {Grid: "IO91"}, {MyGrid: "??", Grid: "IO91"}} {
if got := distanceKm(&q); got != "" {
t.Errorf("distance(%+v) = %q, want empty", q, got)
}
}
}