feat(qsl): copy a card design from another profile
A second profile is usually the same operator with a different rig or a different locator — same callsign, same cards. Having to redraw a design because the antenna changed is work nobody should do. The designer now lists the designs the active profile cannot see and copies a chosen one into it. Designs it already sees — its own, and the shared ones — are left out: they are not something to copy. A COPY, not a move or a share. The original profile keeps its design untouched and the duplicate is free to diverge, which it usually will: a second profile exists because something differs, and that something often ends up on the card. The PICTURES are copied too, and that is the part worth getting right. Stored documents reference photos by name relative to the template's own asset folder, so duplicating the JSON alone would point the new design at files that are not in its folder. It is validated against its own folder afterwards, and a failure rolls the row back rather than leaving a design whose pictures are missing. Sharing the source's folder was the other option and a worse one: deleting either design would then have emptied the other.
This commit is contained in:
+105
-1
@@ -31,7 +31,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
keyQSLEmailSubject = "qsl.email_subject"
|
keyQSLEmailSubject = "qsl.email_subject"
|
||||||
keyQSLEmailBody = "qsl.email_body"
|
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
|
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!",
|
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
|
||||||
|
}
|
||||||
|
|||||||
+4
-2
@@ -5,12 +5,14 @@
|
|||||||
"en": [
|
"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.",
|
"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: 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": [
|
"fr": [
|
||||||
"Amplificateurs : coche ceux qui partagent un combiner et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses mesures.",
|
"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é : 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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
||||||
|
QSLListOtherProfileTemplates, QSLCopyTemplateToActiveProfile,
|
||||||
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
||||||
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
||||||
} from '../../../wailsjs/go/main/App';
|
} from '../../../wailsjs/go/main/App';
|
||||||
@@ -59,6 +60,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
|
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
|
||||||
|
const [foreign, setForeign] = useState<any[]>([]);
|
||||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||||
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
||||||
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
||||||
@@ -88,6 +90,8 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
if (url) p[t.id] = url;
|
if (url) p[t.id] = url;
|
||||||
}));
|
}));
|
||||||
setPreviews(p);
|
setPreviews(p);
|
||||||
|
// The designs this profile cannot see, offered for copying.
|
||||||
|
try { setForeign(((await QSLListOtherProfileTemplates()) ?? []) as any[]); } catch { /* nothing to offer */ }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
@@ -274,7 +278,29 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold">Saved templates</h3>
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-sm font-semibold">Saved templates</h3>
|
||||||
|
{/* 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 && (
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={async (e) => {
|
||||||
|
const id = parseInt(e.target.value, 10);
|
||||||
|
if (!id) return;
|
||||||
|
try { await QSLCopyTemplateToActiveProfile(id); await refreshSaved(); }
|
||||||
|
catch (err) { setError(String(err)); }
|
||||||
|
}}
|
||||||
|
className="h-7 rounded-md border border-border bg-background px-2 text-xs">
|
||||||
|
<option value="">Copy from another profile…</option>
|
||||||
|
{foreign.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>{f.profile_name} — {f.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
|
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{saved.map((t) => (
|
{saved.map((t) => (
|
||||||
|
|||||||
Vendored
+4
@@ -770,6 +770,8 @@ export function PopulateBuiltinReferences(arg1:string):Promise<number>;
|
|||||||
|
|
||||||
export function PublishLogNow():Promise<string>;
|
export function PublishLogNow():Promise<string>;
|
||||||
|
|
||||||
|
export function QSLCopyTemplateToActiveProfile(arg1:number):Promise<number>;
|
||||||
|
|
||||||
export function QSLDefaultTemplateID():Promise<number>;
|
export function QSLDefaultTemplateID():Promise<number>;
|
||||||
|
|
||||||
export function QSLDeleteTemplate(arg1:number):Promise<void>;
|
export function QSLDeleteTemplate(arg1:number):Promise<void>;
|
||||||
@@ -784,6 +786,8 @@ export function QSLGetEmailTemplates():Promise<main.QSLEmailTemplates>;
|
|||||||
|
|
||||||
export function QSLGetTemplate(arg1:number):Promise<string>;
|
export function QSLGetTemplate(arg1:number):Promise<string>;
|
||||||
|
|
||||||
|
export function QSLListOtherProfileTemplates():Promise<Array<main.QSLForeignTemplate>>;
|
||||||
|
|
||||||
export function QSLListTemplates():Promise<Array<main.QSLTemplateInfo>>;
|
export function QSLListTemplates():Promise<Array<main.QSLTemplateInfo>>;
|
||||||
|
|
||||||
export function QSLPhotoDataURL(arg1:number,arg2:string):Promise<string>;
|
export function QSLPhotoDataURL(arg1:number,arg2:string):Promise<string>;
|
||||||
|
|||||||
@@ -1482,6 +1482,10 @@ export function PublishLogNow() {
|
|||||||
return window['go']['main']['App']['PublishLogNow']();
|
return window['go']['main']['App']['PublishLogNow']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function QSLCopyTemplateToActiveProfile(arg1) {
|
||||||
|
return window['go']['main']['App']['QSLCopyTemplateToActiveProfile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function QSLDefaultTemplateID() {
|
export function QSLDefaultTemplateID() {
|
||||||
return window['go']['main']['App']['QSLDefaultTemplateID']();
|
return window['go']['main']['App']['QSLDefaultTemplateID']();
|
||||||
}
|
}
|
||||||
@@ -1510,6 +1514,10 @@ export function QSLGetTemplate(arg1) {
|
|||||||
return window['go']['main']['App']['QSLGetTemplate'](arg1);
|
return window['go']['main']['App']['QSLGetTemplate'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function QSLListOtherProfileTemplates() {
|
||||||
|
return window['go']['main']['App']['QSLListOtherProfileTemplates']();
|
||||||
|
}
|
||||||
|
|
||||||
export function QSLListTemplates() {
|
export function QSLListTemplates() {
|
||||||
return window['go']['main']['App']['QSLListTemplates']();
|
return window['go']['main']['App']['QSLListTemplates']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2785,6 +2785,24 @@ export namespace main {
|
|||||||
this.data_b64 = source["data_b64"];
|
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 {
|
export class QSLPresetInfo {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user