Settings -> Web publishing. Renders the last N QSOs with the columns the operator picks, writes the file locally, and optionally uploads it by FTP or explicit FTPS. Local write FIRST, upload second, always. A network failure then leaves a good file on disk that can be published another way, instead of a truncated one on the server. The local write itself goes to a temp file and renames over the target, so a reader — or a syncing client — never sees a half-written page. The HTML page is fully self-contained: inline CSS, inline sort script, no font, no CDN, no external request at all. It has to work on hosting that blocks third-party requests, and a page about someone's hobby should not report its readers to anyone. Columns are a curated set, not "every ADIF field". This is published to the public: RST and QSL status belong on it, the operator's home address does not. Two triggers, both debounced through one path: a QSO is logged, or the optional timer fires. Fifteen seconds of coalescing means a run of contacts produces one upload rather than one per QSO, and nobody reading a web page can tell the difference. The config is one JSON blob under a single settings key, and that key is marked sensitive: the FTP password lives inside it, so the whole blob is encrypted at rest with the others. A locked vault reads back empty, which correctly reads as "not configured" — publishing must not run with a password it cannot decrypt.
230 lines
6.6 KiB
Go
230 lines
6.6 KiB
Go
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)
|
|
}
|