all repos — weimar @ 8dcf6e79e30fa40a7f7da17169314b250793210c

Unnamed repository; edit this file 'description' to name the repository.

feat: add weimar image rescan-innate CLI command

Adds ListAllImages and UpdateImageInnateTags DB queries, and a
rescan-innate subcommand that computes innate tags for all images
missing them — useful after upgrading from an older version.
Maxwell Jensen maxwelljensen@posteo.net
Thu, 14 May 2026 16:45:35 +0200
commit

8dcf6e79e30fa40a7f7da17169314b250793210c

parent

0137493bbdbd370d56c8a26873843ff02b378485

3 files changed, 135 insertions(+), 0 deletions(-)

jump to
M cmd/weimar/image.gocmd/weimar/image.go

@@ -64,7 +64,60 @@ return nil

}, } +var imageRescanInnateCmd = &cobra.Command{ + Use: "rescan-innate", + Short: "Recompute innate tags for all images missing them", + Long: `Scans every image in the database and computes innate tags +(image, video, gif, lowres, highres, >10 sec, <10 sec) for any +record that has no innate tags yet. This is useful after upgrading +from an older version that did not support innate tags.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.FromViper() + + database, err := db.Open(cfg.Database.Path) + if err != nil { + return fmt.Errorf("opening database: %w", err) + } + defer database.Close() + + st, err := storage.New(cfg.Storage.Path) + if err != nil { + return fmt.Errorf("opening storage: %w", err) + } + + images, err := database.ListAllImages(cmd.Context()) + if err != nil { + return fmt.Errorf("listing images: %w", err) + } + + var updated int + for _, img := range images { + if len(img.InnateTags) > 0 { + continue + } + + videoPath := "" + if storage.IsVideo(img.MimeType) { + videoPath = st.AbsPath(img.StoragePath) + } + tags := storage.ComputeInnateTags(img.MimeType, img.Width, img.Height, videoPath) + + if err := database.UpdateImageInnateTags(cmd.Context(), img.ID, tags); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "error updating image %d: %v\n", img.ID, err) + continue + } + updated++ + fmt.Fprintf(cmd.OutOrStdout(), " image %d (%s): %v\n", img.ID, img.Filename, tags) + } + + fmt.Fprintf(cmd.OutOrStdout(), "done — updated %d image(s)\n", updated) + return nil + }, +} + func init() { rootCmd.AddCommand(imageCmd) imageCmd.AddCommand(imageDeleteCmd) + imageCmd.AddCommand(imageRescanInnateCmd) }
M internal/db/db_test.gointernal/db/db_test.go

@@ -795,3 +795,53 @@ }

}) } } + +func TestListAllImages(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + u, _ := d.CreateUser(ctx, "user", hashPassword(t, "pw"), false) + + // Create two images. + for i := range 2 { + _, err := d.CreateImage(ctx, &Image{ + Filename: fmt.Sprintf("%d.jpg", i), + StoragePath: fmt.Sprintf("a/b/c/%d.jpg", i), + UploadedBy: u.ID, + FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", + }) + if err != nil { + t.Fatalf("CreateImage: %v", err) + } + } + + images, err := d.ListAllImages(ctx) + if err != nil { + t.Fatalf("ListAllImages: %v", err) + } + if len(images) != 2 { + t.Fatalf("got %d images, want 2", len(images)) + } +} + +func TestUpdateImageInnateTags(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + u, _ := d.CreateUser(ctx, "user", hashPassword(t, "pw"), false) + + img, _ := d.CreateImage(ctx, &Image{ + Filename: "test.jpg", StoragePath: "a/b/c/test.jpg", + UploadedBy: u.ID, FileSize: 100, Width: 100, Height: 100, MimeType: "image/jpeg", + }) + + if err := d.UpdateImageInnateTags(ctx, img.ID, []string{"image", "lowres"}); err != nil { + t.Fatalf("UpdateImageInnateTags: %v", err) + } + + got, _ := d.GetImageByID(ctx, img.ID) + if len(got.InnateTags) != 2 { + t.Fatalf("expected 2 innate tags, got %d: %v", len(got.InnateTags), got.InnateTags) + } + if got.InnateTags[0] != "image" || got.InnateTags[1] != "lowres" { + t.Fatalf("innate tags = %v, want [image lowres]", got.InnateTags) + } +}
M internal/db/queries.gointernal/db/queries.go

@@ -354,6 +354,38 @@ }

return nil } +// ListAllImages returns all images ordered by id ascending, without tag/uploader +// filtering. Used by CLI commands that need to iterate over every record. +func (d *DB) ListAllImages(ctx context.Context) ([]Image, error) { + rows, err := d.db.QueryContext(ctx, + `SELECT id, filename, storage_path, thumbnail_path, + uploaded_by, file_size, width, height, mime_type, + innate_tags, created_at, updated_at + FROM images ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("list all images: %w", err) + } + defer rows.Close() + + images := make([]Image, 0) + for rows.Next() { + img, err := scanImage(rows) + if err != nil { + return nil, err + } + images = append(images, *img) + } + return images, rows.Err() +} + +// UpdateImageInnateTags sets the innate_tags column for a single image. +func (d *DB) UpdateImageInnateTags(ctx context.Context, id int64, tags []string) error { + _, err := d.db.ExecContext(ctx, + `UPDATE images SET innate_tags = ?, updated_at = ? WHERE id = ?`, + marshalStrings(tags), now(), id) + return err +} + type imageScanner interface { Scan(dest ...any) error }