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 ) 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// 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–3)", 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) > 3 { return nil, fmt.Errorf("at most 3 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 } // Stamp the standard ADIF eqsl_sent flag plus an app-specific timestamp of // the eQSL-card e-mail (APP_OPSLOG_QSL_SENT) — distinct from eqsl_sent, which // an eQSL.cc upload may also set. q came straight from GetByID, so a full // Update rewrites the row unchanged apart from these fields. now := time.Now().UTC() q.EQSLSent = "Y" q.EQSLSentDate = now.Format("20060102") if q.Extras == nil { q.Extras = map[string]string{} } q.Extras["APP_OPSLOG_QSL_SENT"] = now.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) == "" || 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 ───────────────────────────────────────────────────────────── 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!", } }