From 3cb2e466d802ec946431b91aeb291c07c1bcda9f Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Sat, 13 Jun 2026 01:34:45 +0200 Subject: [PATCH] Qsl --- app.go | 2 + app_qsl_designer.go | 110 +++- frontend/src/App.tsx | 44 +- frontend/src/components/SettingsModal.tsx | 35 +- frontend/src/components/qsl/AutoEQSL.tsx | 100 +++ frontend/src/components/qsl/CardPreview.tsx | 88 ++- frontend/src/components/qsl/EditorPanel.tsx | 41 +- .../src/components/qsl/QslDesignerModal.tsx | 36 +- .../src/components/qsl/StylePresetPicker.tsx | 6 +- frontend/src/components/qsl/qslTypes.ts | 1 + frontend/src/components/qsl/textFx.ts | 576 ++++++++++++++++++ frontend/wailsjs/go/models.ts | 2 + .../assets/fonts/AlfaSlabOne-Regular.ttf | Bin 0 -> 97376 bytes .../qslcard/assets/fonts/OFL-AlfaSlabOne.txt | 93 +++ internal/qslcard/assets/fonts/OFL-Rye.txt | 94 +++ internal/qslcard/assets/fonts/Rye-Regular.ttf | Bin 0 -> 183244 bytes internal/qslcard/fonts.go | 2 + internal/qslcard/placement.go | 114 ++-- internal/qslcard/placement_test.go | 24 +- internal/qslcard/presets.go | 46 +- internal/qslcard/template.go | 1 + 21 files changed, 1285 insertions(+), 130 deletions(-) create mode 100644 frontend/src/components/qsl/AutoEQSL.tsx create mode 100644 frontend/src/components/qsl/textFx.ts create mode 100644 internal/qslcard/assets/fonts/AlfaSlabOne-Regular.ttf create mode 100644 internal/qslcard/assets/fonts/OFL-AlfaSlabOne.txt create mode 100644 internal/qslcard/assets/fonts/OFL-Rye.txt create mode 100644 internal/qslcard/assets/fonts/Rye-Regular.ttf diff --git a/app.go b/app.go index 586b053..90718d1 100644 --- a/app.go +++ b/app.go @@ -1217,6 +1217,7 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) { if a.extsvc != nil { a.extsvc.OnQSOLogged(id) } + a.maybeAutoSendEQSL(q) } return id, err } @@ -5391,6 +5392,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { if a.extsvc != nil { a.extsvc.OnQSOLogged(id) } + a.maybeAutoSendEQSL(q) return id, nil } diff --git a/app_qsl_designer.go b/app_qsl_designer.go index 31066a4..706d7dc 100644 --- a/app_qsl_designer.go +++ b/app_qsl_designer.go @@ -31,6 +31,7 @@ import ( const ( keyQSLEmailSubject = "qsl.email_subject" keyQSLEmailBody = "qsl.email_body" + keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist ) const ( @@ -73,7 +74,7 @@ type QSLPresetInfo struct { // so picking through the native dialog is the reliable route to real paths). func (a *App) QSLPickPhotos() ([]string, error) { return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{ - Title: "Choose card photos (1–6)", + Title: "Choose card photos (1–3)", Filters: []wruntime.FileFilter{ {DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"}, }, @@ -86,8 +87,8 @@ func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) { if len(photoPaths) == 0 { return nil, fmt.Errorf("no photos selected") } - if len(photoPaths) > 6 { - return nil, fmt.Errorf("at most 6 photos (got %d)", len(photoPaths)) + if len(photoPaths) > 3 { + return nil, fmt.Errorf("at most 3 photos (got %d)", len(photoPaths)) } photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths)) for _, p := range photoPaths { @@ -433,10 +434,11 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error { return nil } -// QSLEmailTemplates is the subject/body pair of the eQSL e-mail. +// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle. type QSLEmailTemplates struct { - Subject string `json:"subject"` - Body string `json:"body"` + Subject string `json:"subject"` + Body string `json:"body"` + AutoSend bool `json:"auto_send"` } // QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults). @@ -445,7 +447,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) { if a.settings == nil { return out, nil } - m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody) + m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend) if err != nil { return out, err } @@ -455,10 +457,11 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) { if b := m[keyQSLEmailBody]; b != "" { out.Body = b } + out.AutoSend = m[keyQSLAutoSend] == "1" return out, nil } -// QSLSaveEmailTemplates persists the eQSL e-mail templates. +// QSLSaveEmailTemplates persists the eQSL e-mail templates and auto-send flag. func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error { if a.settings == nil { return fmt.Errorf("db not initialized") @@ -466,7 +469,43 @@ func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error { if err := a.settings.Set(a.ctx, keyQSLEmailSubject, t.Subject); err != nil { return err } - return a.settings.Set(a.ctx, keyQSLEmailBody, t.Body) + if err := a.settings.Set(a.ctx, keyQSLEmailBody, t.Body); err != nil { + return err + } + v := "0" + if t.AutoSend { + v = "1" + } + return a.settings.Set(a.ctx, keyQSLAutoSend, v) +} + +// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the +// user enabled auto-send and the prerequisites hold: a recipient e-mail, a +// default template for the active profile, and the QSO not already confirmed. +// Rasterization happens in the webview, so this only emits an event ("qsl:autosend") +// the frontend acts on — never sends from here. Silent when any condition fails. +func (a *App) maybeAutoSendEQSL(q qso.QSO) { + if a.settings == nil || a.qslTemplates == nil || a.profiles == nil || a.ctx == nil { + return + } + if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" { + return + } + if strings.TrimSpace(q.Email) == "" || strings.EqualFold(q.EQSLSent, "Y") { + return + } + p, err := a.profiles.Active(a.ctx) + if err != nil { + return + } + rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID) + if err != nil { + applog.Printf("qsl: auto-send skipped for %s — no template (%v)", q.Callsign, err) + return + } + wruntime.EventsEmit(a.ctx, "qsl:autosend", map[string]any{ + "qsoId": q.ID, "templateId": rec.ID, "callsign": q.Callsign, + }) } // ── helpers ───────────────────────────────────────────────────────────── @@ -493,6 +532,8 @@ func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) { Callsign: p.Callsign, Operator: p.OpName, // personal name for the QSL script (not the OPERATOR callsign) Grid: p.MyGrid, + Rig: p.MyRig, + Antenna: p.MyAntenna, } if p.MyCQZone != nil { info.CQZone = *p.MyCQZone @@ -510,9 +551,57 @@ func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) { } } } + // Most users define rig/antenna in the operating manager (band defaults), + // not the profile's MY_RIG/MY_ANTENNA text fields. Fall back to a band + // default so the designer knows the station line has data to show. + if (info.Rig == "" || info.Antenna == "") && a.operating != nil { + for _, band := range []string{"20m", "40m", "80m", "10m", "2m", "160m"} { + d, _, e := a.operating.BandDefault(a.ctx, p.ID, band) + if e != nil { + continue + } + if info.Rig == "" { + info.Rig = d.StationName + } + if info.Antenna == "" { + info.Antenna = d.AntennaName + } + if info.Rig != "" && info.Antenna != "" { + break + } + } + } return info, nil } +// qslRigAntenna resolves the rig and antenna names for a QSO: the values +// stamped on it (MY_RIG/MY_ANTENNA), falling back to the operating manager's +// default for the QSO's band (so the preview and back-entered QSOs aren't +// blank). +func (a *App) qslRigAntenna(q qso.QSO) (rig, ant string) { + rig, ant = q.MyRig, q.MyAntenna + if (rig != "" && ant != "") || a.operating == nil || a.profiles == nil { + return rig, ant + } + p, err := a.profiles.Active(a.ctx) + if err != nil { + return rig, ant + } + band := q.Band + if band == "" { + band = "20m" + } + if d, _, e := a.operating.BandDefault(a.ctx, p.ID, band); e == nil { + if rig == "" { + rig = d.StationName + } + if ant == "" { + ant = d.AntennaName + } + } + return rig, ant +} + // qslVars builds the full placeholder map (profile + QSO) and the country // block resolution for one render. func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) { @@ -532,6 +621,8 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) "profile.grid": info.Grid, "profile.cq_zone": zone(info.CQZone), "profile.itu_zone": zone(info.ITUZone), + "profile.rig": info.Rig, + "profile.antenna": info.Antenna, "qso.callsign": q.Callsign, "qso.qso_date": q.QSODate.UTC().Format("2006-01-02"), @@ -543,6 +634,7 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) "qso.qsl_msg": q.QSLMsg, "qso.name": q.Name, } + vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q) if q.FreqHz != nil { vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 790ac17..a16ad9c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,6 +41,7 @@ import { Menubar, type Menu } from '@/components/Menubar'; import { QSLManagerPanel } from '@/components/QSLManagerModal'; import { QslDesignerModal } from '@/components/qsl/QslDesignerModal'; import { SendEQSLModal } from '@/components/qsl/SendEQSLModal'; +import { AutoEQSL } from '@/components/qsl/AutoEQSL'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { SettingsModal } from '@/components/SettingsModal'; import { QSOEditModal } from '@/components/QSOEditModal'; @@ -631,6 +632,19 @@ export default function App() { return next; }); }, []); + // Single band map docked beside the table (toggled by the toolbar button, + // visible across tabs). Independent of the multi-band "Band Map" tab. + const [showBandMap, setShowBandMap] = useState(false); + const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>( + () => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'), + ); + const toggleBandMapSide = useCallback(() => { + setBandMapSide((s) => { + const next = s === 'right' ? 'left' : 'right'; + writeUiPref('bandmap.side', next); + return next; + }); + }, []); type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source'; const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' }); // Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked". @@ -2256,10 +2270,10 @@ export default function App() { )} {emailMsg} + +
+ +
+ Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}. +
+ setEqslField({ subject: e.target.value })} /> +