qsl designer
This commit is contained in:
@@ -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 (1–6)",
|
||||
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!",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user