From c6bede7ae65e2a122e4f393de2e38a638cf44824 Mon Sep 17 00:00:00 2001 From: reaper47 Date: Sat, 18 Jan 2025 12:55:34 -0500 Subject: [PATCH] Add media to the exported zip --- internal/models/recipe.go | 23 +++++--- internal/models/recipe_test.go | 4 +- internal/models/schema-recipe.go | 13 +++-- internal/models/schema-recipe_test.go | 8 +-- internal/services/files.go | 75 +++++++++++++++++++-------- internal/services/files_apps.go | 68 ++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 43 deletions(-) diff --git a/internal/models/recipe.go b/internal/models/recipe.go index 8ee25a0e..38bdfb32 100644 --- a/internal/models/recipe.go +++ b/internal/models/recipe.go @@ -270,9 +270,14 @@ func (r *Recipe) Scale(yield int16) { // Schema creates the schema representation of the Recipe. func (r *Recipe) Schema() RecipeSchema { - var img string + var thumbnail string + images := make([]string, 0, len(r.Images)) if len(r.Images) > 0 { - img = r.Images[0].String() + app.ImageExt + thumbnail = app.Config.Address() + "/data/images/" + r.Images[0].String() + + for _, img := range r.Images { + images = append(images, app.Config.Address()+"/data/images/"+img.String()+app.ImageExt) + } } instructions := make([]HowToItem, 0, len(r.Instructions)) @@ -283,14 +288,18 @@ func (r *Recipe) Schema() RecipeSchema { video := &Videos{Values: make([]VideoObject, 0, len(r.Videos))} for i, v := range r.Videos { u := app.Config.Address() + "/data/videos/" + v.ID.String() + app.VideoExt + if v.ContentURL == "" { + v.ContentURL = u + } video.Values = append(video.Values, VideoObject{ AtType: "VideoObject", Name: "Video #" + strconv.Itoa(i+1), Description: "A video showing how to cook " + r.Name, - ThumbnailURL: nil, - ContentURL: u, - EmbedURL: u, + ID: v.ID, + ThumbnailURL: v.ThumbnailURL, + ContentURL: v.ContentURL, + EmbedURL: v.EmbedURL, UploadDate: v.UploadDate, Duration: v.Duration, Expires: time.Now().AddDate(1000, 0, 0), @@ -309,13 +318,13 @@ func (r *Recipe) Schema() RecipeSchema { DatePublished: r.CreatedAt.Format(time.DateOnly), Description: &Description{Value: r.Description}, Keywords: &Keywords{Values: strings.Join(r.Keywords, ",")}, - Image: &Image{Value: img}, + Image: &Image{Value: strings.Join(images, ";")}, Ingredients: &Ingredients{Values: r.Ingredients}, Instructions: &Instructions{Values: instructions}, Name: r.Name, NutritionSchema: r.Nutrition.Schema(strconv.Itoa(int(r.Yield))), PrepTime: formatDuration(r.Times.Prep), - ThumbnailURL: &ThumbnailURL{Value: img}, + ThumbnailURL: &ThumbnailURL{Value: thumbnail}, Tools: &Tools{Values: r.Tools}, TotalTime: formatDuration(r.Times.Total), Yield: &Yield{Value: r.Yield}, diff --git a/internal/models/recipe_test.go b/internal/models/recipe_test.go index 0be6f2d7..611dacf4 100644 --- a/internal/models/recipe_test.go +++ b/internal/models/recipe_test.go @@ -1045,8 +1045,8 @@ func TestRecipe_Schema(t *testing.T) { if schema.Keywords != nil && schema.Keywords.Values != "kw1,kw2,kw3" { t.Errorf("wanted keywords 'kw1,kw2,kw3' but got %q", schema.Keywords) } - v = imageUUID.String() - if schema.Image != nil && schema.Image.Value != v+app.ImageExt { + v = app.Config.Address() + "/data/images/" + imageUUID.String() + app.ImageExt + if schema.Image != nil && schema.Image.Value != v { t.Errorf("wanted uuid %q but got %q", v, schema.Image.Value) } diff --git a/internal/models/schema-recipe.go b/internal/models/schema-recipe.go index 04643bcf..1af1194d 100644 --- a/internal/models/schema-recipe.go +++ b/internal/models/schema-recipe.go @@ -514,11 +514,7 @@ type Image struct { // MarshalJSON encodes the image. func (i *Image) MarshalJSON() ([]byte, error) { - s := i.Value - if s != "" { - s = app.Config.Address() + "/data/images/" + s - } - return json.Marshal(s) + return json.Marshal(i.Value) } // UnmarshalJSON decodes the image according to the schema (https://schema.org/image). @@ -531,6 +527,11 @@ func (i *Image) UnmarshalJSON(data []byte) error { switch x := v.(type) { case string: + baseURL := app.Config.Address() + if strings.HasPrefix(x, baseURL) { + x = strings.Replace(x, baseURL+"/data/images/", "", 1) + x = strings.TrimSuffix(x, app.ImageExt) + } i.Value = x case []any: if len(x) > 0 { @@ -879,6 +880,8 @@ func (v *Videos) UnmarshalJSON(data []byte) error { vid.IsIFrame = true } + vid.ContentURL = strings.Replace(vid.ContentURL, app.Config.Address(), "", 1) + v.Values = append(v.Values, vid) } } diff --git a/internal/models/schema-recipe_test.go b/internal/models/schema-recipe_test.go index cc3aaba3..22b0d851 100644 --- a/internal/models/schema-recipe_test.go +++ b/internal/models/schema-recipe_test.go @@ -797,13 +797,7 @@ func TestRecipeSchema_Marshal(t *testing.T) { t.Errorf("got keywords %q; want %q", v, rs.Keywords.Values) } case "image": - want := "/data/images/" + rs.Image.Value - s := v.(string) - _, after, ok := strings.Cut(v.(string), "/") - if ok { - s = "/" + after - } - if s != want { + if v.(string) != rs.Image.Value { t.Errorf("got image %q; want %q", v, rs.Image.Value) } case "recipeIngredient": diff --git a/internal/services/files.go b/internal/services/files.go index b39b6baf..22581b38 100644 --- a/internal/services/files.go +++ b/internal/services/files.go @@ -63,9 +63,10 @@ type Files struct { } type exportData struct { - recipeName string - recipeImage uuid.UUID - data []byte + recipeName string + data []byte + images []uuid.UUID + videos []models.VideoObject } // BackupGlobal backs up the whole database to the backup directory. @@ -461,6 +462,7 @@ func (f *Files) ExportRecipes(recipes models.Recipes, fileType models.FileType, progress <- i } + // Add recipe to zip out, err := writer.Create(e.recipeName + "/recipe" + fileType.Ext()) if err != nil { return nil, err @@ -470,6 +472,46 @@ func (f *Files) ExportRecipes(recipes models.Recipes, fileType models.FileType, if err != nil { return nil, err } + + // Add images to zip + for _, u := range e.images { + fileName := u.String() + app.ImageExt + + file, err := os.ReadFile(filepath.Join(app.ImagesDir, fileName)) + if err != nil { + return nil, err + } + + out, err = writer.Create(e.recipeName + "/" + fileName) + if err != nil { + return nil, err + } + + _, err = out.Write(file) + if err != nil { + return nil, err + } + } + + // Add videos to zip + for _, video := range slices.DeleteFunc(e.videos, func(v models.VideoObject) bool { return v.ID == uuid.Nil }) { + fileName := video.ID.String() + app.VideoExt + + file, err := os.ReadFile(filepath.Join(app.VideosDir, fileName)) + if err != nil { + return nil, err + } + + out, err = writer.Create(e.recipeName + "/" + fileName) + if err != nil { + return nil, err + } + + _, err = out.Write(file) + if err != nil { + return nil, err + } + } } case models.PDF: processed := make(map[string]struct{}) @@ -516,15 +558,11 @@ func exportRecipesJSON(recipes models.Recipes) []exportData { continue } - var img uuid.UUID - if len(r.Images) > 0 { - img = r.Images[0] - } - data[i] = exportData{ - recipeName: r.Name, - recipeImage: img, - data: xb, + recipeName: r.Name, + data: xb, + images: r.Images, + videos: r.Videos, } } return data @@ -533,15 +571,9 @@ func exportRecipesJSON(recipes models.Recipes) []exportData { func exportRecipesPDF(recipes models.Recipes) []exportData { data := make([]exportData, len(recipes)) for i, r := range recipes { - var img uuid.UUID - if len(r.Images) > 0 { - img = r.Images[0] - } - data[i] = exportData{ - recipeName: r.Name, - recipeImage: img, - data: recipeToPDF(&r), + recipeName: r.Name, + data: recipeToPDF(&r), } } return data @@ -998,9 +1030,8 @@ func (f *Files) ExportCookbook(cookbook models.Cookbook, fileType models.FileTyp func exportCookbookToPDF(cookbook *models.Cookbook) exportData { return exportData{ - recipeName: cookbook.Title, - recipeImage: cookbook.Image, - data: cookbookToPDF(cookbook), + recipeName: cookbook.Title, + data: cookbookToPDF(cookbook), } } diff --git a/internal/services/files_apps.go b/internal/services/files_apps.go index 1329fd86..10f61c9e 100644 --- a/internal/services/files_apps.go +++ b/internal/services/files_apps.go @@ -9,12 +9,14 @@ import ( "fmt" "github.com/PuerkitoBio/goquery" "github.com/google/uuid" + "github.com/reaper47/recipya/internal/app" "github.com/reaper47/recipya/internal/integrations" "github.com/reaper47/recipya/internal/models" "github.com/reaper47/recipya/internal/utils/extensions" "io" "log/slog" "mime/multipart" + "os" "path/filepath" "slices" "strings" @@ -292,6 +294,72 @@ func (f *Files) processRecipeFiles(zr *zip.Reader) models.Recipes { } recipes = append(recipes, recipe) recipeNumber++ + case app.ImageExt: + parts := strings.Split(zf.Name, "/") + + imagePath := filepath.Join(app.ImagesDir, parts[len(parts)-1]) + _, err = os.Stat(imagePath) + if errors.Is(err, os.ErrNotExist) { + dest, err := os.Create(imagePath) + if err != nil { + slog.Error("Failed to create image file", "file", zf, "error", err) + continue + } + + _, err = io.Copy(dest, openedFile) + if err != nil { + _ = dest.Close() + slog.Error("Failed to copy image file", "file", zf, "error", err) + continue + } + + _ = dest.Close() + } + + thumbnailPath := filepath.Join(app.ThumbnailsDir, parts[len(parts)-1]) + _, err = os.Stat(thumbnailPath) + if errors.Is(err, os.ErrNotExist) { + thumbnail, err := os.Create(thumbnailPath) + if err != nil { + slog.Error("Failed to create thumbnail file", "file", zf, "error", err) + continue + } + + imageFile, err := os.Open(imagePath) + if err != nil { + slog.Error("Failed to create image file", "file", zf, "error", err) + continue + } + + _, err = io.Copy(thumbnail, imageFile) + if err != nil { + _ = imageFile.Close() + _ = thumbnail.Close() + slog.Error("Failed to copy thumbnail file", "file", zf, "error", err) + continue + } + + _ = imageFile.Close() + _ = thumbnail.Close() + } + + case app.VideoExt: + parts := strings.Split(zf.Name, "/") + path := filepath.Join(app.VideosDir, parts[len(parts)-1]) + _, err = os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + dest, err := os.Create(path) + if err != nil { + slog.Error("Failed to create video file", "file", zf, "error", err) + continue + } + + _, err = io.Copy(dest, openedFile) + if err != nil { + slog.Error("Failed to copy video file", "file", zf, "error", err) + continue + } + } } _ = openedFile.Close()