fix(qsl): copy a design's pictures, not just its row
copyDirContents wrote into a destination it never created, so the first
os.Create returned ENOENT. The caller was written as "ignore os.IsNotExist"
— meant for a design with no pictures at all — and that guard matched the
failed write exactly: nothing was copied, no error surfaced, and validation
two lines later reported "copied design is incomplete: hero photo file
img_… not found" with no way to tell why.
Three fixes, one cause:
- copyDirContents creates its destination;
- the caller stats the source folder instead of pattern-matching an error,
so a genuine copy failure is reported and rolls back both the row and
the folder;
- DuplicateProfile had the same defect in its own form — an INSERT … SELECT
that cloned the template rows and left their photos behind, giving the
new profile designs with no thumbnail and nothing to print. It now copies
row and folder together, per template, since each needs its id first.
A test reproduces the original failure: it fails with the exact ENOENT that
was being swallowed.
This commit is contained in:
+63
-5
@@ -827,18 +827,32 @@ func (a *App) QSLCopyTemplateToActiveProfile(id int64) (int64, error) {
|
||||
}
|
||||
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 design with no pictures at all has no folder, and that is not a failure.
|
||||
// Anything else IS: this used to be written as "ignore os.IsNotExist", which
|
||||
// also swallowed every ENOENT raised while copying — so a failed copy looked
|
||||
// like a design that had nothing to copy, and the operator got "hero photo
|
||||
// not found" with no clue why.
|
||||
if _, statErr := os.Stat(srcDir); statErr == nil {
|
||||
if err := copyDirContents(srcDir, dstDir); err != nil {
|
||||
// Roll back rather than leave a design whose pictures are missing.
|
||||
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||
_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
|
||||
return 0, fmt.Errorf("copy template assets: %w", err)
|
||||
}
|
||||
}
|
||||
// Every failure from here rolls back BOTH halves: deleting only the row would
|
||||
// leave the copied pictures on disk under an id nothing refers to.
|
||||
rollback := func() {
|
||||
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||
return 0, fmt.Errorf("copy template assets: %w", err)
|
||||
_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
|
||||
}
|
||||
t, err := qslcard.Parse([]byte(rec.JSON))
|
||||
if err != nil {
|
||||
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||
rollback()
|
||||
return 0, err
|
||||
}
|
||||
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dstDir)); err != nil {
|
||||
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||
rollback()
|
||||
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)",
|
||||
@@ -846,6 +860,50 @@ func (a *App) QSLCopyTemplateToActiveProfile(id int64) (int64, error) {
|
||||
return rec.ID, nil
|
||||
}
|
||||
|
||||
// copyQSLTemplatesToProfile duplicates one profile's QSL designs into another,
|
||||
// PICTURES INCLUDED.
|
||||
//
|
||||
// This was an INSERT … SELECT, which cloned the rows and nothing else. A design
|
||||
// references its photos by name, relative to its own asset folder, so every
|
||||
// duplicated design pointed at a folder that did not exist: no thumbnail in the
|
||||
// designer, and nothing to print. The rows looked right, which is why it went
|
||||
// unnoticed — the damage is entirely on disk.
|
||||
//
|
||||
// Row by row rather than in one statement, because each new design needs its id
|
||||
// before it can own a folder. A design whose files cannot be copied is dropped
|
||||
// rather than kept empty: an operator who sees the design listed will believe
|
||||
// it works.
|
||||
func (a *App) copyQSLTemplatesToProfile(fromProfile, toProfile int64) error {
|
||||
if a.qslTemplates == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
all, err := a.qslTemplates.List(a.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, src := range all {
|
||||
if src.ProfileID == nil || *src.ProfileID != fromProfile {
|
||||
continue
|
||||
}
|
||||
rec := qslcard.Record{Name: src.Name, JSON: src.JSON, IsDefault: src.IsDefault}
|
||||
to := toProfile
|
||||
rec.ProfileID = &to
|
||||
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
|
||||
return err
|
||||
}
|
||||
srcDir := qslcard.TemplateDir(a.qslDir(), src.ID)
|
||||
if _, statErr := os.Stat(srcDir); statErr != nil {
|
||||
continue // a design with no pictures at all — nothing to carry over
|
||||
}
|
||||
if err := copyDirContents(srcDir, qslcard.TemplateDir(a.qslDir(), rec.ID)); err != nil {
|
||||
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||
_ = qslcard.RemoveTemplateDir(a.qslDir(), rec.ID)
|
||||
return fmt.Errorf("copy assets of %q: %w", src.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmptyStr(a, b string) string {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
return a
|
||||
|
||||
Reference in New Issue
Block a user