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) }