diff --git a/app_secret.go b/app_secret.go index f303728..c351fdc 100644 --- a/app_secret.go +++ b/app_secret.go @@ -27,6 +27,11 @@ var sensitiveSettingKeys = map[string]bool{ keyExtHRDLogCode: true, keyExtEQSLPassword: true, keyExtCloudlogAPIKey: true, + // The web-publish config is one JSON blob and the FTP password lives inside + // it, so the whole blob is encrypted. That costs nothing — it is read and + // written as a unit anyway — and beats storing a server password in clear + // next to the rest. + keyWebPublish: true, } func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] } diff --git a/frontend/src/components/WebPublishPanel.tsx b/frontend/src/components/WebPublishPanel.tsx new file mode 100644 index 0000000..395b17d --- /dev/null +++ b/frontend/src/components/WebPublishPanel.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from 'react'; +import { Upload, FolderOpen, Loader2 } from 'lucide-react'; +import { + GetWebPublishConfig, SaveWebPublishConfig, WebPublishColumns, + TestWebPublishFTP, PublishLogNow, GetWebPublishStatus, PickBackupFolder, +} from '../../wailsjs/go/main/App'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; +import { useI18n } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; + +type Cfg = { + enabled: boolean; format: string; folder: string; file_name: string; title: string; + count: number; interval_min: number; columns: string[]; + ftp_enabled: boolean; ftp_host: string; ftp_port: number; ftp_user: string; + ftp_password: string; ftp_tls: boolean; ftp_folder: string; ftp_file_name: string; +}; +type Col = { key: string; header: string }; + +export function WebPublishPanel() { + const { t } = useI18n(); + const [cfg, setCfg] = useState(null); + const [cols, setCols] = useState([]); + const [busy, setBusy] = useState<'' | 'test' | 'publish'>(''); + const [msg, setMsg] = useState(''); + const [err, setErr] = useState(''); + // Raw text for the two numeric boxes: bound straight to the number they could + // not be cleared, the same trap as the Recent-QSOs Max field. + const [countText, setCountText] = useState(''); + const [everyText, setEveryText] = useState(''); + + useEffect(() => { + (async () => { + try { + const [c, k, st] = await Promise.all([GetWebPublishConfig(), WebPublishColumns(), GetWebPublishStatus()]); + setCfg(c as any); + setCols((k ?? []) as Col[]); + setCountText(String((c as any).count ?? 100)); + setEveryText(String((c as any).interval_min ?? 0)); + const s: any = st; + if (s?.last_err) setErr(s.last_err); + else if (s?.last_run) setMsg(t('wpub.lastRun') + ' ' + s.last_run); + } catch (e: any) { setErr(String(e?.message ?? e)); } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!cfg) return

; + const set = (p: Partial) => setCfg((c) => (c ? { ...c, ...p } : c)); + + // Every control saves immediately: this panel has no Save button of its own, + // matching the other "saved instantly" panels. + const save = async (next: Cfg) => { + setCfg(next); + try { await SaveWebPublishConfig(next as any); setErr(''); } + catch (e: any) { setErr(String(e?.message ?? e)); } + }; + const patch = (p: Partial) => save({ ...cfg, ...p }); + + const toggleCol = (key: string) => { + const has = cfg.columns?.includes(key); + patch({ columns: has ? cfg.columns.filter((c) => c !== key) : [...(cfg.columns ?? []), key] }); + }; + + const run = async (what: 'test' | 'publish') => { + setBusy(what); setMsg(''); setErr(''); + try { + const r = what === 'test' ? await TestWebPublishFTP(cfg as any) : await PublishLogNow(); + setMsg(String(r)); + } catch (e: any) { setErr(String(e?.message ?? e)); } + finally { setBusy(''); } + }; + + return ( +
+

{t('wpub.hint')}

+ + + + {cfg.enabled && (<> + {/* ── The file ── */} +
+ +
+ + + + +
+ set({ folder: e.target.value })} onBlur={() => patch({ folder: cfg.folder })} /> + +
+ + + set({ file_name: e.target.value })} onBlur={() => patch({ file_name: cfg.file_name })} /> + + + set({ title: e.target.value })} onBlur={() => patch({ title: cfg.title })} /> + + + setCountText(e.target.value)} + onBlur={() => { + const n = Math.floor(Number(countText)); + if (Number.isFinite(n) && n > 0) patch({ count: n }); else setCountText(String(cfg.count)); + }} /> + + +
+ setEveryText(e.target.value)} + onBlur={() => { + const n = Math.floor(Number(everyText)); + if (Number.isFinite(n) && n >= 0) patch({ interval_min: n }); else setEveryText(String(cfg.interval_min)); + }} /> + {t('wpub.everyHint')} +
+
+
+ + {/* ── Columns ── */} +
+ +
+ {cols.map((c) => { + const on = cfg.columns?.includes(c.key); + return ( + + ); + })} +
+

{t('wpub.columnsHint')}

+
+ + {/* ── Upload ── */} +
+ + {cfg.ftp_enabled && ( +
+ +
+ set({ ftp_host: e.target.value })} onBlur={() => patch({ ftp_host: cfg.ftp_host })} /> + set({ ftp_port: Number(e.target.value) || 21 })} + onBlur={() => patch({ ftp_port: cfg.ftp_port })} /> +
+ + set({ ftp_user: e.target.value })} onBlur={() => patch({ ftp_user: cfg.ftp_user })} /> + + set({ ftp_password: e.target.value })} onBlur={() => patch({ ftp_password: cfg.ftp_password })} /> + + set({ ftp_folder: e.target.value })} onBlur={() => patch({ ftp_folder: cfg.ftp_folder })} /> + + set({ ftp_file_name: e.target.value })} onBlur={() => patch({ ftp_file_name: cfg.ftp_file_name })} /> + + +
+ )} +
+ +
+ + {cfg.ftp_enabled && ( + + )} +
+ )} + + {msg &&

{msg}

} + {err &&

{err}

} +
+ ); +} diff --git a/go.mod b/go.mod index b5c8bff..273e0aa 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-ole/go-ole v1.3.0 github.com/go-sql-driver/mysql v1.10.0 github.com/gorilla/websocket v1.5.3 + github.com/jlaffaye/ftp v0.2.2 github.com/moutend/go-wca v0.3.0 github.com/wailsapp/wails/v2 v2.11.0 github.com/wneessen/go-mail v0.7.3 diff --git a/go.sum b/go.sum index dbd796b..d98aaf6 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/jlaffaye/ftp v0.2.2 h1:JwjrXCAIjN9ZYrF1/8qlmHFXDteh9MHYaiEIh/Oqtd8= +github.com/jlaffaye/ftp v0.2.2/go.mod h1:zuLAKdqFqFvNgkCrH0SC7K1XyUiydS7BFCmmoHUWWg0= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -64,8 +66,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/internal/webpub/webpub.go b/internal/webpub/webpub.go new file mode 100644 index 0000000..ee32777 --- /dev/null +++ b/internal/webpub/webpub.go @@ -0,0 +1,377 @@ +// 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(` + + + + +` + esc(title) + ` + + +
+

` + esc(title) + `

+

` + strconv.Itoa(len(qsos)) + ` QSO · ` + time.Now().UTC().Format("2006-01-02 15:04") + ` UTC

+
`) + for _, c := range cols { + b.WriteString(``) + } + b.WriteString(``) + for i := range qsos { + b.WriteString(``) + for _, c := range cols { + cls := "" + if c.Key == "callsign" { + cls = ` class="call"` + } + b.WriteString(`` + esc(c.Value(&qsos[i])) + ``) + } + b.WriteString(``) + } + b.WriteString(`
` + esc(c.Header) + `
+

Generated by OpsLog

+
+ + +`) + 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 +} diff --git a/webpublish.go b/webpublish.go new file mode 100644 index 0000000..53cab8b --- /dev/null +++ b/webpublish.go @@ -0,0 +1,229 @@ +package main + +// Web publishing — the Wails boundary for internal/webpub, plus the scheduling. +// +// Two triggers, deliberately: a QSO is logged, or the periodic timer fires. +// Both go through publishSoon, which DEBOUNCES: a run of contacts must not +// produce one FTP session per QSO, and a page that is fifteen seconds stale is +// indistinguishable from a live one to anybody reading it on the web. + +import ( + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "hamlog/internal/applog" + "hamlog/internal/qso" + "hamlog/internal/webpub" + + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// publishDebounce is how long a publish request waits for company. Long enough +// to fold a burst of logging into one upload, short enough that the page looks +// live to a reader who just heard you on the air. +const publishDebounce = 15 * time.Second + +type webPublisher struct { + mu sync.Mutex + timer *time.Timer + ticker *time.Ticker + tickStp chan struct{} + last time.Time + lastErr string +} + +// WebPublishStatus is what the settings panel shows under the buttons. +// +// No column list here: the panel gets that from WebPublishColumns(). An +// anonymous struct in a bound type also breaks the Wails generator, which has +// no name to emit for it. +type WebPublishStatus struct { + LastRun string `json:"last_run"` // "" = never this session + LastErr string `json:"last_err"` +} + +// GetWebPublishConfig reads the stored configuration (defaults applied). +func (a *App) GetWebPublishConfig() (webpub.Config, error) { + var cfg webpub.Config + if a.settings == nil { + cfg.Normalise() + return cfg, fmt.Errorf("db not initialized") + } + raw, err := a.settings.Get(a.ctx, keyWebPublish) + if err == nil && strings.TrimSpace(raw) != "" { + // A locked vault hands back "" rather than ciphertext — that reads as "not + // configured", which is exactly right here: publishing must not run with a + // password we cannot decrypt. + _ = json.Unmarshal([]byte(raw), &cfg) + } + cfg.Normalise() + return cfg, nil +} + +// SaveWebPublishConfig persists it and restarts the periodic timer. +func (a *App) SaveWebPublishConfig(cfg webpub.Config) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + cfg.Normalise() + b, err := json.Marshal(cfg) + if err != nil { + return err + } + if err := a.settings.Set(a.ctx, keyWebPublish, string(b)); err != nil { + return err + } + a.restartWebPublishTimer() + return nil +} + +// WebPublishColumns lists the offered columns for the picker. +func (a *App) WebPublishColumns() []map[string]string { + cols := webpub.KnownColumnKeys() + out := make([]map[string]string, 0, len(cols)) + for _, c := range cols { + out = append(out, map[string]string{"key": c.Key, "header": c.Header}) + } + return out +} + +// TestWebPublishFTP validates the server settings without uploading anything. +func (a *App) TestWebPublishFTP(cfg webpub.Config) (string, error) { + return webpub.Test(cfg) +} + +// PublishLogNow renders and publishes immediately, ignoring the debounce, and +// reports what happened. This is the "Publish now" button: the operator is +// waiting on the answer, so it runs synchronously and returns the real error. +func (a *App) PublishLogNow() (string, error) { + cfg, _ := a.GetWebPublishConfig() + return a.publish(cfg) +} + +// publish does the work: read the QSOs, render, write locally, then upload. +// +// Local FIRST and upload second, always. A network failure then leaves a good +// file on disk that the operator can publish another way, instead of a +// truncated one on the server. +func (a *App) publish(cfg webpub.Config) (string, error) { + if a.qso == nil { + return "", fmt.Errorf("logbook not ready") + } + cfg.Normalise() + qsos, err := a.qso.List(a.ctx, qso.ListFilter{Limit: cfg.Count}) + if err != nil { + return "", fmt.Errorf("read the log: %w", err) + } + station := "" + if p, perr := a.profiles.Active(a.ctx); perr == nil { + station = p.Callsign + } + data, err := webpub.Render(cfg, qsos, station) + if err != nil { + return "", fmt.Errorf("build the page: %w", err) + } + path, err := webpub.WriteLocal(cfg, data) + if err != nil { + return "", err + } + msg := fmt.Sprintf("%d QSO → %s", len(qsos), path) + if cfg.FTPEnabled { + if err := webpub.Upload(cfg, data); err != nil { + // The local file IS written — say so, so the operator knows the failure + // is the transfer and not the export. + return msg, fmt.Errorf("written locally, but the upload failed: %w", err) + } + msg += fmt.Sprintf(" → ftp://%s/%s", cfg.FTPHost, strings.TrimPrefix(cfg.FTPFolder+"/"+cfg.FTPFileName, "/")) + } + return msg, nil +} + +// publishSoon schedules a debounced publish. Called on every logged QSO. +func (a *App) publishSoon() { + cfg, _ := a.GetWebPublishConfig() + if !cfg.Enabled { + return + } + a.webpub.mu.Lock() + defer a.webpub.mu.Unlock() + if a.webpub.timer != nil { + a.webpub.timer.Stop() + } + a.webpub.timer = time.AfterFunc(publishDebounce, a.publishNowBackground) +} + +// publishNowBackground runs a scheduled publish and records the outcome for the +// settings panel. Never surfaces a dialog: this fires while the operator is +// working, and a web server that is down must not interrupt logging. +func (a *App) publishNowBackground() { + cfg, _ := a.GetWebPublishConfig() + if !cfg.Enabled { + return + } + msg, err := a.publish(cfg) + a.webpub.mu.Lock() + a.webpub.last = time.Now() + if err != nil { + a.webpub.lastErr = err.Error() + } else { + a.webpub.lastErr = "" + } + a.webpub.mu.Unlock() + if err != nil { + applog.Printf("webpublish: %v", err) + } else { + applog.Printf("webpublish: %s", msg) + } + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "webpublish:done", msg) + } +} + +// GetWebPublishStatus reports the last run for the settings panel. +func (a *App) GetWebPublishStatus() WebPublishStatus { + var st WebPublishStatus + a.webpub.mu.Lock() + if !a.webpub.last.IsZero() { + st.LastRun = a.webpub.last.UTC().Format("2006-01-02 15:04:05") + " UTC" + } + st.LastErr = a.webpub.lastErr + a.webpub.mu.Unlock() + return st +} + +// restartWebPublishTimer (re)arms the periodic refresh from the saved interval. +// Stopped and rebuilt on every save, so a changed interval takes effect at once +// rather than after the old one has fired. +func (a *App) restartWebPublishTimer() { + a.webpub.mu.Lock() + if a.webpub.tickStp != nil { + close(a.webpub.tickStp) + a.webpub.tickStp = nil + } + a.webpub.mu.Unlock() + + cfg, _ := a.GetWebPublishConfig() + if !cfg.Enabled || cfg.IntervalMin <= 0 { + return + } + stop := make(chan struct{}) + a.webpub.mu.Lock() + a.webpub.tickStp = stop + a.webpub.mu.Unlock() + + go func(every time.Duration) { + t := time.NewTicker(every) + defer t.Stop() + for { + select { + case <-stop: + return + case <-t.C: + a.publishNowBackground() + } + } + }(time.Duration(cfg.IntervalMin) * time.Minute) +}