694 lines
22 KiB
Go
694 lines
22 KiB
Go
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
|
||
)
|
||
|
||
// 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"
|
||
|
||
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 (1–5)",
|
||
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 all saved templates (defaults first).
|
||
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
|
||
if a.qslTemplates == nil {
|
||
return nil, fmt.Errorf("db not initialized")
|
||
}
|
||
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. q came straight from GetByID, so a full Update rewrites the
|
||
// row unchanged apart from this field.
|
||
if q.Extras == nil {
|
||
q.Extras = map[string]string{}
|
||
}
|
||
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
|
||
if err := a.qso.Update(a.ctx, q); err != nil {
|
||
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
|
||
return fmt.Errorf("eQSL 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
|
||
}
|
||
|
||
// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle.
|
||
type QSLEmailTemplates struct {
|
||
Subject string `json:"subject"`
|
||
Body string `json:"body"`
|
||
AutoSend bool `json:"auto_send"`
|
||
}
|
||
|
||
// 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)
|
||
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"
|
||
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"
|
||
}
|
||
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) == "" || 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)
|
||
}
|
||
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": 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"
|
||
}
|
||
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!",
|
||
}
|
||
}
|