qsl designer

This commit is contained in:
2026-06-11 21:54:35 +02:00
parent 6150498a9e
commit 408b29896c
252 changed files with 13989 additions and 277 deletions
+589
View File
@@ -0,0 +1,589 @@
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"
)
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 (16)",
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) > 6 {
return nil, fmt.Errorf("at most 6 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
}
if err := a.qso.MarkEQSLSent(a.ctx, qsoID, time.Now().UTC().Format("20060102")); 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 subject/body pair of the eQSL e-mail.
type QSLEmailTemplates struct {
Subject string `json:"subject"`
Body string `json:"body"`
}
// 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)
if err != nil {
return out, err
}
if s := m[keyQSLEmailSubject]; s != "" {
out.Subject = s
}
if b := m[keyQSLEmailBody]; b != "" {
out.Body = b
}
return out, nil
}
// QSLSaveEmailTemplates persists the eQSL e-mail templates.
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
}
return a.settings.Set(a.ctx, keyQSLEmailBody, t.Body)
}
// ── 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,
}
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
}
}
}
return info, nil
}
// 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),
"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,
}
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!",
}
}