feat(webpub): offer every field, and make choosing among them possible

The catalogue held 23 hand-picked columns, so publishing a county, a satellite
pass or an award reference was simply not possible. It is generated from the
qso.QSO struct now — 123 fields — which also means a new ADIF field cannot be
forgotten here, as a hand-written list always eventually is.

Three keys were renamed to their ADIF names on the way (pota, sota, station).
An alias map keeps existing configurations publishing the same columns; without
it three would have vanished silently on upgrade, which is the worst way for a
setting to change.

The picker had to change with it: 123 chips in one wrap is a wall nobody reads
to the end of. It is sectioned by group, filtered as you type, and the chosen
columns sit on top in publication order — after picking eight out of a hundred,
the question stops being "what exists" and becomes "what did I pick".

The package doc said columns were curated so the operator's address could not
be published. That is no longer true and the comment now says so plainly: the
judgement moved to the operator, the default selection is unchanged, and nothing
is published that was not chosen.

Also collapses the changelog: four separate shared-CAT entries were one thing
from where the operator sits, and all of them were far longer than the one or
two sentences this project asks for.
This commit is contained in:
2026-08-11 10:59:01 +02:00
parent d36d431d9a
commit 67fe5bcff0
4 changed files with 253 additions and 54 deletions
+186 -30
View File
@@ -10,9 +10,13 @@
// - The page is self-contained: no external CSS, font or script. It has to
// work dropped into any hosting, including one that blocks third-party
// requests, and it must not leak the reader's visit to anyone.
// - Columns are a fixed, curated set rather than "every ADIF field". This is
// a page shown to the public: RST and QSL status belong, the operator's
// home address does not.
// - Every ADIF field is offered, because an operator publishing a county hunt
// or an award chase needs fields no curated list would have guessed. That
// puts the judgement on them, and it is a real one: this page is PUBLIC, and
// the catalogue includes the correspondent's address, e-mail and the
// operator's own street. Nothing is published unless it is chosen, and the
// default selection stays the eight columns a reader of someone else's log
// actually looks for.
package webpub
import (
@@ -60,7 +64,10 @@ type Config struct {
type Column struct {
Key string
Header string
Value func(q *qso.QSO) string
// Group is the section a picker shows this under. With every ADIF field on
// offer, a flat list is unusable — the grouping IS what makes it navigable.
Group string
Value func(q *qso.QSO) string
}
func str(p *int) string {
@@ -70,43 +77,188 @@ func str(p *int) string {
return strconv.Itoa(*p)
}
// Columns is the curated set, in default display order. Add here to offer a new
// one; the stored config keeps keys, so order changes are safe.
func str64(p *int64) string {
if p == nil {
return ""
}
return strconv.FormatInt(*p, 10)
}
// flt drops a trailing ".0": a distance reads better as "1420" than "1420.0",
// and a bearing as "142.5" keeps the half-degree that matters.
func flt(p *float64) string {
if p == nil {
return ""
}
return strconv.FormatFloat(*p, 'f', -1, 64)
}
func stamp(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02 15:04")
}
// Columns is every field a QSO can carry, in ADIF order, grouped for the picker.
//
// Generated from the qso.QSO struct rather than curated by hand: the previous
// list held 23 of them, so anyone wanting to publish a county, a satellite pass
// or an award reference simply could not. A hand list also silently rots — a new
// ADIF field is added to the struct and nobody remembers this file exists.
//
// The stored configuration keeps KEYS, so order and grouping can change freely.
var Columns = []Column{
{"date", "Date", func(q *qso.QSO) string { return q.QSODate.UTC().Format("2006-01-02") }},
{"time", "UTC", func(q *qso.QSO) string { return q.QSODate.UTC().Format("15:04") }},
{"callsign", "Call", func(q *qso.QSO) string { return q.Callsign }},
{"band", "Band", func(q *qso.QSO) string { return q.Band }},
{"mode", "Mode", func(q *qso.QSO) string { return q.Mode }},
{"freq", "Freq", func(q *qso.QSO) string {
// Date and time are one column in the database and two here: a log reads by
// day, and a reader scanning for "that evening" should not have to parse a
// timestamp. Frequency is stored in Hz and published in MHz.
{"date", "Date", "QSO", func(q *qso.QSO) string { return q.QSODate.UTC().Format("2006-01-02") }},
{"time", "UTC", "QSO", func(q *qso.QSO) string { return q.QSODate.UTC().Format("15:04") }},
{"freq", "Freq", "QSO", func(q *qso.QSO) string {
if q.FreqHz == nil || *q.FreqHz == 0 {
return ""
}
return strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64)
}},
{"rst_sent", "RST S", func(q *qso.QSO) string { return q.RSTSent }},
{"rst_rcvd", "RST R", func(q *qso.QSO) string { return q.RSTRcvd }},
{"name", "Name", func(q *qso.QSO) string { return q.Name }},
{"qth", "QTH", func(q *qso.QSO) string { return q.QTH }},
{"country", "Country", func(q *qso.QSO) string { return q.Country }},
{"grid", "Grid", func(q *qso.QSO) string { return q.Grid }},
{"dxcc", "DXCC", func(q *qso.QSO) string { return str(q.DXCC) }},
{"cqz", "CQ", func(q *qso.QSO) string { return str(q.CQZ) }},
{"ituz", "ITU", func(q *qso.QSO) string { return str(q.ITUZ) }},
{"iota", "IOTA", func(q *qso.QSO) string { return q.IOTA }},
{"pota", "POTA", func(q *qso.QSO) string { return q.POTARef }},
{"sota", "SOTA", func(q *qso.QSO) string { return q.SOTARef }},
{"qsl_sent", "QSL S", func(q *qso.QSO) string { return q.QSLSent }},
{"qsl_rcvd", "QSL R", func(q *qso.QSO) string { return q.QSLRcvd }},
{"lotw_rcvd", "LoTW", func(q *qso.QSO) string { return q.LOTWRcvd }},
{"station", "Station", func(q *qso.QSO) string { return q.StationCallsign }},
{"comment", "Comment", func(q *qso.QSO) string { return q.Comment }},
{"callsign", "Callsign", "QSO", func(q *qso.QSO) string { return q.Callsign }},
{"qso_date_off", "End", "QSO", func(q *qso.QSO) string { return stamp(q.QSODateOff) }},
{"band", "Band", "QSO", func(q *qso.QSO) string { return q.Band }},
{"band_rx", "Band RX", "QSO", func(q *qso.QSO) string { return q.BandRX }},
{"mode", "Mode", "QSO", func(q *qso.QSO) string { return q.Mode }},
{"submode", "Submode", "QSO", func(q *qso.QSO) string { return q.Submode }},
{"freq_rx_hz", "Freq RX", "QSO", func(q *qso.QSO) string { return str64(q.FreqRXHz) }},
{"rst_sent", "RST S", "QSO", func(q *qso.QSO) string { return q.RSTSent }},
{"rst_rcvd", "RST R", "QSO", func(q *qso.QSO) string { return q.RSTRcvd }},
{"name", "Name", "QSO", func(q *qso.QSO) string { return q.Name }},
{"qth", "QTH", "QSO", func(q *qso.QSO) string { return q.QTH }},
{"address", "Address", "QSO", func(q *qso.QSO) string { return q.Address }},
{"email", "Email", "QSO", func(q *qso.QSO) string { return q.Email }},
{"web", "Web", "QSO", func(q *qso.QSO) string { return q.Web }},
{"grid", "Grid", "Location", func(q *qso.QSO) string { return q.Grid }},
{"gridsquare_ext", "Grid ext", "Location", func(q *qso.QSO) string { return q.GridExt }},
{"vucc_grids", "VUCC", "Location", func(q *qso.QSO) string { return q.VUCCGrids }},
{"country", "Country", "Location", func(q *qso.QSO) string { return q.Country }},
{"state", "State", "Location", func(q *qso.QSO) string { return q.State }},
{"cnty", "County", "Location", func(q *qso.QSO) string { return q.County }},
{"dxcc", "Dxcc", "Location", func(q *qso.QSO) string { return str(q.DXCC) }},
{"cont", "Cont", "Location", func(q *qso.QSO) string { return q.Continent }},
{"cqz", "CQ", "Location", func(q *qso.QSO) string { return str(q.CQZ) }},
{"ituz", "ITU", "Location", func(q *qso.QSO) string { return str(q.ITUZ) }},
{"iota", "Iota", "Awards", func(q *qso.QSO) string { return q.IOTA }},
{"sota_ref", "SOTA", "Awards", func(q *qso.QSO) string { return q.SOTARef }},
{"pota_ref", "POTA", "Awards", func(q *qso.QSO) string { return q.POTARef }},
{"age", "Age", "QSO", func(q *qso.QSO) string { return str(q.Age) }},
{"lat", "Lat", "Location", func(q *qso.QSO) string { return flt(q.Lat) }},
{"lon", "Lon", "Location", func(q *qso.QSO) string { return flt(q.Lon) }},
{"rig", "Rig", "QSO", func(q *qso.QSO) string { return q.Rig }},
{"ant", "Ant", "QSO", func(q *qso.QSO) string { return q.Ant }},
{"qsl_sent", "QSL S", "QSL", func(q *qso.QSO) string { return q.QSLSent }},
{"qsl_rcvd", "QSL R", "QSL", func(q *qso.QSO) string { return q.QSLRcvd }},
{"qsl_sent_date", "Qsl sent date", "QSL", func(q *qso.QSO) string { return q.QSLSentDate }},
{"qsl_rcvd_date", "Qsl rcvd date", "QSL", func(q *qso.QSO) string { return q.QSLRcvdDate }},
{"qsl_via", "Qsl via", "QSL", func(q *qso.QSO) string { return q.QSLVia }},
{"qsl_msg", "Qsl msg", "QSL", func(q *qso.QSO) string { return q.QSLMsg }},
{"qslmsg_rcvd", "Qslmsg rcvd", "QSL", func(q *qso.QSO) string { return q.QSLMsgRcvd }},
{"lotw_sent", "LoTW S", "QSL", func(q *qso.QSO) string { return q.LOTWSent }},
{"lotw_rcvd", "LoTW R", "QSL", func(q *qso.QSO) string { return q.LOTWRcvd }},
{"lotw_sent_date", "Lotw sent date", "QSL", func(q *qso.QSO) string { return q.LOTWSentDate }},
{"lotw_rcvd_date", "Lotw rcvd date", "QSL", func(q *qso.QSO) string { return q.LOTWRcvdDate }},
{"eqsl_sent", "eQSL S", "QSL", func(q *qso.QSO) string { return q.EQSLSent }},
{"eqsl_rcvd", "eQSL R", "QSL", func(q *qso.QSO) string { return q.EQSLRcvd }},
{"eqsl_sent_date", "Eqsl sent date", "QSL", func(q *qso.QSO) string { return q.EQSLSentDate }},
{"eqsl_rcvd_date", "Eqsl rcvd date", "QSL", func(q *qso.QSO) string { return q.EQSLRcvdDate }},
{"clublog_qso_upload_date", "Clublog qso upload date", "QSL", func(q *qso.QSO) string { return q.ClublogUploadDate }},
{"clublog_qso_upload_status", "Clublog qso upload status", "QSL", func(q *qso.QSO) string { return q.ClublogUploadStatus }},
{"hrdlog_qso_upload_date", "Hrdlog qso upload date", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadDate }},
{"hrdlog_qso_upload_status", "Hrdlog qso upload status", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadStatus }},
{"qrzcom_qso_upload_date", "Qrzcom qso upload date", "QSL", func(q *qso.QSO) string { return q.QRZComUploadDate }},
{"qrzcom_qso_upload_status", "Qrzcom qso upload status", "QSL", func(q *qso.QSO) string { return q.QRZComUploadStatus }},
{"qrzcom_qso_download_date", "Qrzcom qso download date", "QSL", func(q *qso.QSO) string { return q.QRZComDownloadDate }},
{"qrzcom_qso_download_status", "Qrzcom qso download status", "QSL", func(q *qso.QSO) string { return q.QRZComDownloadStatus }},
{"contest_id", "Contest", "Contest", func(q *qso.QSO) string { return q.ContestID }},
{"srx", "Srx", "Contest", func(q *qso.QSO) string { return str(q.SRX) }},
{"stx", "Stx", "Contest", func(q *qso.QSO) string { return str(q.STX) }},
{"srx_string", "SRX str", "QSO", func(q *qso.QSO) string { return q.SRXString }},
{"stx_string", "STX str", "QSO", func(q *qso.QSO) string { return q.STXString }},
{"check", "Check", "Contest", func(q *qso.QSO) string { return q.Check }},
{"precedence", "Precedence", "Contest", func(q *qso.QSO) string { return q.Precedence }},
{"arrl_sect", "Section", "Contest", func(q *qso.QSO) string { return q.ARRLSect }},
{"prop_mode", "Prop", "QSO", func(q *qso.QSO) string { return q.PropMode }},
{"sat_name", "Sat", "QSO", func(q *qso.QSO) string { return q.SatName }},
{"sat_mode", "Sat mode", "QSO", func(q *qso.QSO) string { return q.SatMode }},
{"ant_az", "Ant az", "QSO", func(q *qso.QSO) string { return flt(q.AntAz) }},
{"ant_el", "Ant el", "QSO", func(q *qso.QSO) string { return flt(q.AntEl) }},
{"ant_path", "Ant path", "QSO", func(q *qso.QSO) string { return q.AntPath }},
{"station_callsign", "Station", "QSO", func(q *qso.QSO) string { return q.StationCallsign }},
{"operator", "Operator", "QSO", func(q *qso.QSO) string { return q.Operator }},
{"my_grid", "My grid", "My station", func(q *qso.QSO) string { return q.MyGrid }},
{"my_gridsquare_ext", "My gridsquare ext", "My station", func(q *qso.QSO) string { return q.MyGridExt }},
{"my_country", "My country", "My station", func(q *qso.QSO) string { return q.MyCountry }},
{"my_state", "My state", "My station", func(q *qso.QSO) string { return q.MyState }},
{"my_cnty", "My cnty", "My station", func(q *qso.QSO) string { return q.MyCounty }},
{"my_iota", "My iota", "My station", func(q *qso.QSO) string { return q.MyIOTA }},
{"my_sota_ref", "My sota ref", "My station", func(q *qso.QSO) string { return q.MySOTARef }},
{"my_pota_ref", "My pota ref", "My station", func(q *qso.QSO) string { return q.MyPOTARef }},
{"my_dxcc", "My dxcc", "My station", func(q *qso.QSO) string { return str(q.MyDXCC) }},
{"my_cq_zone", "My cq zone", "My station", func(q *qso.QSO) string { return str(q.MyCQZone) }},
{"my_itu_zone", "My itu zone", "My station", func(q *qso.QSO) string { return str(q.MyITUZone) }},
{"my_lat", "My lat", "My station", func(q *qso.QSO) string { return flt(q.MyLat) }},
{"my_lon", "My lon", "My station", func(q *qso.QSO) string { return flt(q.MyLon) }},
{"my_street", "My street", "My station", func(q *qso.QSO) string { return q.MyStreet }},
{"my_city", "My city", "My station", func(q *qso.QSO) string { return q.MyCity }},
{"my_postal_code", "My postal code", "My station", func(q *qso.QSO) string { return q.MyPostalCode }},
{"my_rig", "My rig", "My station", func(q *qso.QSO) string { return q.MyRig }},
{"my_antenna", "My antenna", "My station", func(q *qso.QSO) string { return q.MyAntenna }},
{"tx_pwr", "TX pwr", "QSO", func(q *qso.QSO) string { return flt(q.TXPower) }},
{"comment", "Comment", "QSO", func(q *qso.QSO) string { return q.Comment }},
{"notes", "Notes", "QSO", func(q *qso.QSO) string { return q.Notes }},
{"sig", "Sig", "Awards", func(q *qso.QSO) string { return q.SIG }},
{"sig_info", "Sig info", "Awards", func(q *qso.QSO) string { return q.SIGInfo }},
{"my_sig", "My sig", "My station", func(q *qso.QSO) string { return q.MySIG }},
{"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) }},
{"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) }},
{"sfi", "SFI", "QSO", func(q *qso.QSO) string { return flt(q.SFI) }},
{"skcc", "Skcc", "Awards", func(q *qso.QSO) string { return q.SKCC }},
{"fists", "Fists", "Awards", func(q *qso.QSO) string { return q.FISTS }},
{"ten_ten", "10-10", "Awards", func(q *qso.QSO) string { return q.TenTen }},
{"contacted_op", "Op worked", "QSO", func(q *qso.QSO) string { return q.ContactedOp }},
{"eq_call", "Eq call", "QSO", func(q *qso.QSO) string { return q.EqCall }},
{"pfx", "Pfx", "Location", func(q *qso.QSO) string { return q.PFX }},
{"my_name", "My name", "My station", func(q *qso.QSO) string { return q.MyName }},
{"class", "Class", "Contest", func(q *qso.QSO) string { return q.Class }},
{"darc_dok", "DOK", "Awards", func(q *qso.QSO) string { return q.DarcDOK }},
{"my_darc_dok", "My darc dok", "My station", func(q *qso.QSO) string { return q.MyDarcDOK }},
{"region", "Region", "Location", func(q *qso.QSO) string { return q.Region }},
{"silent_key", "SK", "QSO", func(q *qso.QSO) string { return q.SilentKey }},
{"swl", "Swl", "QSO", func(q *qso.QSO) string { return q.SWL }},
{"qso_complete", "Complete", "QSO", func(q *qso.QSO) string { return q.QSOComplete }},
{"qso_random", "Random", "QSO", func(q *qso.QSO) string { return q.QSORandom }},
{"credit_granted", "Credit granted", "QSL", func(q *qso.QSO) string { return q.CreditGranted }},
{"credit_submitted", "Credit sub.", "QSL", func(q *qso.QSO) string { return q.CreditSubmitted }},
{"my_arrl_sect", "My arrl sect", "My station", func(q *qso.QSO) string { return q.MyARRLSect }},
{"my_vucc_grids", "My vucc grids", "My station", func(q *qso.QSO) string { return q.MyVUCCGrids }},
{"award_refs", "Award refs", "Awards", func(q *qso.QSO) string { return q.AwardRefs }},
}
// DefaultColumns is what a fresh configuration publishes — the columns a reader
// of someone else's log actually looks for.
var DefaultColumns = []string{"date", "time", "callsign", "band", "mode", "rst_sent", "rst_rcvd", "country"}
// legacyKeys maps the short names the hand-written table used onto the ADIF
// names the generated one uses. A configuration saved before this change still
// publishes the same columns; without it, three of them would vanish silently on
// upgrade, which is the worst way for a setting to change.
var legacyKeys = map[string]string{
"pota": "pota_ref",
"sota": "sota_ref",
"station": "station_callsign",
}
func columnsFor(keys []string) []Column {
if len(keys) == 0 {
keys = DefaultColumns
@@ -117,7 +269,11 @@ func columnsFor(keys []string) []Column {
}
out := make([]Column, 0, len(keys))
for _, k := range keys {
if c, ok := byKey[strings.TrimSpace(k)]; ok {
k = strings.TrimSpace(k)
if alias, ok := legacyKeys[k]; ok {
k = alias
}
if c, ok := byKey[k]; ok {
out = append(out, c)
}
}