Author SHA1 Message Date
juliaandClaude Sonnet 4.6 649c9687ac feat: fruit overview with thumbnails and card layout (story #07)
- Auto-crop thumbnails on image upload (Go stdlib, no external deps):
  find non-background bounding box, pad 5px, scale to ≤300px, encode JPEG
- Add thumbnail_data BYTEA column to fruit_images and publication_fruit_images
- New GET /api/v1/fruits/:id/images/:imageId/thumbnail endpoint (fallback to full data)
- New GET /api/v1/publications/:id/fruit-images/:imgId/thumbnail endpoint
- New POST /api/v1/admin/backfill-thumbnails to retroactively generate thumbnails
- ListFruits response now includes synonyms and thumbnail_url per fruit
- Replace FruitList table with responsive FruitCard grid (thumbnail + name + OSDB ID + type + synonyms)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:02:14 +02:00
julia 2525e8b68d Merge pull request 'feat: fruit search by name/synonym and type filter (story #06)' (#6) from feature/06-fruit-search into main
Reviewed-on: #6
2026-06-18 10:58:37 +00:00
juliaandClaude Sonnet 4.6 78c23557da feat: fruit search by name/synonym and type filter (story #06)
Backend: List repo query gains name (ILIKE with wildcard escaping on
name + synonym LEFT JOIN, SELECT DISTINCT) and types (ANY cast) params;
handler parses ?name= and ?type=, resolves combined-type aliases from
domain.FruitTypeAliases, validates plain types against validFruitTypes
to prevent Postgres enum cast errors. ORDER BY f.name, f.id for stable
pagination.

Frontend: listFruits gains optional {name, type} params; fruitStore adds
searchName/searchType state and setSearch action (resets offset, refetches);
FruitList.vue wires text input (debounced 300 ms + Enter) and flat type
dropdown (17 enum values + 4 aliases per spec §5 order); debounce timer
cleared on unmount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:46:38 +02:00
julia 50bfa30b06 Merge pull request 'feat: import existing publications from XML (story #05)' (#5) from feature/05-import-publications into main
Reviewed-on: #5
2026-06-18 10:20:01 +00:00
juliaandClaude Sonnet 4.6 e8d48d4fcb feat: import existing publications from XML (story #05)
scripts/import_publications.py imports all osw=1 publications from
03-data/osws.xml — upserts pub rows, loads cover images, scans
03-data/osdb/{pubId}/ for fruit images and PDFs, links matched fruits
by osdb_number. Idempotent re-runs. 17 unit tests. Makefile updated
to include import_publications_test in make test. Added
scripts/README_import.md with run order. Added spec-first rule to
.claude/CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 11:47:45 +02:00
julia dfa3d91ab9 Merge pull request 'feat: publication management with cover images, PDFs, and fruit images (story #04)' (#4) from feature/04-fruit-publications into main
Reviewed-on: #4
2026-06-18 09:27:59 +00:00
26 changed files with 1358 additions and 77 deletions
+11
View File
@@ -0,0 +1,11 @@
## Taiga
- TAIGA_URL: https://taiga.db-extern.de
- TAIGA_PROJECT_SLUG: obstsortendatenbank
## Stories
Before implementing a story: read its spec file in docs/planning/specs/ first.
## Branching
Stories developed sequentially; prior story branch may not be merged to main yet.
Before creating feature branch: run `git branch -r | grep feature/` — if predecessor
story branch exists on remote and is unmerged, base new branch on it, not main.
+1 -1
View File
@@ -34,7 +34,7 @@ run-frontend:
test:
cd backend && go test ./...
cd frontend && npm run test -- --run
cd scripts && python3 -m unittest import_fruits_test -v
cd scripts && python3 -m unittest import_fruits_test import_publications_test -v
## fmt: format all code
fmt:
+2 -1
View File
@@ -6,7 +6,8 @@ A full-stack fruit-variety database (Go + Vue 3).
- Users can view a "Hello, OSDB!" landing page that confirms the backend is reachable via the Vite proxy.
- Users can create, view, edit, and delete fruit varieties, including managing synonyms and uploading images.
- Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`.
- Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`, and then import all publications (cover images, linked fruits, PDFs, fruit images) using `scripts/import_publications.py`.
- Users can search fruits by name or synonym (case-insensitive, debounced) and filter by type or combined-type alias (e.g. "Birnen- und Quittensorten") from the fruit list.
- Users can manage publications (books, catalogues) with cover images, linked fruits, per-fruit PDF descriptions, and fruit images; fruit detail pages show publication descriptions and images.
## Quick Start
+7
View File
@@ -39,6 +39,7 @@ func New(pool *pgxpool.Pool) *echo.Echo {
g.GET("/:id/images", fruits.ListImages)
g.POST("/:id/images", fruits.UploadImage, middleware.BodyLimit("5M"))
g.GET("/:id/images/:imageId", fruits.ServeImage)
g.GET("/:id/images/:imageId/thumbnail", fruits.ServeThumbnail)
g.DELETE("/:id/images/:imageId", fruits.DeleteImage)
// Publications (story #04)
@@ -68,7 +69,13 @@ func New(pool *pgxpool.Pool) *echo.Echo {
p.GET("/:id/fruit-images", pubs.ListFruitImages)
p.POST("/:id/fruit-images", pubs.UploadFruitImage, middleware.BodyLimit("5M"))
p.GET("/:id/fruit-images/:imgId", pubs.ServeFruitImage)
p.GET("/:id/fruit-images/:imgId/thumbnail", pubs.ServeFruitImageThumbnail)
p.DELETE("/:id/fruit-images/:imgId", pubs.DeleteFruitImage)
// Admin (story #07)
admin := handler.NewAdminHandler(fruitRepo, pubRepo)
adminGroup := api.Group("/admin")
adminGroup.POST("/backfill-thumbnails", admin.BackfillThumbnails)
return e
}
+9
View File
@@ -2,6 +2,14 @@ package domain
import "time"
// FruitTypeAliases maps combined-type alias labels to the enum values they expand to.
var FruitTypeAliases = map[string][]string{
"Birnen- und Quittensorten": {"Birnensorten", "Quittensorten"},
"Aprikosen und Pfirsiche": {"Aprikosen", "Pfirsiche"},
"Mirabellen und Reineclauden": {"Mirabellen", "Renekloden"},
"Pflaumen und Zwetschen": {"Pflaumen", "Zwetschen"},
}
type Fruit struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -10,6 +18,7 @@ type Fruit struct {
FruitType string `json:"fruit_type"`
Synonyms []string `json:"synonyms"`
Images []FruitImage `json:"images"`
ThumbnailURL *string `json:"thumbnail_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+44
View File
@@ -0,0 +1,44 @@
package handler
import (
"context"
"net/http"
"github.com/labstack/echo/v4"
)
type FruitBackfiller interface {
BackfillThumbnails(ctx context.Context) (int, error)
}
type PubBackfiller interface {
BackfillThumbnails(ctx context.Context) (int, error)
}
type AdminHandler struct {
fruitRepo FruitBackfiller
pubRepo PubBackfiller
}
func NewAdminHandler(fruitRepo FruitBackfiller, pubRepo PubBackfiller) *AdminHandler {
return &AdminHandler{fruitRepo: fruitRepo, pubRepo: pubRepo}
}
func (h *AdminHandler) BackfillThumbnails(c echo.Context) error {
ctx := c.Request().Context()
fruitCount, err := h.fruitRepo.BackfillThumbnails(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "backfill failed: " + err.Error()})
}
pubCount, err := h.pubRepo.BackfillThumbnails(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "backfill failed: " + err.Error()})
}
return c.JSON(http.StatusOK, map[string]int{
"fruit_images_processed": fruitCount,
"pub_fruit_images_processed": pubCount,
})
}
+31 -2
View File
@@ -21,7 +21,7 @@ var (
// FruitRepository is the consumer-defined interface the handler depends on.
// The pg implementation in the repository package satisfies this structurally.
type FruitRepository interface {
List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error)
List(ctx context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error)
Get(ctx context.Context, id int) (domain.Fruit, error)
Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error)
Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error)
@@ -29,6 +29,7 @@ type FruitRepository interface {
ListImages(ctx context.Context, fruitID int) ([]domain.FruitImage, error)
AddImage(ctx context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error)
GetImageData(ctx context.Context, fruitID, imageID int) ([]byte, error)
GetThumbnailData(ctx context.Context, fruitID, imageID int) ([]byte, error)
DeleteImage(ctx context.Context, fruitID, imageID int) error
}
@@ -107,7 +108,18 @@ func (h *FruitHandler) List(c echo.Context) error {
}
}
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset)
name := c.QueryParam("name")
var types []string
if typeParam := c.QueryParam("type"); typeParam != "" {
if expanded, ok := domain.FruitTypeAliases[typeParam]; ok {
types = expanded
} else if _, ok := validFruitTypes[typeParam]; ok {
types = []string{typeParam}
}
// unknown typeParam → types stays nil → no filter applied
}
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset, name, types)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
@@ -280,3 +292,20 @@ func (h *FruitHandler) DeleteImage(c echo.Context) error {
}
return c.NoContent(http.StatusNoContent)
}
func (h *FruitHandler) ServeThumbnail(c echo.Context) error {
fruitID, err := parseID(c, "id")
if err != nil {
return err
}
imageID, err := parseID(c, "imageId")
if err != nil {
return err
}
data, err := h.repo.GetThumbnailData(c.Request().Context(), fruitID, imageID)
if err != nil {
return mapRepoError(c, err)
}
contentType := http.DetectContentType(data)
return c.Blob(http.StatusOK, contentType, data)
}
+105 -1
View File
@@ -52,9 +52,32 @@ func newFakeRepo() *fakeRepo {
}
}
func (r *fakeRepo) List(_ context.Context, limit, offset int) ([]domain.Fruit, int, error) {
func (r *fakeRepo) List(_ context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error) {
nameLower := strings.ToLower(name)
typeSet := make(map[string]struct{}, len(types))
for _, t := range types {
typeSet[t] = struct{}{}
}
all := make([]domain.Fruit, 0, len(r.fruits))
for _, f := range r.fruits {
if name != "" {
nameMatch := strings.Contains(strings.ToLower(f.Name), nameLower)
synMatch := false
for _, s := range f.Synonyms {
if strings.Contains(strings.ToLower(s), nameLower) {
synMatch = true
break
}
}
if !nameMatch && !synMatch {
continue
}
}
if len(types) > 0 {
if _, ok := typeSet[f.FruitType]; !ok {
continue
}
}
all = append(all, f)
}
total := len(all)
@@ -171,6 +194,14 @@ func (r *fakeRepo) GetImageData(_ context.Context, fruitID, imageID int) ([]byte
return r.imageData[imageID], nil
}
func (r *fakeRepo) GetThumbnailData(_ context.Context, fruitID, imageID int) ([]byte, error) {
img, ok := r.images[imageID]
if !ok || img.FruitID != fruitID {
return nil, handler.ErrNotFound
}
return r.imageData[imageID], nil
}
func (r *fakeRepo) DeleteImage(_ context.Context, fruitID, imageID int) error {
img, ok := r.images[imageID]
if !ok || img.FruitID != fruitID {
@@ -241,6 +272,79 @@ func TestFruitList_WithItems(t *testing.T) {
}
}
func TestFruitList_FilterByName(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Cox Orange", OSDBNumber: "A002", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?name=Boskop", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.List(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var resp domain.FruitListResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 1 {
t.Fatalf("want 1 result got %d", resp.Total)
}
if resp.Items[0].Name != "Boskop" {
t.Fatalf("want Boskop got %s", resp.Items[0].Name)
}
}
func TestFruitList_FilterByType(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Williams", OSDBNumber: "B001", FruitType: "Birnensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?type=Apfelsorten", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.List(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var resp domain.FruitListResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 1 {
t.Fatalf("want 1 result got %d", resp.Total)
}
if resp.Items[0].FruitType != "Apfelsorten" {
t.Fatalf("want Apfelsorten got %s", resp.Items[0].FruitType)
}
}
func TestFruitList_AliasExpandsToMultipleTypes(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Williams", OSDBNumber: "B001", FruitType: "Birnensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Quitte", OSDBNumber: "Q001", FruitType: "Quittensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[3] = domain.Fruit{ID: 3, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?type=Birnen-+und+Quittensorten", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.List(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var resp domain.FruitListResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 2 {
t.Fatalf("want 2 results (Birnensorten + Quittensorten) got %d", resp.Total)
}
}
// -- Get --
func TestFruitGet_Found(t *testing.T) {
@@ -42,6 +42,7 @@ type PublicationRepository interface {
ListFruitImages(ctx context.Context, pubID int) ([]domain.PublicationFruitImage, error)
AddFruitImage(ctx context.Context, pubID, fruitID int, filename *string, data []byte) (domain.PublicationFruitImage, error)
GetFruitImageData(ctx context.Context, pubID, imgID int) ([]byte, error)
GetFruitImageThumbnailData(ctx context.Context, pubID, imgID int) ([]byte, error)
DeleteFruitImage(ctx context.Context, pubID, imgID int) error
GetFruitDescriptions(ctx context.Context, fruitID int) ([]domain.PublicationDescription, error)
@@ -386,6 +387,23 @@ func (h *PublicationHandler) ServeFruitImage(c echo.Context) error {
return c.Blob(http.StatusOK, contentType, data)
}
func (h *PublicationHandler) ServeFruitImageThumbnail(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
imgID, err := parseID(c, "imgId")
if err != nil {
return err
}
data, err := h.repo.GetFruitImageThumbnailData(c.Request().Context(), pubID, imgID)
if err != nil {
return mapPubRepoError(c, err)
}
contentType := http.DetectContentType(data)
return c.Blob(http.StatusOK, contentType, data)
}
func (h *PublicationHandler) DeleteFruitImage(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
@@ -265,6 +265,14 @@ func (r *fakePubRepo) GetFruitImageData(_ context.Context, pubID, imgID int) ([]
return r.fruitImgData[imgID], nil
}
func (r *fakePubRepo) GetFruitImageThumbnailData(_ context.Context, pubID, imgID int) ([]byte, error) {
img, ok := r.fruitImages[imgID]
if !ok || img.PublicationID != pubID {
return nil, handler.ErrNotFound
}
return r.fruitImgData[imgID], nil
}
func (r *fakePubRepo) DeleteFruitImage(_ context.Context, pubID, imgID int) error {
img, ok := r.fruitImages[imgID]
if !ok || img.PublicationID != pubID {
+137
View File
@@ -0,0 +1,137 @@
package imaging
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/jpeg"
_ "image/png"
)
const (
bgThreshold = 10
padding = 5
maxDim = 300
)
// CropToContent auto-crops src to the bounding box of non-background pixels,
// adds padding, scales to fit within maxDim×maxDim, and re-encodes as JPEG.
// Returns the original bytes unchanged if decoding fails.
func CropToContent(src []byte) ([]byte, error) {
img, _, err := image.Decode(bytes.NewReader(src))
if err != nil {
return src, nil
}
bounds := img.Bounds()
if bounds.Dx() == 0 || bounds.Dy() == 0 {
return src, nil
}
bgColor := img.At(bounds.Min.X, bounds.Min.Y)
minX, minY, maxX, maxY := findContentBounds(img, bgColor, bounds)
if minX > maxX || minY > maxY {
return src, nil
}
minX = max(bounds.Min.X, minX-padding)
minY = max(bounds.Min.Y, minY-padding)
maxX = min(bounds.Max.X-1, maxX+padding)
maxY = min(bounds.Max.Y-1, maxY+padding)
cropRect := image.Rect(minX, minY, maxX+1, maxY+1)
cropped := cropImage(img, cropRect)
scaled := scaleDown(cropped)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 85}); err != nil {
return src, nil
}
return buf.Bytes(), nil
}
func findContentBounds(img image.Image, bg color.Color, bounds image.Rectangle) (minX, minY, maxX, maxY int) {
minX = bounds.Max.X
minY = bounds.Max.Y
maxX = bounds.Min.X - 1
maxY = bounds.Min.Y - 1
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if channelDiff(img.At(x, y), bg) > bgThreshold {
if x < minX {
minX = x
}
if y < minY {
minY = y
}
if x > maxX {
maxX = x
}
if y > maxY {
maxY = y
}
}
}
}
return
}
func channelDiff(c1, c2 color.Color) int {
r1, g1, b1, _ := c1.RGBA()
r2, g2, b2, _ := c2.RGBA()
dr := abs(int(r1>>8) - int(r2>>8))
dg := abs(int(g1>>8) - int(g2>>8))
db := abs(int(b1>>8) - int(b2>>8))
return max(dr, max(dg, db))
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
type subImager interface {
SubImage(r image.Rectangle) image.Image
}
func cropImage(img image.Image, rect image.Rectangle) image.Image {
if si, ok := img.(subImager); ok {
return si.SubImage(rect)
}
dst := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
draw.Draw(dst, dst.Bounds(), img, rect.Min, draw.Src)
return dst
}
func scaleDown(img image.Image) image.Image {
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w <= maxDim && h <= maxDim {
return img
}
scaleW := float64(maxDim) / float64(w)
scaleH := float64(maxDim) / float64(h)
scale := scaleW
if scaleH < scale {
scale = scaleH
}
newW := max(1, int(float64(w)*scale))
newH := max(1, int(float64(h)*scale))
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
for y := 0; y < newH; y++ {
for x := 0; x < newW; x++ {
srcX := bounds.Min.X + int(float64(x)/scale)
srcY := bounds.Min.Y + int(float64(y)/scale)
dst.Set(x, y, img.At(srcX, srcY))
}
}
return dst
}
+153
View File
@@ -0,0 +1,153 @@
package imaging_test
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"testing"
"osdb/internal/imaging"
)
func makeWhitePNG(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.White)
}
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
func makeContentPNG(w, h, contentX, contentY, contentW, contentH int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.White)
}
}
for y := contentY; y < contentY+contentH; y++ {
for x := contentX; x < contentX+contentW; x++ {
img.Set(x, y, color.RGBA{R: 100, G: 50, B: 30, A: 255})
}
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
func imageDims(data []byte) (int, int) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return 0, 0
}
b := img.Bounds()
return b.Dx(), b.Dy()
}
func TestCropToContent_InvalidData(t *testing.T) {
result, err := imaging.CropToContent([]byte("not an image"))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !bytes.Equal(result, []byte("not an image")) {
t.Error("expected original bytes returned on decode failure")
}
}
func TestCropToContent_AllBackground(t *testing.T) {
src := makeWhitePNG(100, 100)
result, err := imaging.CropToContent(src)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) == 0 {
t.Error("expected non-empty result")
}
}
func TestCropToContent_CropsToContent(t *testing.T) {
// 200x200 white image with a 20x20 content block at (80,80)
src := makeContentPNG(200, 200, 80, 80, 20, 20)
result, err := imaging.CropToContent(src)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
w, h := imageDims(result)
if w >= 200 || h >= 200 {
t.Errorf("expected smaller result, got %dx%d", w, h)
}
}
func TestCropToContent_PreservesContentWithPadding(t *testing.T) {
// 200x200 white image with a 30x30 red block at (85,85)
src := makeContentPNG(200, 200, 85, 85, 30, 30)
result, err := imaging.CropToContent(src)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Result should be smaller than original but include padding
w, h := imageDims(result)
if w == 0 || h == 0 {
t.Error("expected valid image dimensions")
}
if w >= 200 || h >= 200 {
t.Errorf("expected crop, got %dx%d", w, h)
}
}
func TestCropToContent_ScalesDownLargeImage(t *testing.T) {
// 600x600 image with content filling most of it
src := makeContentPNG(600, 600, 10, 10, 580, 580)
result, err := imaging.CropToContent(src)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
w, h := imageDims(result)
if w > 300 || h > 300 {
t.Errorf("expected scale-down to ≤300, got %dx%d", w, h)
}
}
func TestCropToContent_ReturnsJPEG(t *testing.T) {
src := makeContentPNG(100, 100, 20, 20, 60, 60)
result, err := imaging.CropToContent(src)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// JPEG magic bytes: FF D8 FF
if len(result) < 3 || result[0] != 0xFF || result[1] != 0xD8 {
// Check if we got back original (all-background case returns src)
if !bytes.Equal(result, src) {
t.Error("expected JPEG output")
}
}
}
func TestCropToContent_JPEGInput(t *testing.T) {
// Create a JPEG input
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
for y := 0; y < 100; y++ {
for x := 0; x < 100; x++ {
if x > 20 && x < 80 && y > 20 && y < 80 {
img.Set(x, y, color.RGBA{R: 200, G: 100, B: 50, A: 255})
} else {
img.Set(x, y, color.White)
}
}
}
var buf bytes.Buffer
_ = jpeg.Encode(&buf, img, nil)
result, err := imaging.CropToContent(buf.Bytes())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) == 0 {
t.Error("expected non-empty result")
}
}
+101 -9
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
@@ -11,6 +12,7 @@ import (
"osdb/internal/domain"
"osdb/internal/handler"
"osdb/internal/imaging"
)
type FruitRepo struct {
@@ -25,6 +27,10 @@ func imageURL(fruitID, imageID int) string {
return fmt.Sprintf("/api/v1/fruits/%d/images/%d", fruitID, imageID)
}
func thumbnailURL(fruitID, imageID int) string {
return fmt.Sprintf("/api/v1/fruits/%d/images/%d/thumbnail", fruitID, imageID)
}
func mapPgError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
@@ -33,15 +39,42 @@ func mapPgError(err error) error {
return err
}
func (r *FruitRepo) List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error) {
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
func (r *FruitRepo) List(ctx context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error) {
escaped := escapeLike(name)
countRow := r.pool.QueryRow(ctx,
`SELECT COUNT(DISTINCT f.id)
FROM fruits f
LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id
WHERE ($1 = '' OR f.name ILIKE '%' || $1 || '%' ESCAPE '\' OR fs.synonym ILIKE '%' || $1 || '%' ESCAPE '\')
AND ($2::fruit_type[] IS NULL OR f.fruit_type = ANY($2::fruit_type[]))`,
escaped, types)
var total int
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM fruits").Scan(&total); err != nil {
if err := countRow.Scan(&total); err != nil {
return nil, 0, err
}
rows, err := r.pool.Query(ctx,
`SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at
FROM fruits ORDER BY id LIMIT $1 OFFSET $2`, limit, offset)
`SELECT DISTINCT f.id, f.name, f.osdb_number, f.comment, f.fruit_type, f.created_at, f.updated_at,
COALESCE((SELECT array_agg(fs2.synonym ORDER BY fs2.id) FROM fruit_synonyms fs2 WHERE fs2.fruit_id = f.id), '{}') AS synonyms,
COALESCE(
(SELECT '/api/v1/fruits/' || f.id || '/images/' || fi.id || '/thumbnail'
FROM fruit_images fi WHERE fi.fruit_id = f.id AND fi.image_type = 'fruit' ORDER BY fi.id LIMIT 1),
(SELECT '/api/v1/publications/' || pfi.publication_id || '/fruit-images/' || pfi.id || '/thumbnail'
FROM publication_fruit_images pfi WHERE pfi.fruit_id = f.id ORDER BY pfi.id LIMIT 1)
) AS thumbnail_url
FROM fruits f
LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id
WHERE ($1 = '' OR f.name ILIKE '%' || $1 || '%' ESCAPE '\' OR fs.synonym ILIKE '%' || $1 || '%' ESCAPE '\')
AND ($2::fruit_type[] IS NULL OR f.fruit_type = ANY($2::fruit_type[]))
ORDER BY f.name, f.id LIMIT $3 OFFSET $4`,
escaped, types, limit, offset)
if err != nil {
return nil, 0, err
}
@@ -50,10 +83,13 @@ func (r *FruitRepo) List(ctx context.Context, limit, offset int) ([]domain.Fruit
fruits := []domain.Fruit{}
for rows.Next() {
var f domain.Fruit
if err := rows.Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt); err != nil {
if err := rows.Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt,
&f.Synonyms, &f.ThumbnailURL); err != nil {
return nil, 0, err
}
if f.Synonyms == nil {
f.Synonyms = []string{}
}
f.Images = []domain.FruitImage{}
fruits = append(fruits, f)
}
@@ -243,7 +279,6 @@ func (r *FruitRepo) ListImages(ctx context.Context, fruitID int) ([]domain.Fruit
}
func (r *FruitRepo) AddImage(ctx context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error) {
// verify fruit exists
var exists bool
if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil {
return domain.FruitImage{}, err
@@ -252,12 +287,14 @@ func (r *FruitRepo) AddImage(ctx context.Context, fruitID int, filename *string,
return domain.FruitImage{}, handler.ErrNotFound
}
thumbnailData, _ := imaging.CropToContent(data)
var img domain.FruitImage
err := r.pool.QueryRow(ctx,
`INSERT INTO fruit_images (fruit_id, filename, data, image_type, title)
VALUES ($1, $2, $3, $4, $5)
`INSERT INTO fruit_images (fruit_id, filename, data, thumbnail_data, image_type, title)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, fruit_id, filename, image_type, title, created_at`,
fruitID, filename, data, imageType, title).
fruitID, filename, data, thumbnailData, imageType, title).
Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt)
if err != nil {
return domain.FruitImage{}, err
@@ -279,6 +316,24 @@ func (r *FruitRepo) GetImageData(ctx context.Context, fruitID, imageID int) ([]b
return data, nil
}
// GetThumbnailData returns thumbnail_data if present, falls back to data.
func (r *FruitRepo) GetThumbnailData(ctx context.Context, fruitID, imageID int) ([]byte, error) {
var thumbnailData, data []byte
err := r.pool.QueryRow(ctx,
`SELECT thumbnail_data, data FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID).
Scan(&thumbnailData, &data)
if errors.Is(err, pgx.ErrNoRows) {
return nil, handler.ErrNotFound
}
if err != nil {
return nil, err
}
if len(thumbnailData) > 0 {
return thumbnailData, nil
}
return data, nil
}
func (r *FruitRepo) DeleteImage(ctx context.Context, fruitID, imageID int) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID)
if err != nil {
@@ -289,3 +344,40 @@ func (r *FruitRepo) DeleteImage(ctx context.Context, fruitID, imageID int) error
}
return nil
}
// BackfillThumbnails generates thumbnail_data for all fruit_images rows missing it.
// Returns count of rows processed.
func (r *FruitRepo) BackfillThumbnails(ctx context.Context) (int, error) {
rows, err := r.pool.Query(ctx, `SELECT id, fruit_id, data FROM fruit_images WHERE thumbnail_data IS NULL`)
if err != nil {
return 0, err
}
defer rows.Close()
type row struct {
id, fruitID int
data []byte
}
var pending []row
for rows.Next() {
var rw row
if err := rows.Scan(&rw.id, &rw.fruitID, &rw.data); err != nil {
return 0, err
}
pending = append(pending, rw)
}
if err := rows.Err(); err != nil {
return 0, err
}
count := 0
for _, rw := range pending {
thumb, _ := imaging.CropToContent(rw.data)
if _, err := r.pool.Exec(ctx,
`UPDATE fruit_images SET thumbnail_data=$1 WHERE id=$2`, thumb, rw.id); err != nil {
return count, err
}
count++
}
return count, nil
}
@@ -97,7 +97,7 @@ func TestFruitRepoIntegration(t *testing.T) {
}
// List
fruits, total, err := repo.List(ctx, 50, 0)
fruits, total, err := repo.List(ctx, 50, 0, "", nil)
if err != nil {
t.Fatalf("List: %v", err)
}
@@ -10,6 +10,7 @@ import (
"osdb/internal/domain"
"osdb/internal/handler"
"osdb/internal/imaging"
)
type PublicationRepo struct {
@@ -332,12 +333,15 @@ func (r *PublicationRepo) AddFruitImage(ctx context.Context, pubID, fruitID int,
if err := r.requirePublication(ctx, pubID); err != nil {
return domain.PublicationFruitImage{}, err
}
thumbnailData, _ := imaging.CropToContent(data)
var img domain.PublicationFruitImage
err := r.pool.QueryRow(ctx,
`INSERT INTO publication_fruit_images (publication_id, fruit_id, filename, data, image_type)
VALUES ($1, $2, $3, $4, 'fruit')
`INSERT INTO publication_fruit_images (publication_id, fruit_id, filename, data, thumbnail_data, image_type)
VALUES ($1, $2, $3, $4, $5, 'fruit')
RETURNING id, publication_id, fruit_id, filename, image_type, created_at`,
pubID, fruitID, filename, data).
pubID, fruitID, filename, data, thumbnailData).
Scan(&img.ID, &img.PublicationID, &img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt)
if err != nil {
return domain.PublicationFruitImage{}, err
@@ -357,6 +361,24 @@ func (r *PublicationRepo) GetFruitImageData(ctx context.Context, pubID, imgID in
return data, err
}
// GetFruitImageThumbnailData returns thumbnail_data if present, falls back to data.
func (r *PublicationRepo) GetFruitImageThumbnailData(ctx context.Context, pubID, imgID int) ([]byte, error) {
var thumbnailData, data []byte
err := r.pool.QueryRow(ctx,
`SELECT thumbnail_data, data FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`,
imgID, pubID).Scan(&thumbnailData, &data)
if errors.Is(err, pgx.ErrNoRows) {
return nil, handler.ErrNotFound
}
if err != nil {
return nil, err
}
if len(thumbnailData) > 0 {
return thumbnailData, nil
}
return data, nil
}
func (r *PublicationRepo) DeleteFruitImage(ctx context.Context, pubID, imgID int) error {
tag, err := r.pool.Exec(ctx,
`DELETE FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`, imgID, pubID)
@@ -426,3 +448,41 @@ func (r *PublicationRepo) requirePublication(ctx context.Context, pubID int) err
}
return nil
}
// BackfillThumbnails generates thumbnail_data for all publication_fruit_images rows missing it.
// Returns count of rows processed.
func (r *PublicationRepo) BackfillThumbnails(ctx context.Context) (int, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, data FROM publication_fruit_images WHERE thumbnail_data IS NULL`)
if err != nil {
return 0, err
}
defer rows.Close()
type row struct {
id int
data []byte
}
var pending []row
for rows.Next() {
var rw row
if err := rows.Scan(&rw.id, &rw.data); err != nil {
return 0, err
}
pending = append(pending, rw)
}
if err := rows.Err(); err != nil {
return 0, err
}
count := 0
for _, rw := range pending {
thumb, _ := imaging.CropToContent(rw.data)
if _, err := r.pool.Exec(ctx,
`UPDATE publication_fruit_images SET thumbnail_data=$1 WHERE id=$2`, thumb, rw.id); err != nil {
return count, err
}
count++
}
return count, nil
}
@@ -0,0 +1,2 @@
ALTER TABLE fruit_images DROP COLUMN thumbnail_data;
ALTER TABLE publication_fruit_images DROP COLUMN thumbnail_data;
@@ -0,0 +1,2 @@
ALTER TABLE fruit_images ADD COLUMN thumbnail_data BYTEA;
ALTER TABLE publication_fruit_images ADD COLUMN thumbnail_data BYTEA;
+19 -10
View File
@@ -19,18 +19,17 @@ type MockResponse = {
const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => {
const method = init?.method ?? 'GET'
if (url === '/api/v1/fruits?limit=50&offset=0' && method === 'GET') {
if (url.startsWith('/api/v1/fruits?') && method === 'GET') {
const params = new URLSearchParams(url.split('?')[1])
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }),
})
}
if (url === '/api/v1/fruits?limit=10&offset=20' && method === 'GET') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0, limit: 10, offset: 20 }),
json: async () => ({
items: [],
total: 0,
limit: Number(params.get('limit') ?? 50),
offset: Number(params.get('offset') ?? 0),
}),
})
}
if (url === '/api/v1/fruits/1' && method === 'GET') {
@@ -99,9 +98,19 @@ describe('listFruits', () => {
})
it('passes custom limit and offset', async () => {
const result = await listFruits(10, 20)
const result = await listFruits({ limit: 10, offset: 20 })
expect(result.offset).toBe(20)
})
it('appends name param when provided', async () => {
await listFruits({ name: 'Boskop' })
expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('name=Boskop'))
})
it('appends type param when provided', async () => {
await listFruits({ type: 'Apfelsorten' })
expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('type=Apfelsorten'))
})
})
describe('getFruit', () => {
+12 -2
View File
@@ -38,6 +38,7 @@ export interface Fruit {
fruit_type: string
synonyms: string[]
images: FruitImage[]
thumbnail_url: string | null
created_at: string
updated_at: string
}
@@ -77,8 +78,17 @@ async function checkOk(res: Response): Promise<Response> {
return res
}
export async function listFruits(limit = 50, offset = 0): Promise<FruitListResponse> {
const res = await fetch(`/api/v1/fruits?limit=${limit}&offset=${offset}`)
export async function listFruits(params?: {
limit?: number
offset?: number
name?: string
type?: string
}): Promise<FruitListResponse> {
const { limit = 50, offset = 0, name, type } = params ?? {}
let url = `/api/v1/fruits?limit=${limit}&offset=${offset}`
if (name) url += `&name=${encodeURIComponent(name)}`
if (type) url += `&type=${encodeURIComponent(type)}`
const res = await fetch(url)
return (await checkOk(res)).json()
}
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import type { Fruit } from '../api/fruits'
defineProps<{ fruit: Fruit }>()
</script>
<template>
<RouterLink
:to="`/fruits/${fruit.id}`"
class="block bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-shadow"
>
<div class="aspect-[4/3] bg-gray-100 flex items-center justify-center overflow-hidden">
<img
v-if="fruit.thumbnail_url"
:src="fruit.thumbnail_url"
:alt="fruit.name"
class="max-w-full max-h-full object-contain"
/>
<span v-else class="text-gray-300 text-4xl select-none">🌿</span>
</div>
<div class="p-3">
<p class="font-semibold text-gray-900 truncate">{{ fruit.name }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ fruit.osdb_number }}</p>
<p class="text-xs text-green-700 mt-0.5">{{ fruit.fruit_type }}</p>
<p v-if="fruit.synonyms.length" class="text-xs text-gray-400 mt-1 truncate">
{{ fruit.synonyms.join(', ') }}
</p>
</div>
</RouterLink>
</template>
+12
View File
@@ -11,6 +11,7 @@ const makeFruit = (id: number, name = 'Boskop'): Fruit => ({
fruit_type: 'Apfelsorten',
synonyms: [],
images: [],
thumbnail_url: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
})
@@ -115,4 +116,15 @@ describe('fruitStore', () => {
expect(store.fruits.find((f) => f.id === 1)).toBeUndefined()
expect(store.total).toBe(1)
})
it('setSearch updates search state and resets offset', async () => {
const store = useFruitStore()
await store.fetchFruits()
store.offset = 50
await store.setSearch('Boskop', 'Apfelsorten')
expect(store.searchName).toBe('Boskop')
expect(store.searchType).toBe('Apfelsorten')
expect(store.offset).toBe(0)
expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('name=Boskop'))
})
})
+16 -2
View File
@@ -18,12 +18,19 @@ export const useFruitStore = defineStore('fruit', () => {
const current = ref<Fruit | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const searchName = ref('')
const searchType = ref('')
async function fetchFruits(lim = limit.value, off = offset.value) {
loading.value = true
error.value = null
try {
const resp = await listFruits(lim, off)
const resp = await listFruits({
limit: lim,
offset: off,
name: searchName.value || undefined,
type: searchType.value || undefined,
})
fruits.value = resp.items
total.value = resp.total
limit.value = resp.limit
@@ -35,6 +42,13 @@ export const useFruitStore = defineStore('fruit', () => {
}
}
async function setSearch(name: string, type: string) {
searchName.value = name
searchType.value = type
offset.value = 0
await fetchFruits(limit.value, 0)
}
async function fetchFruit(id: number) {
loading.value = true
error.value = null
@@ -71,5 +85,5 @@ export const useFruitStore = defineStore('fruit', () => {
if (current.value?.id === id) current.value = null
}
return { fruits, total, limit, offset, current, loading, error, fetchFruits, fetchFruit, create, update, remove }
return { fruits, total, limit, offset, current, loading, error, searchName, searchType, fetchFruits, fetchFruit, create, update, remove, setSearch }
})
+73 -33
View File
@@ -1,14 +1,47 @@
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { onMounted, onUnmounted, computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useFruitStore } from '../stores/fruitStore'
import FruitCard from '../components/FruitCard.vue'
const SEARCH_TYPES = [
{ label: '(Alle)', value: '' },
{ label: 'Apfelsorten', value: 'Apfelsorten' },
{ label: 'Birnensorten', value: 'Birnensorten' },
{ label: 'Quittensorten', value: 'Quittensorten' },
{ label: 'Birnen- und Quittensorten', value: 'Birnen- und Quittensorten' },
{ label: 'Aprikosen', value: 'Aprikosen' },
{ label: 'Pfirsiche', value: 'Pfirsiche' },
{ label: 'Aprikosen und Pfirsiche', value: 'Aprikosen und Pfirsiche' },
{ label: 'Mirabellen', value: 'Mirabellen' },
{ label: 'Renekloden', value: 'Renekloden' },
{ label: 'Mirabellen und Reineclauden', value: 'Mirabellen und Reineclauden' },
{ label: 'Pflaumen', value: 'Pflaumen' },
{ label: 'Zwetschen', value: 'Zwetschen' },
{ label: 'Pflaumen und Zwetschen', value: 'Pflaumen und Zwetschen' },
{ label: 'Sauerkirschen', value: 'Sauerkirschen' },
{ label: 'Süßkirschen', value: 'Süßkirschen' },
{ label: 'Brombeeren', value: 'Brombeeren' },
{ label: 'Erdbeeren', value: 'Erdbeeren' },
{ label: 'Himbeeren', value: 'Himbeeren' },
{ label: 'Johannisbeeren', value: 'Johannisbeeren' },
{ label: 'Stachelbeeren', value: 'Stachelbeeren' },
{ label: 'Wein', value: 'Wein' },
]
const store = useFruitStore()
const nameInput = ref(store.searchName)
const typeSelect = ref(store.searchType)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
onMounted(async () => {
await store.fetchFruits()
})
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
const hasPrev = computed(() => store.offset > 0)
const hasNext = computed(() => store.offset + store.limit < store.total)
@@ -19,6 +52,23 @@ async function prev() {
async function next() {
await store.fetchFruits(store.limit, store.offset + store.limit)
}
function onNameInput() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
store.setSearch(nameInput.value, typeSelect.value)
}, 300)
}
function onNameEnter() {
if (debounceTimer) clearTimeout(debounceTimer)
store.setSearch(nameInput.value, typeSelect.value)
}
function onTypeChange() {
if (debounceTimer) clearTimeout(debounceTimer)
store.setSearch(nameInput.value, typeSelect.value)
}
</script>
<template>
@@ -33,48 +83,38 @@ async function next() {
</RouterLink>
</div>
<!-- Search placeholder (story #06) -->
<div class="mb-4">
<div class="mb-4 flex gap-3">
<input
v-model="nameInput"
type="text"
placeholder="Suche... (kommt in Story #06)"
disabled
class="w-full border border-gray-300 rounded px-3 py-2 bg-gray-100 text-gray-400 cursor-not-allowed"
placeholder="Name oder Synonym suchen..."
class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
@input="onNameInput"
@keydown.enter="onNameEnter"
/>
<select
v-model="typeSelect"
class="border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
@change="onTypeChange"
>
<option v-for="opt in SEARCH_TYPES" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<div v-else>
<table class="w-full border-collapse border border-gray-200 rounded">
<thead class="bg-gray-50">
<tr>
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Name</th>
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">OSDB-Kürzel</th>
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Typ</th>
</tr>
</thead>
<tbody>
<tr v-if="store.fruits.length === 0">
<td colspan="3" class="border border-gray-200 px-4 py-8 text-center text-gray-400">
<div v-if="store.fruits.length === 0" class="text-center text-gray-400 py-16">
Keine Früchte vorhanden.
</td>
</tr>
<tr
v-for="fruit in store.fruits"
:key="fruit.id"
class="hover:bg-gray-50 cursor-pointer"
</div>
<div
v-else
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
>
<td class="border border-gray-200 px-4 py-2">
<RouterLink :to="`/fruits/${fruit.id}`" class="text-green-700 hover:underline">
{{ fruit.name }}
</RouterLink>
</td>
<td class="border border-gray-200 px-4 py-2 text-sm text-gray-600">{{ fruit.osdb_number }}</td>
<td class="border border-gray-200 px-4 py-2 text-sm text-gray-600">{{ fruit.fruit_type }}</td>
</tr>
</tbody>
</table>
<FruitCard v-for="fruit in store.fruits" :key="fruit.id" :fruit="fruit" />
</div>
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
<span>{{ store.total }} Früchte gesamt</span>
+37
View File
@@ -0,0 +1,37 @@
# Import Scripts
## Prerequisites
- Postgres running and migrated (`make migrate-up`)
- `DATABASE_URL` set, e.g.:
```
export DATABASE_URL=postgres://user:pass@localhost:5432/osdb
```
- `03-data/` directory present at repo root
- `psycopg2` installed (`pip install psycopg2-binary`)
---
## Run Order
Scripts must be run in order — publications import depends on fruits being present.
### 1. Import fruits
```bash
DATABASE_URL=... python3 scripts/import_fruits.py
```
Imports fruits, synonyms, and fruit images from `03-data/obstsorten.xml`.
Idempotent: upserts fruits by `osdb_number`.
### 2. Import publications
```bash
DATABASE_URL=... python3 scripts/import_publications.py
```
Imports publications from `03-data/osws.xml` (only `<obj>` elements with `<osw>=1`).
For each publication: upserts the row, loads cover image, links fruits found by
filesystem scan of `03-data/osdb/{pubId}/`, and imports PDFs and fruit images.
Idempotent: clears and re-inserts linked data on re-run.
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""
Import publications from 03-data/osws.xml into the OSDB database.
Usage:
DATABASE_URL=postgres://... python3 scripts/import_publications.py
Idempotent: upserts publication by osdb_pub_id; deletes and re-inserts
linked fruits, descriptions, and fruit images per publication on re-run.
"""
import os
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
import psycopg2
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "03-data")
def parse_publications(xml_path: str) -> list:
"""
Parse osws.xml and return list of publication dicts for <obj> elements with <osw>=1.
Each dict: id, title, author (None if "."), img_path (relative to data root, or None).
"""
tree = ET.parse(xml_path)
root = tree.getroot()
pubs = []
for obj in root.findall("obj"):
if (obj.findtext("osw") or "").strip() != "1":
continue
author_raw = (obj.findtext("author") or "").strip()
img = obj.findtext("img")
pubs.append({
"id": (obj.findtext("id") or "").strip(),
"title": (obj.findtext("name") or "").strip(),
"author": author_raw if author_raw and author_raw != "." else None,
"img_path": img.strip() if img else None,
})
return pubs
def scan_pub_dir(pub_dir: str, pub_id: str) -> dict:
"""
Scan pub_dir for files matching {osdb_number}_{pub_id}_s0.jpg and {osdb_number}_{pub_id}.pdf.
Returns dict mapping osdb_number → {img_path?: str, pdf_path?: str}.
Ignores _tn.jpg thumbnails and any other files.
"""
d = Path(pub_dir)
if not d.is_dir():
return {}
result = {}
img_re = re.compile(rf"^(.+)_{re.escape(pub_id)}_s0\.jpg$")
pdf_re = re.compile(rf"^(.+)_{re.escape(pub_id)}\.pdf$")
for f in d.iterdir():
m = img_re.match(f.name)
if m:
result.setdefault(m.group(1), {})["img_path"] = str(f)
continue
m = pdf_re.match(f.name)
if m:
result.setdefault(m.group(1), {})["pdf_path"] = str(f)
return result
def import_publications(conn, data_dir: str = None) -> dict:
"""
Import all publications from osws.xml into the database.
Returns counts: publications, covers, fruits_linked, pdfs, images, skipped_fruits.
"""
if data_dir is None:
data_dir = DATA_DIR
data_path = Path(data_dir)
pubs = parse_publications(str(data_path / "osws.xml"))
counts = {
"publications": 0,
"covers": 0,
"fruits_linked": 0,
"pdfs": 0,
"images": 0,
"skipped_fruits": 0,
}
with conn.cursor() as cur:
for pub in pubs:
pub_id = pub["id"]
# Load cover image bytes (skip silently if file missing or unreadable)
cover_data = None
if pub["img_path"]:
cover_path = data_path / pub["img_path"]
try:
cover_data = cover_path.read_bytes()
counts["covers"] += 1
except OSError:
pass
# Upsert publication row
cur.execute(
"""
INSERT INTO publications (title, author, osdb_pub_id, image_data, updated_at)
VALUES (%s, %s, %s, %s, NOW())
ON CONFLICT (osdb_pub_id) DO UPDATE
SET title = EXCLUDED.title,
author = EXCLUDED.author,
image_data = EXCLUDED.image_data,
updated_at = NOW()
RETURNING id
""",
(
pub["title"],
pub["author"],
pub_id,
psycopg2.Binary(cover_data) if cover_data else None,
),
)
db_pub_id = cur.fetchone()[0]
counts["publications"] += 1
# Scan filesystem for linked fruits
pub_dir = str(data_path / "osdb" / pub_id)
fruit_files = scan_pub_dir(pub_dir, pub_id)
# Resolve osdb_numbers → DB fruit IDs; warn and skip unknowns
linked = {}
for osdb_number, files in fruit_files.items():
cur.execute("SELECT id FROM fruits WHERE osdb_number = %s", (osdb_number,))
row = cur.fetchone()
if row is None:
print(
f" WARNING: osdb_number not in fruits table, skipping: {osdb_number}",
file=sys.stderr,
)
counts["skipped_fruits"] += 1
continue
linked[osdb_number] = {"db_id": row[0], **files}
# Idempotent: clear previous linked data for this publication
cur.execute("DELETE FROM publication_fruit_images WHERE publication_id = %s", (db_pub_id,))
cur.execute("DELETE FROM publication_descriptions WHERE publication_id = %s", (db_pub_id,))
cur.execute("DELETE FROM publication_fruits WHERE publication_id = %s", (db_pub_id,))
# Re-insert linked fruits, PDFs, and images
for osdb_number, info in linked.items():
fruit_db_id = info["db_id"]
cur.execute(
"INSERT INTO publication_fruits (publication_id, fruit_id) VALUES (%s, %s)",
(db_pub_id, fruit_db_id),
)
counts["fruits_linked"] += 1
if "pdf_path" in info:
pdf_data = Path(info["pdf_path"]).read_bytes()
cur.execute(
"""
INSERT INTO publication_descriptions (publication_id, fruit_id, pdf_data)
VALUES (%s, %s, %s)
""",
(db_pub_id, fruit_db_id, psycopg2.Binary(pdf_data)),
)
counts["pdfs"] += 1
if "img_path" in info:
img_data = Path(info["img_path"]).read_bytes()
filename = Path(info["img_path"]).name
cur.execute(
"""
INSERT INTO publication_fruit_images
(publication_id, fruit_id, filename, data, image_type)
VALUES (%s, %s, %s, %s, 'fruit')
""",
(db_pub_id, fruit_db_id, filename, psycopg2.Binary(img_data)),
)
counts["images"] += 1
conn.commit()
return counts
def main():
database_url = os.environ.get("DATABASE_URL")
if not database_url:
print("Error: DATABASE_URL environment variable not set", file=sys.stderr)
sys.exit(1)
print("Connecting to database...")
conn = psycopg2.connect(database_url)
try:
print("Importing publications...")
counts = import_publications(conn)
print("\nDone.")
print(f" Publications upserted: {counts['publications']}")
print(f" Cover images loaded: {counts['covers']}")
print(f" Fruits linked: {counts['fruits_linked']}")
print(f" PDFs imported: {counts['pdfs']}")
print(f" Fruit images imported: {counts['images']}")
print(f" Fruits skipped: {counts['skipped_fruits']}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
conn.rollback()
sys.exit(1)
finally:
conn.close()
if __name__ == "__main__":
main()
+236
View File
@@ -0,0 +1,236 @@
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch
from import_publications import parse_publications, scan_pub_dir, import_publications
MINIMAL_XML_STR = """<?xml version="1.0" encoding="iso-8859-1" ?>
<root>
<obj>
<id>skip_me</id>
<name>Not a publication</name>
<author>Nobody</author>
<osw>0</osw>
</obj>
<obj>
<id>ber</id>
<name>Bernisches Stammregister</name>
<author>.</author>
<img>osdb/ber/ber_s0.jpg</img>
<osw>1</osw>
</obj>
<obj>
<id>cal</id>
<name>Obst- und Beeren</name>
<author>Calwer</author>
<img>osdb/cal/cal_s0.jpg</img>
<osw>1</osw>
</obj>
<obj>
<id>noc</id>
<name>No Cover</name>
<author>Someone</author>
<osw>1</osw>
</obj>
</root>
"""
MINIMAL_XML = MINIMAL_XML_STR.encode("iso-8859-1")
def _write_xml(tmp_dir, content=MINIMAL_XML):
p = Path(tmp_dir) / "osws.xml"
p.write_bytes(content)
return str(p)
class TestParsePublications(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.xml_path = _write_xml(self.tmp)
def test_excludes_osw_not_1(self):
pubs = parse_publications(self.xml_path)
ids = [p["id"] for p in pubs]
self.assertNotIn("skip_me", ids)
def test_includes_osw_1(self):
pubs = parse_publications(self.xml_path)
ids = [p["id"] for p in pubs]
self.assertIn("ber", ids)
self.assertIn("cal", ids)
def test_author_dot_becomes_none(self):
pubs = parse_publications(self.xml_path)
ber = next(p for p in pubs if p["id"] == "ber")
self.assertIsNone(ber["author"])
def test_author_string_preserved(self):
pubs = parse_publications(self.xml_path)
cal = next(p for p in pubs if p["id"] == "cal")
self.assertEqual(cal["author"], "Calwer")
def test_img_path_present(self):
pubs = parse_publications(self.xml_path)
cal = next(p for p in pubs if p["id"] == "cal")
self.assertEqual(cal["img_path"], "osdb/cal/cal_s0.jpg")
def test_img_path_absent_is_none(self):
pubs = parse_publications(self.xml_path)
noc = next(p for p in pubs if p["id"] == "noc")
self.assertIsNone(noc["img_path"])
def test_title_mapped(self):
pubs = parse_publications(self.xml_path)
cal = next(p for p in pubs if p["id"] == "cal")
self.assertEqual(cal["title"], "Obst- und Beeren")
class TestScanPubDir(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
def test_returns_empty_for_missing_directory(self):
result = scan_pub_dir(os.path.join(self.tmp, "nonexistent"), "xyz")
self.assertEqual(result, {})
def test_extracts_osdb_number_from_image(self):
d = Path(self.tmp)
(d / "apfel_ber_s0.jpg").write_bytes(b"img")
result = scan_pub_dir(str(d), "ber")
self.assertIn("apfel", result)
self.assertEqual(result["apfel"]["img_path"], str(d / "apfel_ber_s0.jpg"))
def test_extracts_osdb_number_from_pdf(self):
d = Path(self.tmp)
(d / "birne_cal.pdf").write_bytes(b"pdf")
result = scan_pub_dir(str(d), "cal")
self.assertIn("birne", result)
self.assertEqual(result["birne"]["pdf_path"], str(d / "birne_cal.pdf"))
def test_combines_img_and_pdf_for_same_fruit(self):
d = Path(self.tmp)
(d / "apfel_ber_s0.jpg").write_bytes(b"img")
(d / "apfel_ber.pdf").write_bytes(b"pdf")
result = scan_pub_dir(str(d), "ber")
self.assertIn("img_path", result["apfel"])
self.assertIn("pdf_path", result["apfel"])
def test_ignores_thumbnail_files(self):
d = Path(self.tmp)
(d / "apfel_ber_tn.jpg").write_bytes(b"tn")
result = scan_pub_dir(str(d), "ber")
self.assertEqual(result, {})
def test_multi_segment_osdb_number(self):
d = Path(self.tmp)
(d / "berner_grauechapfel_ber_s0.jpg").write_bytes(b"img")
result = scan_pub_dir(str(d), "ber")
self.assertIn("berner_grauechapfel", result)
class TestImportPublications(unittest.TestCase):
def _make_data_dir(self, pubs):
"""Build a temp data dir with osws.xml and osdb/<id>/ dirs."""
tmp = tempfile.mkdtemp()
lines = ['<?xml version="1.0" encoding="iso-8859-1" ?>', "<root>"]
for p in pubs:
lines.append(" <obj>")
lines.append(f" <id>{p['id']}</id>")
lines.append(f" <name>{p['name']}</name>")
author = p.get("author", "Test")
lines.append(f" <author>{author}</author>")
if p.get("img"):
lines.append(f" <img>{p['img']}</img>")
lines.append(" <osw>1</osw>")
lines.append(" </obj>")
lines.append("</root>")
Path(tmp, "osws.xml").write_bytes("\n".join(lines).encode("iso-8859-1"))
for p in pubs:
pub_dir = Path(tmp, "osdb", p["id"])
pub_dir.mkdir(parents=True, exist_ok=True)
for fruit in p.get("fruits", []):
if fruit.get("img"):
(pub_dir / f"{fruit['osdb_number']}_{p['id']}_s0.jpg").write_bytes(b"\x89PNG")
if fruit.get("pdf"):
(pub_dir / f"{fruit['osdb_number']}_{p['id']}.pdf").write_bytes(b"%PDF")
if p.get("cover_bytes"):
cover_rel = p["img"]
cover_path = Path(tmp, cover_rel)
cover_path.parent.mkdir(parents=True, exist_ok=True)
cover_path.write_bytes(p["cover_bytes"])
return tmp
def _make_conn(self, pub_db_id=42, fruit_db_ids=None):
"""Return a mock psycopg2 connection."""
conn = MagicMock()
cur = MagicMock()
conn.cursor.return_value.__enter__.return_value = cur
# fetchone side_effect: first call = pub id; subsequent = fruit lookups.
# None in fruit_db_ids → bare None (simulates no row found); int → (int,) tuple.
fruit_db_ids = fruit_db_ids or []
side_effects = [(pub_db_id,)] + [((fid,) if fid is not None else None) for fid in fruit_db_ids]
cur.fetchone.side_effect = side_effects
return conn, cur
def test_missing_cover_image_does_not_abort_import(self):
data_dir = self._make_data_dir([
{"id": "ber", "name": "Test", "img": "osdb/ber/ber_s0.jpg"},
])
# cover file NOT created → missing
conn, cur = self._make_conn(pub_db_id=1)
counts = import_publications(conn, data_dir)
self.assertEqual(counts["publications"], 1)
self.assertEqual(counts["covers"], 0)
def test_fruit_not_in_db_is_skipped_with_warning(self):
data_dir = self._make_data_dir([
{"id": "cal", "name": "Cal", "fruits": [
{"osdb_number": "unknown_fruit", "img": True},
]},
])
conn, cur = self._make_conn(pub_db_id=5, fruit_db_ids=[None])
counts = import_publications(conn, data_dir)
self.assertEqual(counts["skipped_fruits"], 1)
self.assertEqual(counts["fruits_linked"], 0)
def test_happy_path_upserts_pub_links_fruits_imports_files(self):
data_dir = self._make_data_dir([
{
"id": "pom",
"name": "Pomologie",
"author": "Diel",
"img": "osdb/pom/pom_s0.jpg",
"cover_bytes": b"\x89PNG",
"fruits": [
{"osdb_number": "apfelsorte_alpha", "img": True, "pdf": True},
],
}
])
conn, cur = self._make_conn(pub_db_id=7, fruit_db_ids=[99])
counts = import_publications(conn, data_dir)
self.assertEqual(counts["publications"], 1)
self.assertEqual(counts["covers"], 1)
self.assertEqual(counts["fruits_linked"], 1)
self.assertEqual(counts["pdfs"], 1)
self.assertEqual(counts["images"], 1)
self.assertEqual(counts["skipped_fruits"], 0)
def test_osw_not_1_never_imported(self):
tmp = tempfile.mkdtemp()
xml = b"""<?xml version="1.0" encoding="iso-8859-1"?>
<root>
<obj><id>skip</id><name>Skip</name><author>X</author><osw>0</osw></obj>
</root>"""
Path(tmp, "osws.xml").write_bytes(xml)
conn, cur = self._make_conn()
counts = import_publications(conn, tmp)
self.assertEqual(counts["publications"], 0)
cur.execute.assert_not_called()
if __name__ == "__main__":
unittest.main()