The designer's data model lives in internal/labels: STOCKS are the physical roll in the printer (geometry in mm, margins, dpi — seeded with the common Brother DK sizes, the QL family being what prompted the feature), TEMPLATES are one design each, of two kinds: the QSO label glued on the card, whose repeating table carries several contacts of the same station, and the address label for the envelope, whose lines collapse when a variable is empty. Everything is measured in millimetres — labels are sold in mm, and an operator lining a design up against a physical sticker thinks in mm; pixels exist only in the renderer, at the stock's dpi. The canvas renderer is shared between the editor's preview and the future print path, so there is no second implementation for the preview to disagree with. The preview is fed with the log's latest contacts rather than lorem ipsum: real data shows a too-narrow column immediately. Printing (PDF, one page per label at exact size) is the next module; nothing here prints yet.
209 lines
6.5 KiB
Go
209 lines
6.5 KiB
Go
package main
|
|
|
|
// Label designer — the Wails boundary for internal/labels.
|
|
//
|
|
// The designer edits two things: STOCKS (the physical roll in the printer,
|
|
// geometry in mm) and TEMPLATES (one design per label kind: the QSO label glued
|
|
// on a card, the address label for the envelope). Printing — a later module —
|
|
// will ask for the default template of each kind and hand the rasterised pages
|
|
// to a PDF; nothing here prints.
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"hamlog/internal/applog"
|
|
"hamlog/internal/labels"
|
|
"hamlog/internal/qso"
|
|
)
|
|
|
|
// LabelTemplateInfo is one row of the designer's template list.
|
|
type LabelTemplateInfo struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
StockID int64 `json:"stock_id"`
|
|
ProfileID *int64 `json:"profile_id,omitempty"`
|
|
IsDefault bool `json:"is_default"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// LabelListStocks returns every label stock, seeding the builtin Brother rolls
|
|
// on first use.
|
|
func (a *App) LabelListStocks() ([]labels.Stock, error) {
|
|
if a.labelRepo == nil {
|
|
return nil, fmt.Errorf("db not initialized")
|
|
}
|
|
if err := a.labelRepo.SeedStocks(a.ctx); err != nil {
|
|
applog.Printf("labels: seeding stocks failed: %v", err)
|
|
}
|
|
return a.labelRepo.Stocks(a.ctx)
|
|
}
|
|
|
|
// LabelSaveStock creates or updates one stock and returns its id.
|
|
func (a *App) LabelSaveStock(s labels.Stock) (int64, error) {
|
|
if a.labelRepo == nil {
|
|
return 0, fmt.Errorf("db not initialized")
|
|
}
|
|
if err := a.labelRepo.SaveStock(a.ctx, &s); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.ID, nil
|
|
}
|
|
|
|
// LabelDeleteStock removes a stock; designs pointing at it keep their content.
|
|
func (a *App) LabelDeleteStock(id int64) error {
|
|
if a.labelRepo == nil {
|
|
return fmt.Errorf("db not initialized")
|
|
}
|
|
return a.labelRepo.DeleteStock(a.ctx, id)
|
|
}
|
|
|
|
// LabelListTemplates lists the designs visible to the active profile.
|
|
func (a *App) LabelListTemplates() ([]LabelTemplateInfo, error) {
|
|
if a.labelRepo == nil {
|
|
return nil, fmt.Errorf("db not initialized")
|
|
}
|
|
var recs []labels.Record
|
|
var err error
|
|
if p, e := a.profiles.Active(a.ctx); e == nil {
|
|
recs, err = a.labelRepo.ListFor(a.ctx, p.ID)
|
|
} else {
|
|
recs, err = a.labelRepo.List(a.ctx)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]LabelTemplateInfo, 0, len(recs))
|
|
for _, r := range recs {
|
|
info := LabelTemplateInfo{
|
|
ID: r.ID, Name: r.Name, Kind: r.Kind, ProfileID: r.ProfileID,
|
|
IsDefault: r.IsDefault, UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04"),
|
|
}
|
|
if r.StockID != nil {
|
|
info.StockID = *r.StockID
|
|
}
|
|
out = append(out, info)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// LabelGetTemplate returns one stored design document (JSON).
|
|
func (a *App) LabelGetTemplate(id int64) (string, error) {
|
|
if a.labelRepo == nil {
|
|
return "", fmt.Errorf("db not initialized")
|
|
}
|
|
rec, err := a.labelRepo.Get(a.ctx, id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return rec.JSON, nil
|
|
}
|
|
|
|
// LabelSaveTemplate validates and stores a design; id 0 creates. Returns the id.
|
|
func (a *App) LabelSaveTemplate(id int64, name string, doc string, forActiveProfile bool) (int64, error) {
|
|
if a.labelRepo == nil {
|
|
return 0, fmt.Errorf("db not initialized")
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return 0, fmt.Errorf("template name required")
|
|
}
|
|
t, err := labels.Parse([]byte(doc))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if err := labels.Validate(t); err != nil {
|
|
return 0, err
|
|
}
|
|
rec := labels.Record{ID: id, Name: name, Kind: t.Kind, JSON: doc}
|
|
if t.StockID != 0 {
|
|
sid := t.StockID
|
|
rec.StockID = &sid
|
|
}
|
|
if forActiveProfile {
|
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
|
rec.ProfileID = &p.ID
|
|
}
|
|
}
|
|
if err := a.labelRepo.Save(a.ctx, &rec); err != nil {
|
|
return 0, err
|
|
}
|
|
applog.Printf("labels: template %q (%s) saved (id %d)", name, t.Kind, rec.ID)
|
|
return rec.ID, nil
|
|
}
|
|
|
|
// LabelDeleteTemplate removes a design.
|
|
func (a *App) LabelDeleteTemplate(id int64) error {
|
|
if a.labelRepo == nil {
|
|
return fmt.Errorf("db not initialized")
|
|
}
|
|
return a.labelRepo.Delete(a.ctx, id)
|
|
}
|
|
|
|
// LabelSetDefaultTemplate marks a design as the default for its kind.
|
|
func (a *App) LabelSetDefaultTemplate(id int64) error {
|
|
if a.labelRepo == nil {
|
|
return fmt.Errorf("db not initialized")
|
|
}
|
|
return a.labelRepo.SetDefault(a.ctx, id)
|
|
}
|
|
|
|
// LabelSampleQSO is one row of preview data for the designer's QSO table.
|
|
type LabelSampleQSO struct {
|
|
Callsign string `json:"callsign"`
|
|
QSODate string `json:"qso_date"` // YYYY-MM-DD
|
|
TimeOn string `json:"time_on"` // HH:MM
|
|
Band string `json:"band"`
|
|
FreqMHz string `json:"freq"`
|
|
Mode string `json:"mode"`
|
|
RSTSent string `json:"rst_sent"`
|
|
RSTRcvd string `json:"rst_rcvd"`
|
|
Name string `json:"name"`
|
|
QTH string `json:"qth"`
|
|
Country string `json:"country"`
|
|
}
|
|
|
|
// LabelSampleQSOs returns the last few real contacts for the designer's live
|
|
// preview — real data shows a too-narrow column immediately ("14074.0" does not
|
|
// fit where "7.1" did). Falls back to plausible fakes on an empty log; the
|
|
// preview must never be blank.
|
|
func (a *App) LabelSampleQSOs(limit int) []LabelSampleQSO {
|
|
if limit <= 0 || limit > 20 {
|
|
limit = 4
|
|
}
|
|
fake := []LabelSampleQSO{
|
|
{Callsign: "DL1ABC", QSODate: "2026-08-01", TimeOn: "14:32", Band: "20m", FreqMHz: "14.074", Mode: "FT8", RSTSent: "-08", RSTRcvd: "-12", Name: "Hans", QTH: "Berlin", Country: "Germany"},
|
|
{Callsign: "VK3XYZ", QSODate: "2026-08-02", TimeOn: "09:15", Band: "15m", FreqMHz: "21.245", Mode: "SSB", RSTSent: "59", RSTRcvd: "57", Name: "Bruce", QTH: "Melbourne", Country: "Australia"},
|
|
{Callsign: "JA1TOK", QSODate: "2026-08-03", TimeOn: "21:47", Band: "40m", FreqMHz: "7.012", Mode: "CW", RSTSent: "599", RSTRcvd: "579", Name: "Ken", QTH: "Tokyo", Country: "Japan"},
|
|
{Callsign: "W1AW", QSODate: "2026-08-04", TimeOn: "18:03", Band: "10m", FreqMHz: "28.480", Mode: "SSB", RSTSent: "59", RSTRcvd: "59", Name: "Hiram", QTH: "Newington", Country: "United States"},
|
|
}
|
|
if a.qso == nil {
|
|
return fake[:min(limit, len(fake))]
|
|
}
|
|
rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: limit})
|
|
if err != nil || len(rows) == 0 {
|
|
return fake[:min(limit, len(fake))]
|
|
}
|
|
out := make([]LabelSampleQSO, 0, len(rows))
|
|
for _, q := range rows {
|
|
s := LabelSampleQSO{
|
|
Callsign: q.Callsign,
|
|
QSODate: q.QSODate.UTC().Format("2006-01-02"),
|
|
TimeOn: q.QSODate.UTC().Format("15:04"),
|
|
Band: q.Band,
|
|
Mode: q.Mode,
|
|
RSTSent: q.RSTSent,
|
|
RSTRcvd: q.RSTRcvd,
|
|
Name: q.Name,
|
|
QTH: q.QTH,
|
|
Country: q.Country,
|
|
}
|
|
if q.FreqHz != nil && *q.FreqHz > 0 {
|
|
s.FreqMHz = fmt.Sprintf("%.3f", float64(*q.FreqHz)/1e6)
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|