Files
OpsLog/app_qsl_designer.go
T
rouggy 7d7d1042c0 feat(qsl): OpsLog QSL received marker + PSE/TNX card stamp + default QSL message
Received flag: new APP_OPSLOG_QSL_RCVD extra, toggled on the QSO edit window
next to QSL Message (immediate targeted write via SetOpsLogQSLReceived so it sets
AND clears reliably), plus a live PSE QSL / TNX indicator and a Recent-QSOs
column mirroring the sent one.

PSE/TNX card stamp: automatic — received → TNX, otherwise PSE QSL — exposed as
the {qso.pse_tnx} token (added to qslVars, so preview and send agree) and placed
in the default QSO-box footer; it can be moved to its own element in the designer.

Default QSL message: new qsl.default_message (QSLEmailTemplates.DefaultMessage),
edited under Settings → E-mail → QSL card e-mail. qslVars falls back to it when
the QSO's own QSLMSG is empty, so a per-QSO message always wins. Single choke
point covers both live preview and send.
2026-08-07 10:36:57 +02:00

751 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// QSL card designer bindings. All logic lives in internal/qslcard — this file
// only adapts it to the Wails boundary (thin methods on *App, same receiver
// as app.go so bindings keep working). Template documents cross the bridge
// as JSON strings: the schema is the single source of truth and the frontend
// mirrors it in qslTypes.ts.
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"hamlog/internal/applog"
"hamlog/internal/dxcc"
"hamlog/internal/email"
"hamlog/internal/qslcard"
"hamlog/internal/qso"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// Settings keys for the eQSL e-mail templates (subject/body share the
// {CALL}/{DATE}/{BAND}/{MODE}/{MYCALL} variables of the recording e-mail).
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
keyQSLDefaultMsg = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty
)
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
// appQSLCardRcvdField marks that a QSL was RECEIVED for this QSO (set by the
// operator, e.g. when a card arrives by e-mail). It drives the PSE/TNX stamp on
// the card: received → "TNX" (thanks for your card), not received → "PSE QSL"
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"
const (
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
)
// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
func (a *App) qslDir() string { return filepath.Join(a.dataDir, "qsl") }
// QSLTemplateInfo is the template-list row shown in the designer.
type QSLTemplateInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
ProfileID *int64 `json:"profile_id,omitempty"`
IsDefault bool `json:"is_default"`
UpdatedAt string `json:"updated_at"`
}
// QSLFontInfo is one font face offered by the designer, TTF bytes included
// so the frontend can build @font-face rules (and inline them into the SVG
// before rasterization).
type QSLFontInfo struct {
Family string `json:"family"`
Kind string `json:"kind"` // "display" | "script" | "system"
Variable bool `json:"variable"`
DataB64 string `json:"data_b64"`
}
// QSLPresetInfo describes one style preset for the picker.
type QSLPresetInfo struct {
Name string `json:"name"`
Label string `json:"label"`
Params []string `json:"params"` // accepted style_params keys
Defaults qslcard.StyleParams `json:"defaults"`
}
// QSLPickPhotos opens a multi-select dialog for the designer drop zone's
// "browse" path (drag & drop hands the webview File objects without paths,
// 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 (15)",
Filters: []wruntime.FileFilter{
{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
},
})
}
// QSLGenerateProposals analyzes the photos and returns 3 distinct template
// documents (JSON) from the automatic placement engine.
func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
if len(photoPaths) == 0 {
return nil, fmt.Errorf("no photos selected")
}
if len(photoPaths) > 5 {
return nil, fmt.Errorf("at most 5 photos (got %d)", len(photoPaths))
}
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
for _, p := range photoPaths {
ph, err := qslcard.AnalyzePhoto(p)
if err != nil {
return nil, fmt.Errorf("analyze %s: %w", filepath.Base(p), err)
}
photos = append(photos, ph)
}
info, err := a.qslProfileInfo()
if err != nil {
return nil, err
}
var engine qslcard.LayoutEngine = qslcard.HeuristicEngine{}
templates, err := engine.Propose(photos, info)
if err != nil {
return nil, err
}
out := make([]string, len(templates))
for i, t := range templates {
b, err := qslcard.Encode(t)
if err != nil {
return nil, err
}
out[i] = string(b)
}
return out, nil
}
// QSLListTemplates returns the templates for the ACTIVE profile (its own plus any
// shared/global ones), defaults first — so the default is per-profile, not a
// single global list shared by every profile.
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
if a.qslTemplates == nil {
return nil, fmt.Errorf("db not initialized")
}
var recs []qslcard.Record
var err error
if p, e := a.profiles.Active(a.ctx); e == nil {
recs, err = a.qslTemplates.ListFor(a.ctx, p.ID)
} else {
recs, err = a.qslTemplates.List(a.ctx)
}
if err != nil {
return nil, err
}
out := make([]QSLTemplateInfo, 0, len(recs))
for _, r := range recs {
out = append(out, QSLTemplateInfo{
ID: r.ID, Name: r.Name, ProfileID: r.ProfileID,
IsDefault: r.IsDefault, UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04"),
})
}
return out, nil
}
// QSLGetTemplate returns a stored template document (JSON).
func (a *App) QSLGetTemplate(id int64) (string, error) {
if a.qslTemplates == nil {
return "", fmt.Errorf("db not initialized")
}
rec, err := a.qslTemplates.Get(a.ctx, id)
if err != nil {
return "", err
}
return rec.JSON, nil
}
// QSLSaveTemplate validates and stores a template. id 0 creates; the photos
// the document references at their original (absolute) paths are copied into
// the template's asset folder so the design survives the user moving files.
// Returns the template id.
func (a *App) QSLSaveTemplate(id int64, name string, doc string, forActiveProfile bool) (int64, error) {
if a.qslTemplates == nil {
return 0, fmt.Errorf("db not initialized")
}
name = strings.TrimSpace(name)
if name == "" {
return 0, fmt.Errorf("template name required")
}
t, err := qslcard.Parse([]byte(doc))
if err != nil {
return 0, err
}
t.Name = name
if err := qslcard.Validate(t, nil); err != nil {
return 0, err
}
rec := qslcard.Record{ID: id, Name: name}
if forActiveProfile {
if p, err := a.profiles.Active(a.ctx); err == nil {
rec.ProfileID = &p.ID
}
}
// Two-phase save: the row first (a new template needs its id to own an
// asset folder), then photo import + the final document.
rec.JSON = doc
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
return 0, err
}
dir := qslcard.TemplateDir(a.qslDir(), rec.ID)
if err := qslcard.ImportPhotos(&t, dir); err != nil {
return 0, err
}
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil {
return 0, err
}
final, err := qslcard.Encode(t)
if err != nil {
return 0, err
}
rec.JSON = string(final)
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
return 0, err
}
applog.Printf("qsl: template %q saved (id %d)", name, rec.ID)
return rec.ID, nil
}
// QSLSetDefaultTemplate marks a template as default for its profile scope.
func (a *App) QSLSetDefaultTemplate(id int64) error {
if a.qslTemplates == nil {
return fmt.Errorf("db not initialized")
}
return a.qslTemplates.SetDefault(a.ctx, id)
}
// QSLDeleteTemplate removes a template and its asset folder.
func (a *App) QSLDeleteTemplate(id int64) error {
if a.qslTemplates == nil {
return fmt.Errorf("db not initialized")
}
if err := a.qslTemplates.Delete(a.ctx, id); err != nil {
return err
}
return qslcard.RemoveTemplateDir(a.qslDir(), id)
}
// QSLSavePreview stores the rasterized thumbnail (PNG, base64) the frontend
// renders at save time, shown in the template list.
func (a *App) QSLSavePreview(id int64, pngB64 string) error {
data, err := base64.StdEncoding.DecodeString(pngB64)
if err != nil {
return fmt.Errorf("decode preview: %w", err)
}
dir := qslcard.TemplateDir(a.qslDir(), id)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create template dir: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, "preview.png"), data, 0o644); err != nil {
return fmt.Errorf("write preview: %w", err)
}
return nil
}
// QSLPreviewDataURL returns the stored thumbnail as a data URL ("" if the
// template has none yet).
func (a *App) QSLPreviewDataURL(id int64) (string, error) {
data, err := os.ReadFile(filepath.Join(qslcard.TemplateDir(a.qslDir(), id), "preview.png"))
if os.IsNotExist(err) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("read preview: %w", err)
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data), nil
}
// QSLPhotoDataURL returns a template photo as a data URL. templateID 0 reads
// a draft photo at its original absolute path (pre-save editing); otherwise
// ref is a name inside the template's asset folder.
func (a *App) QSLPhotoDataURL(templateID int64, ref string) (string, error) {
var path string
switch {
case templateID > 0 && !qslcard.IsDraftPhoto(ref):
path = filepath.Join(qslcard.TemplateDir(a.qslDir(), templateID), filepath.Base(ref))
case qslcard.IsDraftPhoto(ref):
path = ref
default:
return "", fmt.Errorf("photo %q not available without a template id", ref)
}
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read photo: %w", err)
}
mime := "image/jpeg"
if strings.EqualFold(filepath.Ext(path), ".png") {
mime = "image/png"
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}
// QSLFlagDataURL returns an embedded flag SVG as a data URL ("" = no flag).
func (a *App) QSLFlagDataURL(iso string) (string, error) {
if iso == "" {
return "", nil
}
svg, err := qslcard.FlagSVG(iso)
if err != nil {
return "", err
}
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(svg), nil
}
// QSLFonts returns the embedded designer fonts (plus Cooper Black when the
// machine has it) for @font-face registration.
func (a *App) QSLFonts() ([]QSLFontInfo, error) {
fonts, err := qslcard.Fonts()
if err != nil {
return nil, err
}
out := make([]QSLFontInfo, 0, len(fonts))
for _, f := range fonts {
out = append(out, QSLFontInfo{
Family: f.Family, Kind: f.Kind, Variable: f.Variable,
DataB64: base64.StdEncoding.EncodeToString(f.Data),
})
}
return out, nil
}
// QSLStylePresets lists the built-in style presets for the picker.
func (a *App) QSLStylePresets() []QSLPresetInfo {
out := make([]QSLPresetInfo, 0, len(qslcard.Presets))
for _, p := range qslcard.Presets {
params := make([]string, 0, len(p.AllowedParams))
for k := range p.AllowedParams {
params = append(params, k)
}
sort.Strings(params)
out = append(out, QSLPresetInfo{Name: p.Name, Label: p.Label, Params: params, Defaults: p.Defaults})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// QSLResolvePreview fills a template's placeholders with the active profile
// and a representative sample QSO, for the live editor preview. Returns the
// RenderModel as JSON.
func (a *App) QSLResolvePreview(doc string) (string, error) {
t, err := qslcard.Parse([]byte(doc))
if err != nil {
return "", err
}
vars, country, err := a.qslVars(sampleQSO())
if err != nil {
return "", err
}
return encodeRenderModel(qslcard.Resolve(t, vars, country))
}
// QSLDefaultTemplateID returns the template the eQSL send flow should use
// for the active profile (0 when none exists yet).
func (a *App) QSLDefaultTemplateID() (int64, error) {
if a.qslTemplates == nil {
return 0, fmt.Errorf("db not initialized")
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return 0, fmt.Errorf("no active profile: %w", err)
}
rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
if err != nil {
return 0, nil // no templates yet — the UI offers to open the designer
}
return rec.ID, nil
}
// RenderEQSL loads a QSO and a template and returns the fully-resolved
// render model (JSON) for the frontend to rasterize.
func (a *App) RenderEQSL(qsoID int64, templateID int64) (string, error) {
if a.qso == nil || a.qslTemplates == nil {
return "", fmt.Errorf("db not initialized")
}
q, err := a.qso.GetByID(a.ctx, qsoID)
if err != nil {
return "", err
}
rec, err := a.qslTemplates.Get(a.ctx, templateID)
if err != nil {
return "", fmt.Errorf("template %d: %w", templateID, err)
}
t, err := qslcard.Parse([]byte(rec.JSON))
if err != nil {
return "", err
}
dir := qslcard.TemplateDir(a.qslDir(), templateID)
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dir)); err != nil {
return "", err
}
vars, country, err := a.qslVars(q)
if err != nil {
return "", err
}
return encodeRenderModel(qslcard.Resolve(t, vars, country))
}
// SendEQSL e-mails the rasterized card (JPEG, base64) to the QSO's
// correspondent, archives it in the outbox and stamps eqsl_sent on success.
func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
q, err := a.qso.GetByID(a.ctx, qsoID)
if err != nil {
return err
}
to := strings.TrimSpace(q.Email)
if to == "" {
return fmt.Errorf("no e-mail address for %s — run a QRZ/HamQTH lookup first", q.Callsign)
}
data, err := base64.StdEncoding.DecodeString(jpegB64)
if err != nil {
return fmt.Errorf("decode card image: %w", err)
}
outbox := filepath.Join(a.qslDir(), "outbox")
if err := os.MkdirAll(outbox, 0o755); err != nil {
return fmt.Errorf("create outbox: %w", err)
}
safeCall := strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' {
return '_'
}
return r
}, q.Callsign)
path := filepath.Join(outbox, fmt.Sprintf("%s_%s.jpg", safeCall, q.QSODate.UTC().Format("20060102_1504")))
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write outbox file: %w", err)
}
s, _ := a.GetEmailSettings()
subject, _ := a.settings.Get(a.ctx, keyQSLEmailSubject)
if subject == "" {
subject = defaultQSLEmailSubject
}
body, _ := a.settings.Get(a.ctx, keyQSLEmailBody)
if body == "" {
body = defaultQSLEmailBody
}
if err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), path); err != nil {
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
return err
}
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
//
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
// read up top is now stale after the slow e-mail send, and rewriting the whole
// row would revert any column an auto-upload changed meanwhile — that's how
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
return fmt.Errorf("QSL card sent but status not saved: %w", err)
}
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
return nil
}
// qslDefaultMessage returns the operator's default QSL message (Settings), used
// on the card when a QSO has no QSLMSG of its own.
func (a *App) qslDefaultMessage() string {
if a.settings == nil {
return ""
}
s, _ := a.settings.Get(a.ctx, keyQSLDefaultMsg)
return s
}
// SetOpsLogQSLReceived marks (or clears) that a QSL was received for a QSO. This
// flips the card's PSE/TNX stamp. Stored as a timestamp in the QSO extras (a
// single-key UPDATE, like the sent marker) so it survives concurrent uploads.
func (a *App) SetOpsLogQSLReceived(qsoID int64, on bool) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
v := ""
if on {
v = time.Now().UTC().Format(time.RFC3339)
}
return a.qso.SetExtra(a.ctx, qsoID, appQSLCardRcvdField, v)
}
// QSLEmailTemplates is the eQSL e-mail subject/body, the auto-send toggle, and
// the default QSL message printed on the card when a QSO has none of its own.
type QSLEmailTemplates struct {
Subject string `json:"subject"`
Body string `json:"body"`
AutoSend bool `json:"auto_send"`
DefaultMessage string `json:"default_message"`
}
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
out := QSLEmailTemplates{Subject: defaultQSLEmailSubject, Body: defaultQSLEmailBody}
if a.settings == nil {
return out, nil
}
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend, keyQSLDefaultMsg)
if err != nil {
return out, err
}
if s := m[keyQSLEmailSubject]; s != "" {
out.Subject = s
}
if b := m[keyQSLEmailBody]; b != "" {
out.Body = b
}
out.AutoSend = m[keyQSLAutoSend] == "1"
out.DefaultMessage = m[keyQSLDefaultMsg]
return out, nil
}
// 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")
}
if err := a.settings.Set(a.ctx, keyQSLEmailSubject, t.Subject); err != nil {
return err
}
if err := a.settings.Set(a.ctx, keyQSLEmailBody, t.Body); err != nil {
return err
}
v := "0"
if t.AutoSend {
v = "1"
}
if err := a.settings.Set(a.ctx, keyQSLAutoSend, v); err != nil {
return err
}
return a.settings.Set(a.ctx, keyQSLDefaultMsg, t.DefaultMessage)
}
// 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) == "" || q.Extras[appQSLCardSentField] != "" {
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 ─────────────────────────────────────────────────────────────
func encodeRenderModel(m qslcard.RenderModel) (string, error) {
b, err := json.Marshal(m)
if err != nil {
return "", fmt.Errorf("encode render model: %w", err)
}
return string(b), nil
}
// qslProfileInfo extracts the placement-engine inputs from the active
// profile, filling missing zones from cty.dat.
func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) {
if a.profiles == nil {
return qslcard.ProfileInfo{}, fmt.Errorf("db not initialized")
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return qslcard.ProfileInfo{}, fmt.Errorf("no active profile: %w", err)
}
info := qslcard.ProfileInfo{
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
}
if p.MyITUZone != nil {
info.ITUZone = *p.MyITUZone
}
if (info.CQZone == 0 || info.ITUZone == 0) && a.dxcc != nil {
if m, ok := a.dxcc.Lookup(p.Callsign); ok {
if info.CQZone == 0 {
info.CQZone = m.CQZone
}
if info.ITUZone == 0 {
info.ITUZone = m.ITUZone
}
}
}
// 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) {
info, err := a.qslProfileInfo()
if err != nil {
return nil, qslcard.CountryInfo{}, err
}
zone := func(z int) string { // unknown zone reads better blank than "0"
if z == 0 {
return ""
}
return strconv.Itoa(z)
}
// The QSL message on the card: the QSO's own QSLMSG wins; when it's empty the
// operator's default message (Settings) is used instead.
msg := q.QSLMsg
if strings.TrimSpace(msg) == "" {
msg = a.qslDefaultMessage()
}
// PSE/TNX stamp, chosen automatically by whether a QSL was received for this
// QSO (appQSLCardRcvdField): received → thank them, otherwise ask for a card.
pseTnx := "PSE QSL"
if q.Extras != nil && strings.TrimSpace(q.Extras[appQSLCardRcvdField]) != "" {
pseTnx = "TNX"
}
vars := map[string]string{
"profile.callsign": info.Callsign,
"profile.operator_name": info.Operator,
"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"),
"qso.time_on": q.QSODate.UTC().Format("15:04"),
"qso.band": q.Band,
"qso.mode": q.Mode,
"qso.submode": q.Submode,
"qso.rst_sent": q.RSTSent,
"qso.qsl_msg": msg,
"qso.pse_tnx": pseTnx,
"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"
}
return vars, a.qslCountryInfo(), nil
}
// qslCountryInfo resolves the operator's own country (label + flag) from the
// active profile: explicit MY_DXCC wins, then a cty.dat lookup of the
// callsign. No match → label only or nothing (graceful).
func (a *App) qslCountryInfo() qslcard.CountryInfo {
p, err := a.profiles.Active(a.ctx)
if err != nil {
return qslcard.CountryInfo{}
}
out := qslcard.CountryInfo{Label: p.MyCountry}
entityNum := 0
if p.MyDXCC != nil {
entityNum = *p.MyDXCC
if out.Label == "" {
out.Label = dxcc.NameForDXCC(entityNum)
}
} else if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil {
if out.Label == "" {
out.Label = m.Entity.Name
}
entityNum = dxcc.EntityDXCC(m.Entity.Name)
}
}
out.FlagISO = qslcard.FlagISO(entityNum)
return out
}
// sampleQSO is the canned contact used by the editor's live preview.
func sampleQSO() qso.QSO {
return qso.QSO{
Callsign: "DL1ABC",
QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC),
Band: "20m",
Mode: "SSB",
RSTSent: "59",
QSLMsg: "TNX FB QSO — 73!",
}
}