feat(webpublish): publish the log as a web page or CSV, with FTP upload
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.
This commit is contained in:
@@ -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] }
|
||||
|
||||
@@ -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<Cfg | null>(null);
|
||||
const [cols, setCols] = useState<Col[]>([]);
|
||||
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 <p className="text-xs text-muted-foreground">…</p>;
|
||||
const set = (p: Partial<Cfg>) => 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<Cfg>) => 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 (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{t('wpub.hint')}</p>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} />
|
||||
{t('wpub.enable')}
|
||||
</label>
|
||||
|
||||
{cfg.enabled && (<>
|
||||
{/* ── The file ── */}
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<Label className="text-xs font-semibold">{t('wpub.fileSection')}</Label>
|
||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">{t('wpub.format')}</Label>
|
||||
<Select value={cfg.format} onValueChange={(v) => patch({ format: v })}>
|
||||
<SelectTrigger className="h-8 w-56"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="html">{t('wpub.formatHtml')}</SelectItem>
|
||||
<SelectItem value="csv">{t('wpub.formatCsv')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Label className="text-sm">{t('wpub.folder')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input className="h-8 font-mono text-xs flex-1" value={cfg.folder}
|
||||
onChange={(e) => set({ folder: e.target.value })} onBlur={() => patch({ folder: cfg.folder })} />
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={async () => {
|
||||
try { const p = await PickBackupFolder(); if (p) patch({ folder: p }); } catch { /* cancelled */ }
|
||||
}}><FolderOpen className="size-3.5" /> {t('wpub.browse')}</Button>
|
||||
</div>
|
||||
|
||||
<Label className="text-sm">{t('wpub.fileName')}</Label>
|
||||
<Input className="h-8 w-56 font-mono text-xs" value={cfg.file_name}
|
||||
onChange={(e) => set({ file_name: e.target.value })} onBlur={() => patch({ file_name: cfg.file_name })} />
|
||||
|
||||
<Label className="text-sm">{t('wpub.title')}</Label>
|
||||
<Input className="h-8" placeholder={t('wpub.titlePh')} value={cfg.title}
|
||||
onChange={(e) => set({ title: e.target.value })} onBlur={() => patch({ title: cfg.title })} />
|
||||
|
||||
<Label className="text-sm">{t('wpub.count')}</Label>
|
||||
<Input type="number" min={1} max={100000} className="h-8 w-28 font-mono text-xs" value={countText}
|
||||
onChange={(e) => 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));
|
||||
}} />
|
||||
|
||||
<Label className="text-sm">{t('wpub.every')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="number" min={0} max={1440} className="h-8 w-28 font-mono text-xs" value={everyText}
|
||||
onChange={(e) => 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));
|
||||
}} />
|
||||
<span className="text-xs text-muted-foreground">{t('wpub.everyHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Columns ── */}
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<Label className="text-xs font-semibold">{t('wpub.columns')}</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{cols.map((c) => {
|
||||
const on = cfg.columns?.includes(c.key);
|
||||
return (
|
||||
<button key={c.key} type="button" onClick={() => toggleCol(c.key)}
|
||||
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
|
||||
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
{c.header}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('wpub.columnsHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Upload ── */}
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.ftp_enabled} onCheckedChange={(c) => patch({ ftp_enabled: !!c })} />
|
||||
{t('wpub.ftpEnable')}
|
||||
</label>
|
||||
{cfg.ftp_enabled && (
|
||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
|
||||
<Label className="text-sm">{t('wpub.ftpHost')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input className="h-8 flex-1" placeholder="ftp.example.com" value={cfg.ftp_host}
|
||||
onChange={(e) => set({ ftp_host: e.target.value })} onBlur={() => patch({ ftp_host: cfg.ftp_host })} />
|
||||
<Input type="number" className="h-8 w-24 font-mono text-xs" value={cfg.ftp_port}
|
||||
onChange={(e) => set({ ftp_port: Number(e.target.value) || 21 })}
|
||||
onBlur={() => patch({ ftp_port: cfg.ftp_port })} />
|
||||
</div>
|
||||
<Label className="text-sm">{t('wpub.ftpUser')}</Label>
|
||||
<Input className="h-8" value={cfg.ftp_user}
|
||||
onChange={(e) => set({ ftp_user: e.target.value })} onBlur={() => patch({ ftp_user: cfg.ftp_user })} />
|
||||
<Label className="text-sm">{t('wpub.ftpPassword')}</Label>
|
||||
<Input type="password" className="h-8" value={cfg.ftp_password}
|
||||
onChange={(e) => set({ ftp_password: e.target.value })} onBlur={() => patch({ ftp_password: cfg.ftp_password })} />
|
||||
<Label className="text-sm">{t('wpub.ftpFolder')}</Label>
|
||||
<Input className="h-8 font-mono text-xs" placeholder="/www/log" value={cfg.ftp_folder}
|
||||
onChange={(e) => set({ ftp_folder: e.target.value })} onBlur={() => patch({ ftp_folder: cfg.ftp_folder })} />
|
||||
<Label className="text-sm">{t('wpub.ftpFileName')}</Label>
|
||||
<Input className="h-8 w-56 font-mono text-xs" value={cfg.ftp_file_name}
|
||||
onChange={(e) => set({ ftp_file_name: e.target.value })} onBlur={() => patch({ ftp_file_name: cfg.ftp_file_name })} />
|
||||
<span />
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.ftp_tls} onCheckedChange={(c) => patch({ ftp_tls: !!c })} />
|
||||
{t('wpub.ftpTls')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-border/60 pt-3 flex-wrap">
|
||||
<Button size="sm" onClick={() => run('publish')} disabled={busy !== ''}>
|
||||
{busy === 'publish' ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
|
||||
{t('wpub.publishNow')}
|
||||
</Button>
|
||||
{cfg.ftp_enabled && (
|
||||
<Button variant="outline" size="sm" onClick={() => run('test')} disabled={busy !== ''}>
|
||||
{busy === 'test' ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{t('wpub.testFtp')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{msg && <p className="text-xs text-success break-all">{msg}</p>}
|
||||
{err && <p className="text-xs text-danger break-all">{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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(`<!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)}
|
||||
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 {
|
||||
b.WriteString(`<tr>`)
|
||||
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. Kept tiny and dependency-free: the page has to work
|
||||
// offline and on any hosting.
|
||||
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),
|
||||
asc=th.dataset.asc!=='1';
|
||||
rows.sort(function(a,b){
|
||||
var x=a.cells[i].textContent.trim(), y=b.cells[i].textContent.trim(),
|
||||
nx=parseFloat(x), ny=parseFloat(y),
|
||||
n=!isNaN(nx)&&!isNaN(ny)&&x!==''&&y!=='';
|
||||
var c=n?(nx-ny):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});
|
||||
th.dataset.asc=asc?'1':'0';
|
||||
});
|
||||
});
|
||||
</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
|
||||
}
|
||||
+229
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user