Two new QSO columns, clublog_qso_download_status/date — a Club Log MATCH, the service's own confirmation (both stations uploaded the QSO, paired within 15 minutes). Full promoted-column lockstep: migration 0031, repo insert/scan, ADIF dictionary + import + export (app-defined CLUBLOG_QSO_DOWNLOAD_*), table columns, filter builder, bulk edit and the QSO editor's Club Log row. The QSL Manager's Club Log entry now actually downloads: getmatches.php with the existing account settings and the embedded application key, incremental via the match-completion date filter, matched call+band+mode ±15 min with a mode-blind fallback because Club Log reports 'false' for modes it cannot infer. Matches always exist on both sides, so unmatched ones are listed rather than skeleton-added. And Super Check Partial can merge Club Log's weekly SCP list (~180k calls worked on the air in the last 3 years) with MASTER.SCP — an opt-in checkbox under the SCP setting.
591 lines
27 KiB
Go
591 lines
27 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 }},
|
|
{"clublog_qso_download_date", "Clublog match date", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadDate }},
|
|
{"clublog_qso_download_status", "Clublog match status", "QSL", func(q *qso.QSO) string { return q.ClublogDownloadStatus }},
|
|
{"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
|
|
}
|