Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6e09a1d7 | ||
|
|
1b7f8ec9c1 | ||
|
|
75a2f73992 | ||
|
|
deee8c4618 | ||
|
|
92a5f30ac0 | ||
|
|
d3a405f4f6 | ||
|
|
f7b9bfd0bc | ||
|
|
102097c5c4 | ||
|
|
3b90ef6b7a |
@@ -40,6 +40,7 @@ import (
|
||||
"hamlog/internal/dxcc"
|
||||
"hamlog/internal/email"
|
||||
"hamlog/internal/extsvc"
|
||||
"hamlog/internal/geo"
|
||||
"hamlog/internal/integrations/udp"
|
||||
"hamlog/internal/lookup"
|
||||
"hamlog/internal/lotwusers"
|
||||
@@ -746,48 +747,16 @@ type App struct {
|
||||
udpLastMode string
|
||||
}
|
||||
|
||||
// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the
|
||||
// centre lat/lon in degrees. Returns 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-char locator: aim at the centre of the square.
|
||||
lon += 1
|
||||
lat += 0.5
|
||||
return lat, lon, true
|
||||
}
|
||||
// gridToLatLon and haversineKm live in internal/geo, which the internal
|
||||
// packages can import — package main cannot be imported by anything. These
|
||||
// forward so there is exactly ONE implementation: the PSK Reporter watcher and
|
||||
// the web publisher measure the same path the cluster does, and a bearing that
|
||||
// disagrees with itself between two panels is a fault nobody reports, because
|
||||
// each screen looks plausible alone.
|
||||
func gridToLatLon(grid string) (lat, lon float64, ok bool) { return geo.GridToLatLon(grid) }
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
return R * c
|
||||
return geo.HaversineKm(lat1, lon1, lat2, lon2)
|
||||
}
|
||||
|
||||
// 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
|
||||
// known: without it there is no distance to measure and the feed stays down.
|
||||
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)
|
||||
}
|
||||
@@ -2651,6 +2622,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||
}
|
||||
}()
|
||||
a.applyStationDefaults(&q, true)
|
||||
fillDistance(&q)
|
||||
a.applyDXCCNumber(&q)
|
||||
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
|
||||
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
|
||||
// currently-active profile's values. Multi-profile support means a user
|
||||
// can be /P with a different callsign + grid + SOTA ref than home — the
|
||||
@@ -6169,6 +6162,12 @@ var bulkFieldColumns = map[string]string{
|
||||
"iota": "iota",
|
||||
"sig": "sig",
|
||||
"sig_info": "sig_info",
|
||||
// The contact itself — repair fields. Setting mode also clears submode, in
|
||||
// qso.BulkSetField: a submode left over from the old mode contradicts the new.
|
||||
"mode": "mode",
|
||||
"submode": "submode",
|
||||
"rst_sent": "rst_sent",
|
||||
"rst_rcvd": "rst_rcvd",
|
||||
// Misc text
|
||||
"comment": "comment",
|
||||
"notes": "notes",
|
||||
@@ -6485,6 +6484,8 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
|
||||
a.applyClublogException(q, true) // force: explicit import-time correction
|
||||
}
|
||||
}
|
||||
// Unconditional: see fillDistance.
|
||||
fillDistance(q)
|
||||
if applyStation {
|
||||
// Backfill empty MY_* descriptive fields from the active profile
|
||||
// (identity fields left alone to keep mixed-call routing intact).
|
||||
@@ -10368,6 +10369,97 @@ func (a *App) DownloadULSCounties() error {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Scanned int `json:"scanned"` // US QSOs examined
|
||||
County int `json:"county"` // QSOs that gained a county
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// A bulk-editable field passes through THREE tables: the field list in the UI,
|
||||
// bulkFieldColumns here, and bulkEditableCols in internal/qso. Mode and RST were
|
||||
// added to the first and the third and not the second, so the dialog offered
|
||||
// them and the save failed with "unknown field" — reported from the field.
|
||||
//
|
||||
// Nothing warns about that: each table is valid on its own. This is the check.
|
||||
func TestEveryMappedBulkFieldIsWhitelisted(t *testing.T) {
|
||||
for id, col := range bulkFieldColumns {
|
||||
if !qso.BulkEditable(col) {
|
||||
t.Errorf("bulk field %q maps to column %q, which internal/qso refuses to write", id, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the reverse: a column the qso layer allows but nothing maps to is dead
|
||||
// weight — it looks supported from the inside and cannot be reached from outside.
|
||||
func TestNoUnreachableBulkColumn(t *testing.T) {
|
||||
mapped := map[string]bool{}
|
||||
for _, col := range bulkFieldColumns {
|
||||
mapped[col] = true
|
||||
}
|
||||
for _, col := range qso.BulkEditableColumns() {
|
||||
if !mapped[col] {
|
||||
t.Errorf("column %q is bulk-writable but no field maps to it — unreachable", col)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
"date": "",
|
||||
|
||||
@@ -91,6 +91,17 @@ const FIELDS: FieldDef[] = [
|
||||
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
||||
{ id: 'sig', label: 'bulk.fSig', 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
|
||||
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
|
||||
// logged on a stale/default frequency after CAT dropped.
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -88,6 +88,8 @@ export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Reco
|
||||
|
||||
export function AwardsFolder():Promise<string>;
|
||||
|
||||
export function BackfillDistances():Promise<main.BackfillDistancesResult>;
|
||||
|
||||
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
|
||||
|
||||
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
|
||||
|
||||
@@ -118,6 +118,10 @@ export function AwardsFolder() {
|
||||
return window['go']['main']['App']['AwardsFolder']();
|
||||
}
|
||||
|
||||
export function BackfillDistances() {
|
||||
return window['go']['main']['App']['BackfillDistances']();
|
||||
}
|
||||
|
||||
export function BackfillUSCounties() {
|
||||
return window['go']['main']['App']['BackfillUSCounties']();
|
||||
}
|
||||
|
||||
@@ -1931,6 +1931,22 @@ export namespace main {
|
||||
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 {
|
||||
scanned: number;
|
||||
county: number;
|
||||
|
||||
+1018
-1017
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-8
@@ -734,13 +734,26 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// bulkEditableCols whitelists the columns BulkSetField may write. Limited to
|
||||
// TEXT fields where setting one value across many QSOs is meaningful: the
|
||||
// per-service QSL/upload status fields, plus "my station"/operator fields that
|
||||
// are naturally constant across a run (grid, antenna, rig, address, …). It
|
||||
// deliberately excludes per-QSO fields (callsign, band, mode, date, RST, the
|
||||
// contacted station's details) and numeric columns (power, zones, lat/lon),
|
||||
// which would be corrupted or meaningless if bulk-set to a single value.
|
||||
// bulkEditableCols whitelists the columns BulkSetField may write.
|
||||
//
|
||||
// Mostly TEXT fields where one value across many QSOs is meaningful: the
|
||||
// per-service QSL/upload status fields, plus "my station"/operator fields
|
||||
// naturally constant across a run (grid, antenna, rig, address, …).
|
||||
//
|
||||
// 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{
|
||||
// QSL / upload status
|
||||
"lotw_sent": true,
|
||||
@@ -820,6 +833,14 @@ var bulkEditableCols = map[string]bool{
|
||||
"iota": true,
|
||||
"sig": 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
|
||||
"comment": true,
|
||||
"notes": true,
|
||||
@@ -843,8 +864,16 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
||||
ph[i] = "?"
|
||||
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,
|
||||
`UPDATE qso SET `+column+` = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||
`UPDATE qso SET `+set+` WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||
args...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
||||
@@ -867,6 +896,23 @@ var bulkEditableExtras = map[string]string{
|
||||
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
|
||||
func BulkExtraKey(field string) string { return bulkEditableExtras[field] }
|
||||
|
||||
// BulkEditable reports whether a COLUMN may be bulk-written. Exported so the
|
||||
// app layer can check its own field mapping against this whitelist: the two
|
||||
// lists are separate, valid on their own, and a field present in one and absent
|
||||
// from the other fails only when an operator tries to use it.
|
||||
func BulkEditable(column string) bool { return bulkEditableCols[column] }
|
||||
|
||||
// BulkEditableColumns lists every bulk-writable column, for the same check from
|
||||
// the other side: a column nothing maps to looks supported and cannot be used.
|
||||
func BulkEditableColumns() []string {
|
||||
out := make([]string, 0, len(bulkEditableCols))
|
||||
for c := range bulkEditableCols {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// BulkSetExtra sets one whitelisted extras_json field on every listed QSO,
|
||||
// leaving the other extras untouched. An empty value REMOVES the key rather than
|
||||
// storing a blank — an empty extra would otherwise be carried into every export.
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
|
||||
"github.com/jlaffaye/ftp"
|
||||
|
||||
"hamlog/internal/geo"
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
@@ -93,6 +94,26 @@ func flt(p *float64) string {
|
||||
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 {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
@@ -218,7 +239,7 @@ var Columns = []Column{
|
||||
{"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 }},
|
||||
{"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) }},
|
||||
{"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) }},
|
||||
@@ -380,11 +401,25 @@ func renderHTML(cfg Config, cols []Column, qsos []qso.QSO, stationCall string) [
|
||||
*{box-sizing:border-box}
|
||||
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}
|
||||
.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}
|
||||
.meta{margin:0 0 1rem;color:var(--mut);font-size:.8rem}
|
||||
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px}
|
||||
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
|
||||
/* A visible scrollbar. Windows hides overlay scrollbars until something moves,
|
||||
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{position:sticky;top:0;background:var(--head);font-size:.72rem;letter-spacing:.05em;
|
||||
text-transform:uppercase;color:var(--mut);cursor:pointer;user-select:none}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user