diff --git a/app_qsl_designer.go b/app_qsl_designer.go index c4dea12..7c0cff2 100644 --- a/app_qsl_designer.go +++ b/app_qsl_designer.go @@ -31,7 +31,7 @@ import ( 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 + keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist keyQSLDefaultMsg = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty ) @@ -748,3 +748,107 @@ func sampleQSO() qso.QSO { QSLMsg: "TNX FB QSO — 73!", } } + +// QSLForeignTemplate is one card design belonging to ANOTHER profile, offered +// for copying into the active one. +type QSLForeignTemplate struct { + ID int64 `json:"id"` + Name string `json:"name"` + ProfileID int64 `json:"profile_id"` + ProfileName string `json:"profile_name"` +} + +// QSLListOtherProfileTemplates lists the designs the ACTIVE profile cannot see. +// +// A second profile is usually the same operator with a different rig or a +// different locator — same callsign, same cards. Redrawing a design because the +// antenna changed is work nobody should have to do, and the designs a profile +// already sees (its own, and the shared ones) are deliberately left out: they +// are not something to copy. +func (a *App) QSLListOtherProfileTemplates() ([]QSLForeignTemplate, error) { + if a.qslTemplates == nil || a.profiles == nil { + return nil, fmt.Errorf("db not initialized") + } + active := int64(-1) + if p, err := a.profiles.Active(a.ctx); err == nil { + active = p.ID + } + names := map[int64]string{} + if list, err := a.ListProfiles(); err == nil { + for _, p := range list { + names[p.ID] = p.Name + } + } + all, err := a.qslTemplates.List(a.ctx) + if err != nil { + return nil, err + } + out := []QSLForeignTemplate{} + for _, r := range all { + // Shared designs (no profile) already appear in every profile's list. + if r.ProfileID == nil || *r.ProfileID == active { + continue + } + out = append(out, QSLForeignTemplate{ + ID: r.ID, Name: r.Name, ProfileID: *r.ProfileID, + ProfileName: firstNonEmptyStr(names[*r.ProfileID], fmt.Sprintf("profile %d", *r.ProfileID)), + }) + } + return out, nil +} + +// QSLCopyTemplateToActiveProfile duplicates another profile's design into the +// active one and returns the new id. +// +// A COPY, not a move or a share. The original profile keeps its design +// untouched, and the copy is free to diverge — a second profile usually exists +// because something differs, and that something often ends up on the card. +func (a *App) QSLCopyTemplateToActiveProfile(id int64) (int64, error) { + if a.qslTemplates == nil { + return 0, fmt.Errorf("db not initialized") + } + src, err := a.qslTemplates.Get(a.ctx, id) + if err != nil { + return 0, err + } + // The stored document references photos by NAME, relative to the template's + // own asset folder — so copying the JSON alone would point the new design at + // files that are not in its folder, and the save would fail validation. + // + // The files are copied too, giving the duplicate its own folder. Sharing the + // source's would mean deleting one design silently emptied the other. + rec := qslcard.Record{Name: src.Name + " (copy)", JSON: src.JSON} + if p, e := a.profiles.Active(a.ctx); e == nil { + rec.ProfileID = &p.ID + } + // Saved first: a new template needs its id before it can own a folder. + if err := a.qslTemplates.Save(a.ctx, &rec); err != nil { + return 0, err + } + srcDir := qslcard.TemplateDir(a.qslDir(), id) + dstDir := qslcard.TemplateDir(a.qslDir(), rec.ID) + if err := copyDirContents(srcDir, dstDir); err != nil && !os.IsNotExist(err) { + // Roll back rather than leave a design whose pictures are missing. + _ = a.qslTemplates.Delete(a.ctx, rec.ID) + return 0, fmt.Errorf("copy template assets: %w", err) + } + t, err := qslcard.Parse([]byte(rec.JSON)) + if err != nil { + _ = a.qslTemplates.Delete(a.ctx, rec.ID) + return 0, err + } + if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dstDir)); err != nil { + _ = a.qslTemplates.Delete(a.ctx, rec.ID) + return 0, fmt.Errorf("copied design is incomplete: %w", err) + } + applog.Printf("qsl: copied template %q (id %d) into the active profile as %q (id %d)", + src.Name, id, rec.Name, rec.ID) + return rec.ID, nil +} + +func firstNonEmptyStr(a, b string) string { + if strings.TrimSpace(a) != "" { + return a + } + return b +} diff --git a/changelog.json b/changelog.json index fc260d6..7a725ba 100644 --- a/changelog.json +++ b/changelog.json @@ -5,12 +5,14 @@ "en": [ "Amplifiers: tick the ones sharing a combiner and ON, OFF and OPERATE act on all of them at once. Each keeps its own meters.", "Shared CAT: with the wire trace on, every command a client sends and the answer given are logged.", - "Shared CAT: JTDX and WSJT-X no longer get an error for turning split off on a rig that has none, or for setting the transmit mode — which made JTDX abandon a transmission." + "Shared CAT: JTDX and WSJT-X no longer get an error for turning split off on a rig that has none, or for setting the transmit mode — which made JTDX abandon a transmission.", + "QSL designer: a card design can be copied from another profile, pictures included — a second profile for a different rig no longer means redrawing it." ], "fr": [ "Amplificateurs : coche ceux qui partagent un combiner et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses mesures.", "CAT partagé : avec la trace activée, chaque commande envoyée par un client et la réponse donnée sont journalisées.", - "CAT partagé : JTDX et WSJT-X ne reçoivent plus d erreur en désactivant un split inexistant ni en réglant le mode d émission — ce qui faisait abandonner une émission à JTDX." + "CAT partagé : JTDX et WSJT-X ne reçoivent plus d erreur en désactivant un split inexistant ni en réglant le mode d émission — ce qui faisait abandonner une émission à JTDX.", + "Concepteur QSL : un modèle de carte peut être copié depuis un autre profil, images comprises — un second profil pour une autre radio n oblige plus à le redessiner." ] }, { diff --git a/frontend/src/components/qsl/QslDesignerModal.tsx b/frontend/src/components/qsl/QslDesignerModal.tsx index ba6a8b5..47a5a7e 100644 --- a/frontend/src/components/qsl/QslDesignerModal.tsx +++ b/frontend/src/components/qsl/QslDesignerModal.tsx @@ -13,6 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import { QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate, + QSLListOtherProfileTemplates, QSLCopyTemplateToActiveProfile, QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview, QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets, } from '../../../wailsjs/go/main/App'; @@ -59,6 +60,7 @@ export function QslDesignerModal({ open, onClose }: Props) { const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [saved, setSaved] = useState([]); + const [foreign, setForeign] = useState([]); const [previews, setPreviews] = useState>({}); const [presets, setPresets] = useState([]); const [fontFamilies, setFontFamilies] = useState([]); @@ -88,6 +90,8 @@ export function QslDesignerModal({ open, onClose }: Props) { if (url) p[t.id] = url; })); setPreviews(p); + // The designs this profile cannot see, offered for copying. + try { setForeign(((await QSLListOtherProfileTemplates()) ?? []) as any[]); } catch { /* nothing to offer */ } } catch (e) { setError(String(e)); } @@ -274,7 +278,29 @@ export function QslDesignerModal({ open, onClose }: Props) {
-

Saved templates

+
+

Saved templates

+ {/* Designs owned by another profile. A second profile is usually + the same operator with a different rig or locator — same + callsign, same cards — and redrawing one because the antenna + changed is work nobody should have to do. */} + {!!foreign.length && ( + + )} +
{!saved.length &&

None yet.

}
{saved.map((t) => ( diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 42aa295..d8299f3 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -770,6 +770,8 @@ export function PopulateBuiltinReferences(arg1:string):Promise; export function PublishLogNow():Promise; +export function QSLCopyTemplateToActiveProfile(arg1:number):Promise; + export function QSLDefaultTemplateID():Promise; export function QSLDeleteTemplate(arg1:number):Promise; @@ -784,6 +786,8 @@ export function QSLGetEmailTemplates():Promise; export function QSLGetTemplate(arg1:number):Promise; +export function QSLListOtherProfileTemplates():Promise>; + export function QSLListTemplates():Promise>; export function QSLPhotoDataURL(arg1:number,arg2:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 91d99bd..bacb981 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1482,6 +1482,10 @@ export function PublishLogNow() { return window['go']['main']['App']['PublishLogNow'](); } +export function QSLCopyTemplateToActiveProfile(arg1) { + return window['go']['main']['App']['QSLCopyTemplateToActiveProfile'](arg1); +} + export function QSLDefaultTemplateID() { return window['go']['main']['App']['QSLDefaultTemplateID'](); } @@ -1510,6 +1514,10 @@ export function QSLGetTemplate(arg1) { return window['go']['main']['App']['QSLGetTemplate'](arg1); } +export function QSLListOtherProfileTemplates() { + return window['go']['main']['App']['QSLListOtherProfileTemplates'](); +} + export function QSLListTemplates() { return window['go']['main']['App']['QSLListTemplates'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 479b6bb..83edbe6 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2785,6 +2785,24 @@ export namespace main { this.data_b64 = source["data_b64"]; } } + export class QSLForeignTemplate { + id: number; + name: string; + profile_id: number; + profile_name: string; + + static createFrom(source: any = {}) { + return new QSLForeignTemplate(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.profile_id = source["profile_id"]; + this.profile_name = source["profile_name"]; + } + } export class QSLPresetInfo { name: string; label: string;