The page was capped at 1100px — a comfortable reading width, and the right choice for the eight default columns. Now that all 123 fields can be published, anything past about eight sat behind a horizontal scrollbar on a screen wide enough to show the lot. Worse, Windows hides overlay scrollbars until something moves, so a table that scrolled looked exactly like a table with its right-hand columns missing. That is what was reported, and the report was reasonable: nothing on screen said otherwise. The wrapper grows with its content now, up to the window, and keeps a minimum so a two-column table does not collapse on a large display. The table sizes to what its cells need rather than being squeezed to the container first — that squeeze could wrap a callsign while empty space sat further along the same row. And the scrollbar is drawn permanently, thin and in the page's own colours.
589 lines
26 KiB
Go
589 lines
26 KiB
Go
// Package webpub publishes the log as a file an operator can put on a website:
|
|
// a self-contained HTML page or a CSV, written locally and optionally uploaded
|
|
// by FTP/FTPS.
|
|
//
|
|
// Design notes that matter:
|
|
//
|
|
// - The local file is ALWAYS written first and the upload layered on top. A
|
|
// network failure then leaves a good file on disk rather than a truncated
|
|
// one on the server, and the operator can publish it by any other means.
|
|
// - 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.
|
|
// - 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 (
|
|
"crypto/tls"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"html"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jlaffaye/ftp"
|
|
|
|
"hamlog/internal/geo"
|
|
"hamlog/internal/qso"
|
|
)
|
|
|
|
// Config is the whole feature's configuration. Stored per profile.
|
|
type Config struct {
|
|
Enabled bool `json:"enabled"`
|
|
Format string `json:"format"` // "html" | "csv"
|
|
Folder string `json:"folder"` // local output folder
|
|
FileName string `json:"file_name"` // e.g. "log.html"
|
|
Title string `json:"title"` // page heading; blank → callsign
|
|
Count int `json:"count"` // publish the last N QSOs
|
|
// IntervalMin is the periodic refresh in minutes. 0 = only republish when a
|
|
// QSO is logged. Every publish is debounced regardless (see Publisher).
|
|
IntervalMin int `json:"interval_min"`
|
|
Columns []string `json:"columns"`
|
|
|
|
FTPEnabled bool `json:"ftp_enabled"`
|
|
FTPHost string `json:"ftp_host"`
|
|
FTPPort int `json:"ftp_port"`
|
|
FTPUser string `json:"ftp_user"`
|
|
FTPPassword string `json:"ftp_password"`
|
|
FTPTLS bool `json:"ftp_tls"` // explicit AUTH TLS (FTPS)
|
|
FTPFolder string `json:"ftp_folder"`
|
|
FTPFileName string `json:"ftp_file_name"`
|
|
}
|
|
|
|
// Column is one publishable field: a stable key, the header printed in the
|
|
// file, and how to read it off a QSO.
|
|
type Column struct {
|
|
Key string
|
|
Header 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 {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return strconv.Itoa(*p)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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 ""
|
|
}
|
|
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 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)
|
|
}},
|
|
{"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", 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) }},
|
|
{"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
|
|
}
|
|
byKey := make(map[string]Column, len(Columns))
|
|
for _, c := range Columns {
|
|
byKey[c.Key] = c
|
|
}
|
|
out := make([]Column, 0, len(keys))
|
|
for _, k := range keys {
|
|
k = strings.TrimSpace(k)
|
|
if alias, ok := legacyKeys[k]; ok {
|
|
k = alias
|
|
}
|
|
if c, ok := byKey[k]; ok {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
if len(out) == 0 { // every stored key unknown (config from a newer build)
|
|
return columnsFor(DefaultColumns)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// KnownColumnKeys lists the offered columns, for the settings UI.
|
|
func KnownColumnKeys() []Column {
|
|
out := make([]Column, len(Columns))
|
|
copy(out, Columns)
|
|
sort.SliceStable(out, func(i, j int) bool { return false }) // keep declared order
|
|
return out
|
|
}
|
|
|
|
// Normalise fills in the defaults a half-filled config would otherwise carry
|
|
// into the renderer.
|
|
func (c *Config) Normalise() {
|
|
if c.Format != "csv" {
|
|
c.Format = "html"
|
|
}
|
|
if strings.TrimSpace(c.FileName) == "" {
|
|
if c.Format == "csv" {
|
|
c.FileName = "log.csv"
|
|
} else {
|
|
c.FileName = "log.html"
|
|
}
|
|
}
|
|
if c.Count <= 0 {
|
|
c.Count = 100
|
|
}
|
|
if c.FTPPort <= 0 {
|
|
c.FTPPort = 21
|
|
}
|
|
if len(c.Columns) == 0 {
|
|
c.Columns = append([]string{}, DefaultColumns...)
|
|
}
|
|
if strings.TrimSpace(c.FTPFileName) == "" {
|
|
c.FTPFileName = c.FileName
|
|
}
|
|
}
|
|
|
|
// Render builds the file contents for the given QSOs.
|
|
func Render(cfg Config, qsos []qso.QSO, stationCall string) ([]byte, error) {
|
|
cfg.Normalise()
|
|
cols := columnsFor(cfg.Columns)
|
|
if cfg.Format == "csv" {
|
|
return renderCSV(cols, qsos)
|
|
}
|
|
return renderHTML(cfg, cols, qsos, stationCall), nil
|
|
}
|
|
|
|
func renderCSV(cols []Column, qsos []qso.QSO) ([]byte, error) {
|
|
var b strings.Builder
|
|
w := csv.NewWriter(&b)
|
|
head := make([]string, len(cols))
|
|
for i, c := range cols {
|
|
head[i] = c.Header
|
|
}
|
|
if err := w.Write(head); err != nil {
|
|
return nil, err
|
|
}
|
|
row := make([]string, len(cols))
|
|
for i := range qsos {
|
|
for j, c := range cols {
|
|
row[j] = c.Value(&qsos[i])
|
|
}
|
|
if err := w.Write(row); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
w.Flush()
|
|
if err := w.Error(); err != nil {
|
|
return nil, err
|
|
}
|
|
return []byte(b.String()), nil
|
|
}
|
|
|
|
// renderHTML writes a standalone page: inline CSS, inline sort script, no
|
|
// external request of any kind.
|
|
func renderHTML(cfg Config, cols []Column, qsos []qso.QSO, stationCall string) []byte {
|
|
title := strings.TrimSpace(cfg.Title)
|
|
if title == "" {
|
|
if stationCall != "" {
|
|
title = stationCall + " — log"
|
|
} else {
|
|
title = "Log"
|
|
}
|
|
}
|
|
esc := html.EscapeString
|
|
|
|
var b strings.Builder
|
|
b.WriteString(`<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>` + esc(title) + `</title>
|
|
<style>
|
|
:root{color-scheme:light dark;--bg:#fff;--fg:#16181d;--mut:#5b6270;--line:#e2e5ea;--head:#f4f6f8;--zebra:#fafbfc;--accent:#2a78d6}
|
|
@media (prefers-color-scheme:dark){:root{--bg:#16181d;--fg:#e6e8ec;--mut:#9aa2b1;--line:#2e343f;--head:#1f232b;--zebra:#1b1f26;--accent:#6da7ec}}
|
|
*{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}
|
|
/* 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}
|
|
/* 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}
|
|
th:hover{color:var(--fg)}
|
|
th::after{content:'';font-size:.7em;opacity:.7}
|
|
th[data-asc="1"]::after{content:' \25B2'}
|
|
th[data-asc="0"]::after{content:' \25BC'}
|
|
tbody tr:nth-child(even){background:var(--zebra)}
|
|
tbody tr:last-child td{border-bottom:0}
|
|
td.call{font-family:ui-monospace,Consolas,monospace;font-weight:700;color:var(--accent)}
|
|
.foot{margin-top:.75rem;color:var(--mut);font-size:.75rem}
|
|
</style>
|
|
</head>
|
|
<body><div class="wrap">
|
|
<h1>` + esc(title) + `</h1>
|
|
<p class="meta">` + strconv.Itoa(len(qsos)) + ` QSO · ` + time.Now().UTC().Format("2006-01-02 15:04") + ` UTC</p>
|
|
<div class="scroll"><table><thead><tr>`)
|
|
for _, c := range cols {
|
|
b.WriteString(`<th>` + esc(c.Header) + `</th>`)
|
|
}
|
|
b.WriteString(`</tr></thead><tbody>`)
|
|
for i := range qsos {
|
|
// The row's position as published. Sorting is a view on top of it, so a
|
|
// third click can put the table back the way the operator first saw it.
|
|
b.WriteString(`<tr data-i="` + strconv.Itoa(i) + `">`)
|
|
for _, c := range cols {
|
|
cls := ""
|
|
if c.Key == "callsign" {
|
|
cls = ` class="call"`
|
|
}
|
|
b.WriteString(`<td` + cls + `>` + esc(c.Value(&qsos[i])) + `</td>`)
|
|
}
|
|
b.WriteString(`</tr>`)
|
|
}
|
|
b.WriteString(`</tbody></table></div>
|
|
<p class="foot">Generated by OpsLog</p>
|
|
</div>
|
|
<script>
|
|
// Click a header to sort: ascending, descending, then back to the published
|
|
// order. Kept tiny and dependency-free — the page has to work offline and on any
|
|
// hosting, so no library is loaded.
|
|
//
|
|
// NUM is deliberately strict: the whole cell must be a number. parseFloat alone
|
|
// accepts a numeric PREFIX, so "2026-08-10" became 2026 and every date in a year
|
|
// compared equal — the date column looked as though it simply would not sort.
|
|
// Callsigns starting with a digit (8B81SU) hit the same trap.
|
|
var NUM=/^[+-]?\d+(\.\d+)?$/;
|
|
document.querySelectorAll('th').forEach(function(th,i){
|
|
th.addEventListener('click',function(){
|
|
var tb=th.closest('table').tBodies[0],
|
|
rows=Array.prototype.slice.call(tb.rows),
|
|
cur=th.dataset.asc,
|
|
next=cur===undefined?'1':(cur==='1'?'0':'');
|
|
if(next===''){
|
|
// Third click: restore the order the page was published in.
|
|
rows.sort(function(a,b){return a.dataset.i-b.dataset.i});
|
|
}else{
|
|
var asc=next==='1';
|
|
rows.sort(function(a,b){
|
|
var x=a.cells[i].textContent.trim(), y=b.cells[i].textContent.trim();
|
|
// Blanks always sink, whichever way the column is pointing: an empty
|
|
// cell is missing data, not the smallest value.
|
|
if(x===''||y==='') return x===y?0:(x===''?1:-1);
|
|
var c=NUM.test(x)&&NUM.test(y)?(parseFloat(x)-parseFloat(y)):x.localeCompare(y);
|
|
return asc?c:-c;
|
|
});
|
|
}
|
|
rows.forEach(function(r){tb.appendChild(r)});
|
|
th.closest('tr').querySelectorAll('th').forEach(function(o){delete o.dataset.asc});
|
|
if(next!=='') th.dataset.asc=next;
|
|
});
|
|
});
|
|
</script>
|
|
</body></html>
|
|
`)
|
|
return []byte(b.String())
|
|
}
|
|
|
|
// WriteLocal writes the payload into the configured folder and returns the path.
|
|
func WriteLocal(cfg Config, data []byte) (string, error) {
|
|
cfg.Normalise()
|
|
dir := strings.TrimSpace(cfg.Folder)
|
|
if dir == "" {
|
|
return "", fmt.Errorf("no output folder set")
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "", fmt.Errorf("create %s: %w", dir, err)
|
|
}
|
|
path := filepath.Join(dir, cfg.FileName)
|
|
// Write to a temp file and rename over the target: a reader (or a syncing
|
|
// client) never sees a half-written page.
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
|
return "", fmt.Errorf("write %s: %w", tmp, err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return "", fmt.Errorf("replace %s: %w", path, err)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// Upload sends the payload to the configured FTP/FTPS server.
|
|
func Upload(cfg Config, data []byte) error {
|
|
cfg.Normalise()
|
|
host := strings.TrimSpace(cfg.FTPHost)
|
|
if host == "" {
|
|
return fmt.Errorf("no FTP server set")
|
|
}
|
|
c, err := dial(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = c.Quit() }()
|
|
|
|
if err := c.Login(cfg.FTPUser, cfg.FTPPassword); err != nil {
|
|
return fmt.Errorf("login as %q: %w", cfg.FTPUser, err)
|
|
}
|
|
if dir := strings.TrimSpace(cfg.FTPFolder); dir != "" {
|
|
if err := c.ChangeDir(dir); err != nil {
|
|
return fmt.Errorf("enter remote folder %q: %w", dir, err)
|
|
}
|
|
}
|
|
if err := c.Stor(cfg.FTPFileName, strings.NewReader(string(data))); err != nil {
|
|
return fmt.Errorf("upload %q: %w", cfg.FTPFileName, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func dial(cfg Config) (*ftp.ServerConn, error) {
|
|
addr := fmt.Sprintf("%s:%d", strings.TrimSpace(cfg.FTPHost), cfg.FTPPort)
|
|
opts := []ftp.DialOption{ftp.DialWithTimeout(20 * time.Second)}
|
|
if cfg.FTPTLS {
|
|
// Explicit FTPS (AUTH TLS), the form virtually every web host offers.
|
|
// InsecureSkipVerify is NOT set: a certificate that does not validate is
|
|
// a real warning, and silently accepting it would defeat the point of
|
|
// ticking the TLS box in the first place.
|
|
opts = append(opts, ftp.DialWithExplicitTLS(&tls.Config{ServerName: strings.TrimSpace(cfg.FTPHost)}))
|
|
}
|
|
c, err := ftp.Dial(addr, opts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("connect to %s: %w", addr, err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// Test connects, logs in and enters the remote folder without uploading — the
|
|
// "Test connection" button. Returns a short human-readable success line.
|
|
func Test(cfg Config) (string, error) {
|
|
cfg.Normalise()
|
|
c, err := dial(cfg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = c.Quit() }()
|
|
if err := c.Login(cfg.FTPUser, cfg.FTPPassword); err != nil {
|
|
return "", fmt.Errorf("login as %q: %w", cfg.FTPUser, err)
|
|
}
|
|
if dir := strings.TrimSpace(cfg.FTPFolder); dir != "" {
|
|
if err := c.ChangeDir(dir); err != nil {
|
|
return "", fmt.Errorf("enter remote folder %q: %w", dir, err)
|
|
}
|
|
}
|
|
cwd, _ := c.CurrentDir()
|
|
return "connected — remote folder " + cwd, nil
|
|
}
|