parseFloat accepts a numeric PREFIX. "2026-08-10" therefore became the number 2026, every date in the same year compared equal, and since the sort is stable nothing moved: the Date column looked as though it were simply not sortable. The same trap caught every callsign starting with a digit - 8B81SU and 8P9AB both read as 8 - and the times, where "09:56" became 9. The numeric test is now anchored to the whole cell, so anything that is not entirely a number is compared as text, which is exactly right for an ISO date. Sorting had no way back either. Each row now carries the index it was published at, and a third click on a header restores that order. Headers also show an arrow: with no indicator, a column that silently refused to sort was indistinguishable from one that had sorted into the same order. Blank cells sink in both directions rather than leading the ascending sort. An empty field is missing data, not the smallest value. Tested where it can be: the page ships its own script, so a regression is silent - the table still renders, it just sorts wrongly.
398 lines
14 KiB
Go
398 lines
14 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.
|
|
// - 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.
|
|
package webpub
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"html"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jlaffaye/ftp"
|
|
|
|
"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
|
|
Value func(q *qso.QSO) string
|
|
}
|
|
|
|
func str(p *int) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
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.
|
|
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 {
|
|
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 }},
|
|
}
|
|
|
|
// 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"}
|
|
|
|
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 {
|
|
if c, ok := byKey[strings.TrimSpace(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}
|
|
.wrap{max-width:1100px;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}
|
|
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
|
|
}
|