Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7d2de0777 | ||
|
|
344e8b2091 | ||
|
|
c5e0ec9033 | ||
|
|
210a99983e | ||
|
|
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,24 @@
|
||||
[
|
||||
{
|
||||
"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.",
|
||||
"QSO filter: asking a field to equal nothing now finds the empty ones. SQL answers that question with nothing at all — a missing value never equals an empty one — so the filter looked broken rather than wrong. Empty on a numeric field also covers zero as well as missing, which SQLite and MySQL disagreed about.",
|
||||
"Callsign lookup: a /QRP call now returns its locator. The lookup falls back to the home callsign when the slashed form is not registered, and then cleared the location — right for /P and /M, where the operator is somewhere other than their registered address, and wrong for /QRP, which says something about power and nothing about place. The grid came back empty while the same call without the suffix answered perfectly."
|
||||
],
|
||||
"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.",
|
||||
"Filtre QSO : demander à un champ d être égal à rien trouve désormais les vides. SQL répond à cette question par rien du tout — une valeur absente n est jamais égale à une valeur vide — et le filtre paraissait cassé plutôt que mal posé. Vide sur un champ numérique couvre aussi le zéro autant que l absence, ce sur quoi SQLite et MySQL n étaient pas d accord.",
|
||||
"Recherche d indicatif : un indicatif en /QRP rend enfin son locator. La recherche se rabat sur l indicatif de base quand la forme avec barre n est pas enregistrée, puis effaçait la localisation — ce qui est juste pour /P et /M, où l opérateur n est pas à son adresse déclarée, et faux pour /QRP, qui parle de puissance et pas de lieu. Le locator revenait vide alors que le même indicatif sans le suffixe répondait parfaitement."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.24.5",
|
||||
"date": "",
|
||||
|
||||
@@ -172,8 +172,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
|
||||
[lat, lon],
|
||||
);
|
||||
// Stacked, not side by side. Laid out in a row this cost about 150 px of a
|
||||
// header that has to hold the callsign, the badges and the band grid, and it
|
||||
// was what pushed the whole row onto a second line. Two short times one above
|
||||
// the other take a fraction of that and no extra height: the row is already
|
||||
// taller than one line of text.
|
||||
//
|
||||
// "UTC" moves into the tooltip with them — the times are monospaced and always
|
||||
// UTC everywhere in OpsLog, so the label was spending width to repeat a
|
||||
// convention the operator already lives by.
|
||||
const sunBlock = sun ? (
|
||||
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
|
||||
<div className="ml-auto flex flex-col items-end leading-tight text-xs shrink-0"
|
||||
title="Sunrise / sunset at the DX station (UTC)">
|
||||
{sun.polarDay ? (
|
||||
<span className="font-semibold text-warning">midnight sun</span>
|
||||
@@ -182,14 +191,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sunrise className="size-3.5 text-warning" />
|
||||
<Sunrise className="size-3 text-warning" />
|
||||
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sunset className="size-3.5 text-info" />
|
||||
<Sunset className="size-3 text-info" />
|
||||
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">UTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
@@ -110,7 +121,12 @@ const STATUS_VALUES: { v: string; label: string }[] = [
|
||||
{ v: '_', label: 'bulk.statusBlank' },
|
||||
];
|
||||
|
||||
const GROUPS = ['QSL / upload', 'My station', 'Contacted station', 'Contest', 'Propagation', 'Misc'];
|
||||
// Derived from the fields themselves, in the order they are declared.
|
||||
//
|
||||
// This used to be a hand-written list, and a group added to FIELDS but not to it
|
||||
// simply never rendered — the fields existed, passed every check, and could not
|
||||
// be picked. Two lists that must agree, with nothing to make them.
|
||||
const GROUPS = [...new Set(FIELDS.map((f) => f.group))];
|
||||
// Maps the internal group key → its i18n label key.
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
'QSL / upload': 'bulk.groupQsl',
|
||||
@@ -119,6 +135,7 @@ const GROUP_LABELS: Record<string, string> = {
|
||||
'Contest': 'bulk.groupContest',
|
||||
'Propagation': 'bulk.groupPropagation',
|
||||
'Misc': 'bulk.groupMisc',
|
||||
'The contact': 'bulk.groupContact',
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -177,7 +194,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
<SelectContent>
|
||||
{GROUPS.map((g) => (
|
||||
<div key={g}>
|
||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{GROUP_LABELS[g] ? t(GROUP_LABELS[g]) : g}</div>
|
||||
{FIELDS.filter((f) => f.group === g)
|
||||
.map((f) => ({ f, txt: t(f.label) }))
|
||||
.sort((a, b) => a.txt.localeCompare(b.txt))
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.24.5';
|
||||
export const APP_VERSION = '0.24.6';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
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;
|
||||
|
||||
+1017
-1016
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
|
||||
}
|
||||
@@ -176,12 +176,20 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
r.Callsign = call
|
||||
r.Source = p.Name()
|
||||
r.FetchedAt = time.Now().UTC()
|
||||
// The home record's location is the operator's HOME, not where they
|
||||
// are portable now — clear it so cty.dat fills the real entity.
|
||||
r.Country, r.Continent = "", ""
|
||||
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
|
||||
r.Lat, r.Lon = 0, 0
|
||||
r.Grid, r.State, r.County = "", "", ""
|
||||
// The home record's location is the operator's HOME — clear it so
|
||||
// cty.dat fills in where they actually are.
|
||||
//
|
||||
// UNLESS the suffix says nothing about location. /QRP is a statement
|
||||
// about power, not about place: M0BFS/QRP is M0BFS, at home, running
|
||||
// five watts. Wiping the grid there threw away the one field the
|
||||
// operator was looking the call up for, and it came back empty while
|
||||
// the same lookup without the suffix answered perfectly.
|
||||
if !saysNothingAboutLocation(call) {
|
||||
r.Country, r.Continent = "", ""
|
||||
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
|
||||
r.Lat, r.Lon = 0, 0
|
||||
r.Grid, r.State, r.County = "", "", ""
|
||||
}
|
||||
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
||||
normalizeNames(&r)
|
||||
_ = m.cache.Put(ctx, r)
|
||||
@@ -232,6 +240,33 @@ var LogSink = func(string, ...any) {}
|
||||
// right and must be looked up exactly as entered.
|
||||
var opSuffixes = map[string]bool{"M": true, "MM": true, "AM": true, "P": true, "QRP": true}
|
||||
|
||||
// nonLocationSuffixes say nothing about WHERE the operator is.
|
||||
//
|
||||
// /QRP is a statement about power. /M and /P and their kin are not: mobile and
|
||||
// portable both mean "somewhere other than the home station", which is exactly
|
||||
// why the home record's location is discarded for them. Keeping that distinction
|
||||
// is the difference between a grid that is stale and a grid that is absent.
|
||||
var nonLocationSuffixes = map[string]bool{"QRP": true}
|
||||
|
||||
// saysNothingAboutLocation reports a call whose every suffix leaves the operator
|
||||
// at their registered address — so the home record's location can be trusted.
|
||||
func saysNothingAboutLocation(call string) bool {
|
||||
parts := strings.Split(strings.ToUpper(strings.TrimSpace(call)), "/")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
base := strings.TrimSpace(parts[0])
|
||||
if len(base) < 3 || !strings.ContainsAny(base, "0123456789") {
|
||||
return false // "JW/OR1A": the first part is a prefix — a location change
|
||||
}
|
||||
for _, p := range parts[1:] {
|
||||
if !nonLocationSuffixes[strings.TrimSpace(p)] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stripOpSuffix returns the bare callsign when call carries nothing but
|
||||
// operational suffixes ("F4LYI/M" → "F4LYI", true). Reports false for anything
|
||||
// that changes entity or area ("JW/OR1A", "F4BPO/8"), and for a call whose base
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package lookup
|
||||
|
||||
import "testing"
|
||||
|
||||
// A grid came back empty for M0BFS/QRP while the same lookup without the suffix
|
||||
// answered perfectly. The home-call pass wiped the location on the grounds that
|
||||
// a portable operator is not at their registered address — true for /P and /M,
|
||||
// and simply wrong for /QRP, which is a statement about power.
|
||||
func TestSaysNothingAboutLocation(t *testing.T) {
|
||||
keep := []string{"M0BFS/QRP", "f4bpo/qrp", "G0ABC/QRP"}
|
||||
for _, c := range keep {
|
||||
if !saysNothingAboutLocation(c) {
|
||||
t.Errorf("%s: the home location should be kept — /QRP does not move anyone", c)
|
||||
}
|
||||
}
|
||||
// These DO move the operator, or change the entity outright.
|
||||
drop := []string{"F4BPO/P", "F4BPO/M", "F4BPO/MM", "F4BPO/AM", "JW/OR1A", "VP8/F4BPO", "F4BPO/8", "F4BPO"}
|
||||
for _, c := range drop {
|
||||
if saysNothingAboutLocation(c) {
|
||||
t.Errorf("%s: the home location must NOT be trusted", c)
|
||||
}
|
||||
}
|
||||
// A power suffix on top of a portable one still moves them.
|
||||
if saysNothingAboutLocation("F4BPO/P/QRP") {
|
||||
t.Error("F4BPO/P/QRP is portable — location must not be kept")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package qso
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// "equals nothing" and "is empty" are the same question. SQL answers the first
|
||||
// with nothing at all — a NULL never equals ” — so a filter written in plain
|
||||
// words returned zero rows and looked broken rather than wrong.
|
||||
func TestEqualsBlankMeansEmpty(t *testing.T) {
|
||||
sql, args, err := conditionSQL(Condition{Field: "freq_hz", Op: "eq", Value: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("eq blank: %v", err)
|
||||
}
|
||||
if len(args) != 0 || !strings.Contains(sql, "IS NULL") {
|
||||
t.Errorf("sql = %q args = %v — want the empty test", sql, args)
|
||||
}
|
||||
sql, _, _ = conditionSQL(Condition{Field: "name", Op: "ne", Value: " "})
|
||||
if !strings.Contains(sql, "<> ''") {
|
||||
t.Errorf("ne blank on text gave %q — want the not-empty test", sql)
|
||||
}
|
||||
}
|
||||
|
||||
// A numeric column is empty when NULL *or* zero, and that must be explicit:
|
||||
// SQLite compares 0 against ” as false while MySQL calls it true, so one
|
||||
// expression would answer two different questions depending on the backend.
|
||||
func TestEmptyOnNumericCoversZeroAndNull(t *testing.T) {
|
||||
sql, _, err := conditionSQL(Condition{Field: "freq_hz", Op: "empty"})
|
||||
if err != nil {
|
||||
t.Fatalf("empty: %v", err)
|
||||
}
|
||||
if !strings.Contains(sql, "IS NULL") || !strings.Contains(sql, "= 0") {
|
||||
t.Errorf("sql = %q — want both NULL and zero", sql)
|
||||
}
|
||||
// Text keeps the string test: '' is a real value there, 0 is not.
|
||||
sql, _, _ = conditionSQL(Condition{Field: "name", Op: "empty"})
|
||||
if !strings.Contains(sql, "IFNULL") || strings.Contains(sql, "= 0") {
|
||||
t.Errorf("text empty gave %q", sql)
|
||||
}
|
||||
}
|
||||
+87
-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.
|
||||
@@ -1220,6 +1266,17 @@ var filterableColumns = map[string]bool{
|
||||
// value compares on the date part (see conditionSQL) so day filters are exact.
|
||||
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
|
||||
|
||||
// numericColumns are the filterable columns holding numbers rather than text.
|
||||
//
|
||||
// "Empty" means something different for them: NULL *or* zero. It has to be said
|
||||
// explicitly because the two backends disagree — SQLite compares 0 against ”
|
||||
// as false, MySQL calls it true — so one expression would quietly answer two
|
||||
// different questions depending on where the logbook lives.
|
||||
var numericColumns = map[string]bool{
|
||||
"freq_hz": true, "freq_rx_hz": true, "dxcc": true, "cqz": true, "ituz": true,
|
||||
"srx": true, "stx": true, "tx_pwr": true,
|
||||
}
|
||||
|
||||
// bareDateRe matches a plain calendar date with no time component.
|
||||
var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
|
||||
@@ -1320,6 +1377,18 @@ func conditionSQL(c Condition) (string, []any, error) {
|
||||
col = "substr(" + col + ",1,10)"
|
||||
v = strings.TrimSpace(v)
|
||||
}
|
||||
// "equals nothing" and "is empty" are the same question, and SQL answers the
|
||||
// first with nothing at all: a NULL never equals '', so a filter written that
|
||||
// way returns zero rows and looks broken rather than wrong. Asking it in
|
||||
// plain words is not a mistake worth punishing.
|
||||
if strings.TrimSpace(v) == "" {
|
||||
switch c.Op {
|
||||
case "eq":
|
||||
c.Op = "empty"
|
||||
case "ne":
|
||||
c.Op = "notempty"
|
||||
}
|
||||
}
|
||||
switch c.Op {
|
||||
case "eq":
|
||||
return col + " = ?", []any{v}, nil
|
||||
@@ -1370,8 +1439,18 @@ func conditionSQL(c Condition) (string, []any, error) {
|
||||
}
|
||||
return col + " IN (" + ph + ")", args, nil
|
||||
case "empty":
|
||||
// A numeric column is empty when it is NULL *or* zero, and that has to be
|
||||
// said explicitly: SQLite compares 0 against '' as false while MySQL calls
|
||||
// it true, so IFNULL(col,'')='' quietly means different things on the two
|
||||
// backends OpsLog supports.
|
||||
if numericColumns[strings.ToLower(strings.TrimSpace(c.Field))] {
|
||||
return "(" + col + " IS NULL OR " + col + " = 0)", nil, nil
|
||||
}
|
||||
return "IFNULL(" + col + ",'') = ''", nil, nil
|
||||
case "notempty":
|
||||
if numericColumns[strings.ToLower(strings.TrimSpace(c.Field))] {
|
||||
return "(" + col + " IS NOT NULL AND " + col + " <> 0)", nil, nil
|
||||
}
|
||||
return "IFNULL(" + col + ",'') <> ''", nil, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unknown operator %q", c.Op)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.24.5"
|
||||
appVersion = "0.24.6"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user