Author SHA1 Message Date
julia bf0f1af649 Merge branch 'feature/08-authorize-user' 2026-06-22 13:21:34 +02:00
julia 5e4120a027 update: consolidate import instructions, expand supported image directories in import_fruits, and add backend .env inclusion in Makefile 2026-06-22 13:19:51 +02:00
julia e14aa0fea6 Merge pull request 'feat: protect write endpoints with JWT auth (story #08)' (#9) from feature/08-authorize-user into main
Merge pull request #9: feat: protect write endpoints with JWT auth (story #08)
2026-06-19 10:10:20 +00:00
juliaandClaude Sonnet 4.6 1b381d9385 feat: protect write endpoints with JWT auth (story #08)
Add bcrypt user file auth, JWT middleware on all write routes, Pinia
authStore with localStorage persistence, login view with redirect
support, and v-if guards on all CRUD controls and admin nav link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:18:42 +02:00
julia b6f2ac06ae Merge pull request 'feat: fruit overview with thumbnails and card layout (story #07)' (#7) from feature/07-improve-fruit-overview into main
Reviewed-on: #7
2026-06-19 07:19:00 +00:00
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
juliaandClaude Sonnet 4.6 fce4d67cbe feat: publication management with cover images, PDFs, and fruit images (story #04)
Full CRUD for publications; link fruits to publications; upload per-fruit
PDF descriptions and fruit images stored as BYTEA; fruit detail page shows
publication descriptions and images. ImageOverlayDrawer for cover thumbnail
full-size view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 10:48:08 +02:00
julia 2e3f0f366c Merge pull request 'Story #02: Manage Fruits — full CRUD with synonyms and images' (#2) from feature/02-manage-fruits into main
Reviewed-on: #2
2026-06-17 12:46:15 +00:00
julia b0cb64bdaf Merge pull request 'feat: bootstrap OSDB full-stack skeleton (story #01)' (#1) from feature/01-bootstrap-the-project into main
Reviewed-on: #1
2026-06-17 12:45:14 +00:00
61 changed files with 5328 additions and 95 deletions
+11
View File
@@ -0,0 +1,11 @@
## Taiga
- TAIGA_URL: https://taiga.db-extern.de
- TAIGA_PROJECT_SLUG: obstsortendatenbank-1
## Stories
Before starting 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
View File
@@ -11,6 +11,7 @@ frontend/.vite/
# Environment — never commit real credentials # Environment — never commit real credentials
.env .env
backend/.env backend/.env
users.env
# Import data — large binary files, not committed # Import data — large binary files, not committed
03-data/ 03-data/
+2 -1
View File
@@ -1,5 +1,6 @@
# -include: silently skip if .env doesn't exist (e.g. fresh clone before 'cp .env.example .env') # -include: silently skip if .env doesn't exist (e.g. fresh clone before 'cp .env.example .env')
-include .env -include .env
-include backend/.env
export export
.PHONY: dev-db migrate-up migrate-down run-backend run-frontend test fmt .PHONY: dev-db migrate-up migrate-down run-backend run-frontend test fmt
@@ -34,7 +35,7 @@ run-frontend:
test: test:
cd backend && go test ./... cd backend && go test ./...
cd frontend && npm run test -- --run 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: format all code
fmt: fmt:
+42 -6
View File
@@ -6,16 +6,52 @@ 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 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. - 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.
- Maintainers can log in with a username and password to access data-modifying operations; all write API endpoints and admin controls are protected behind JWT authentication.
## Quick Start ## Quick Start
```bash ```bash
cp .env.example .env # set credentials cp .env.example .env # set credentials
make dev-db # start Postgres (waits until healthy) cp backend/.env.example backend/.env # set JWT_SECRET and USERS_FILE
make migrate-up # apply schema migrations cp users.env.example users.env # create user file (see below)
make run-backend # Echo API on :3000 ./scripts/create_user.sh admin secret >> users.env # add a user (restart backend to reload)
make run-frontend # Vite dev server on :5000 make dev-db # start Postgres (waits until healthy)
make migrate-up # apply schema migrations
make run-backend # Echo API on :3000
make run-frontend # Vite dev server on :5000
```
## Importing Legacy Data
Prerequisites: Postgres running and migrated, `psycopg2` installed (`pip install psycopg2-binary`), `03-data/` present at repo root.
Scripts must be run in order — publications depend on fruits being present.
### 1. Import fruits
```bash
DATABASE_URL=postgres://osdb:osdb@localhost:5432/osdb?sslmode=disable python3 scripts/import_fruits.py
```
Imports fruits, synonyms, and fruit images from `03-data/obstsorten.xml`. Idempotent: upserts by `osdb_number`.
### 2. Import publications
```bash
DATABASE_URL=postgres://osdb:osdb@localhost:5432/osdb?sslmode=disable python3 scripts/import_publications.py
```
Imports publications from `03-data/osws.xml`. For each publication: upserts the row, loads cover image, links fruits, and imports PDFs and fruit images. Idempotent: clears and re-inserts linked data on re-run.
## Managing Users
Add a user and restart the backend to reload:
```bash
./scripts/create_user.sh <username> <password> >> users.env
``` ```
## Testing ## Testing
+4
View File
@@ -0,0 +1,4 @@
DATABASE_URL=postgres://osdb:osdb@localhost:5432/osdb?sslmode=disable
PORT=3000
JWT_SECRET=change-me-use-a-long-random-string
USERS_FILE=../users.env
+8 -1
View File
@@ -10,6 +10,7 @@ import (
"syscall" "syscall"
"time" "time"
"osdb/internal/auth"
"osdb/internal/config" "osdb/internal/config"
"osdb/internal/database" "osdb/internal/database"
"osdb/internal/migrate" "osdb/internal/migrate"
@@ -52,7 +53,13 @@ func serve(cfg *config.Config) {
defer pool.Close() defer pool.Close()
log.Println("database: connected") log.Println("database: connected")
e := New(pool) users, err := auth.LoadUsers(cfg.UsersFile)
if err != nil {
log.Fatalf("auth: load users from %s: %v", cfg.UsersFile, err)
}
log.Printf("auth: loaded %d user(s)", len(users))
e := New(pool, users, cfg.JWTSecret)
// Start server; signal failures via a channel so the main goroutine can // Start server; signal failures via a channel so the main goroutine can
// handle them without calling os.Exit from a goroutine (which would skip // handle them without calling os.Exit from a goroutine (which would skip
+49 -6
View File
@@ -6,17 +6,20 @@ import (
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"osdb/internal/handler" "osdb/internal/handler"
mw "osdb/internal/middleware"
"osdb/internal/repository" "osdb/internal/repository"
) )
// New creates and configures the Echo instance with all routes registered. // New creates and configures the Echo instance with all routes registered.
func New(pool *pgxpool.Pool) *echo.Echo { func New(pool *pgxpool.Pool, users map[string]string, jwtSecret string) *echo.Echo {
e := echo.New() e := echo.New()
e.HideBanner = true e.HideBanner = true
e.Use(middleware.Logger()) e.Use(middleware.Logger())
e.Use(middleware.Recover()) e.Use(middleware.Recover())
jwtAuth := mw.JWTAuth(jwtSecret)
health := handler.NewHealthHandler() health := handler.NewHealthHandler()
// Root health — used by docker healthcheck + ops tooling // Root health — used by docker healthcheck + ops tooling
@@ -26,20 +29,60 @@ func New(pool *pgxpool.Pool) *echo.Echo {
api := e.Group("/api/v1") api := e.Group("/api/v1")
api.GET("/health", health.Health) api.GET("/health", health.Health)
// Auth (story #08)
authHandler := handler.NewAuthHandler(users, jwtSecret)
api.POST("/auth/login", authHandler.Login)
// Fruits (story #02) // Fruits (story #02)
fruitRepo := repository.NewFruitRepo(pool) fruitRepo := repository.NewFruitRepo(pool)
fruits := handler.NewFruitHandler(fruitRepo) fruits := handler.NewFruitHandler(fruitRepo)
g := api.Group("/fruits") g := api.Group("/fruits")
g.GET("", fruits.List) g.GET("", fruits.List)
g.POST("", fruits.Create) g.POST("", fruits.Create, jwtAuth)
g.GET("/:id", fruits.Get) g.GET("/:id", fruits.Get)
g.PUT("/:id", fruits.Update) g.PUT("/:id", fruits.Update, jwtAuth)
g.DELETE("/:id", fruits.Delete) g.DELETE("/:id", fruits.Delete, jwtAuth)
g.GET("/:id/images", fruits.ListImages) g.GET("/:id/images", fruits.ListImages)
g.POST("/:id/images", fruits.UploadImage, middleware.BodyLimit("5M")) g.POST("/:id/images", fruits.UploadImage, jwtAuth, middleware.BodyLimit("5M"))
g.GET("/:id/images/:imageId", fruits.ServeImage) g.GET("/:id/images/:imageId", fruits.ServeImage)
g.DELETE("/:id/images/:imageId", fruits.DeleteImage) g.GET("/:id/images/:imageId/thumbnail", fruits.ServeThumbnail)
g.DELETE("/:id/images/:imageId", fruits.DeleteImage, jwtAuth)
// Publications (story #04)
pubRepo := repository.NewPublicationRepo(pool)
pubs := handler.NewPublicationHandler(pubRepo)
// Fruit-scoped publication reads
g.GET("/:id/descriptions", pubs.GetFruitDescriptions)
g.GET("/:id/publication-images", pubs.GetFruitPublicationImages)
p := api.Group("/publications")
p.GET("", pubs.List)
p.POST("", pubs.Create, jwtAuth)
p.GET("/:id", pubs.Get)
p.PUT("/:id", pubs.Update, jwtAuth)
p.DELETE("/:id", pubs.Delete, jwtAuth)
p.POST("/:id/image", pubs.UploadCoverImage, jwtAuth, middleware.BodyLimit("5M"))
p.GET("/:id/image", pubs.ServeCoverImage)
p.DELETE("/:id/image", pubs.DeleteCoverImage, jwtAuth)
p.GET("/:id/fruits", pubs.ListFruits)
p.POST("/:id/fruits", pubs.LinkFruit, jwtAuth)
p.DELETE("/:id/fruits/:fruitId", pubs.UnlinkFruit, jwtAuth)
p.GET("/:id/descriptions", pubs.ListDescriptions)
p.POST("/:id/descriptions", pubs.UploadDescription, jwtAuth, middleware.BodyLimit("5M"))
p.GET("/:id/descriptions/:descId", pubs.ServeDescription)
p.DELETE("/:id/descriptions/:descId", pubs.DeleteDescription, jwtAuth)
p.GET("/:id/fruit-images", pubs.ListFruitImages)
p.POST("/:id/fruit-images", pubs.UploadFruitImage, jwtAuth, 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, jwtAuth)
// Admin (story #07) — all admin routes require auth
admin := handler.NewAdminHandler(fruitRepo, pubRepo)
adminGroup := api.Group("/admin", jwtAuth)
adminGroup.POST("/backfill-thumbnails", admin.BackfillThumbnails)
return e return e
} }
+1
View File
@@ -9,6 +9,7 @@ require (
) )
require ( require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
+2
View File
@@ -27,6 +27,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+83
View File
@@ -0,0 +1,83 @@
package auth
import (
"bufio"
"errors"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// Claims is the JWT payload.
type Claims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
// LoadUsers reads username:bcrypt_hash pairs from path.
// Lines starting with '#' and blank lines are ignored.
func LoadUsers(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
users := make(map[string]string)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
users[parts[0]] = parts[1]
}
return users, scanner.Err()
}
// VerifyPassword checks username+password against the bcrypt hash stored for that user.
// The peppered input is "username:password".
func VerifyPassword(users map[string]string, username, password string) bool {
hash, ok := users[username]
if !ok {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(username+":"+password)) == nil
}
// GenerateJWT creates a signed HS256 token for username, valid for 1 hour.
func GenerateJWT(username, secret string) (string, error) {
claims := Claims{
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
// ValidateJWT parses and verifies token, returning its claims.
func ValidateJWT(tokenStr, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
+125
View File
@@ -0,0 +1,125 @@
package auth_test
import (
"os"
"path/filepath"
"testing"
"golang.org/x/crypto/bcrypt"
"osdb/internal/auth"
)
// hashPepper mirrors the pepper convention: bcrypt("username:password")
func hashPepper(username, password string) string {
h, err := bcrypt.GenerateFromPassword([]byte(username+":"+password), bcrypt.MinCost)
if err != nil {
panic(err)
}
return string(h)
}
func writeTempUsersFile(t *testing.T, content string) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "users*.env")
if err != nil {
t.Fatal(err)
}
if _, err := f.WriteString(content); err != nil {
t.Fatal(err)
}
f.Close()
return f.Name()
}
func TestLoadUsers_ParsesValidFile(t *testing.T) {
hash := hashPepper("admin", "secret")
path := writeTempUsersFile(t, "admin:"+hash+"\n")
users, err := auth.LoadUsers(path)
if err != nil {
t.Fatalf("LoadUsers: %v", err)
}
if len(users) != 1 {
t.Fatalf("expected 1 user, got %d", len(users))
}
if _, ok := users["admin"]; !ok {
t.Error("expected 'admin' in users map")
}
}
func TestLoadUsers_SkipsCommentLines(t *testing.T) {
hash := hashPepper("admin", "secret")
content := "# comment\nadmin:" + hash + "\n"
path := writeTempUsersFile(t, content)
users, err := auth.LoadUsers(path)
if err != nil {
t.Fatalf("LoadUsers: %v", err)
}
if len(users) != 1 {
t.Fatalf("expected 1 user, got %d", len(users))
}
}
func TestLoadUsers_MissingFile(t *testing.T) {
_, err := auth.LoadUsers(filepath.Join(t.TempDir(), "nonexistent.env"))
if err == nil {
t.Error("expected error for missing file")
}
}
func TestVerifyPassword_CorrectPassword(t *testing.T) {
hash := hashPepper("admin", "secret")
users := map[string]string{"admin": hash}
if !auth.VerifyPassword(users, "admin", "secret") {
t.Error("expected password to match")
}
}
func TestVerifyPassword_WrongPassword(t *testing.T) {
hash := hashPepper("admin", "secret")
users := map[string]string{"admin": hash}
if auth.VerifyPassword(users, "admin", "wrong") {
t.Error("expected password to not match")
}
}
func TestVerifyPassword_UnknownUser(t *testing.T) {
if auth.VerifyPassword(map[string]string{}, "nobody", "secret") {
t.Error("expected false for unknown user")
}
}
func TestGenerateAndValidateJWT(t *testing.T) {
token, err := auth.GenerateJWT("admin", "testsecret")
if err != nil {
t.Fatalf("GenerateJWT: %v", err)
}
if token == "" {
t.Error("expected non-empty token")
}
claims, err := auth.ValidateJWT(token, "testsecret")
if err != nil {
t.Fatalf("ValidateJWT: %v", err)
}
if claims.Username != "admin" {
t.Errorf("expected username 'admin', got %q", claims.Username)
}
}
func TestValidateJWT_WrongSecret(t *testing.T) {
token, _ := auth.GenerateJWT("admin", "correct")
if _, err := auth.ValidateJWT(token, "wrong"); err == nil {
t.Error("expected error for wrong secret")
}
}
func TestValidateJWT_MalformedToken(t *testing.T) {
if _, err := auth.ValidateJWT("not.a.token", "secret"); err == nil {
t.Error("expected error for malformed token")
}
}
+16
View File
@@ -9,11 +9,15 @@ import (
type Config struct { type Config struct {
DatabaseURL string DatabaseURL string
Port string Port string
UsersFile string
JWTSecret string
} }
// Load reads configuration from environment variables. // Load reads configuration from environment variables.
// DATABASE_URL is required (no silent default — fails fast if missing). // DATABASE_URL is required (no silent default — fails fast if missing).
// PORT defaults to "3000". // PORT defaults to "3000".
// USERS_FILE defaults to "users.env".
// JWT_SECRET is required.
func Load() *Config { func Load() *Config {
dbURL := os.Getenv("DATABASE_URL") dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" { if dbURL == "" {
@@ -21,13 +25,25 @@ func Load() *Config {
"Copy .env.example to .env and set the correct value.") "Copy .env.example to .env and set the correct value.")
} }
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
log.Fatal("JWT_SECRET environment variable is required but not set.")
}
port := os.Getenv("PORT") port := os.Getenv("PORT")
if port == "" { if port == "" {
port = "3000" port = "3000"
} }
usersFile := os.Getenv("USERS_FILE")
if usersFile == "" {
usersFile = "users.env"
}
return &Config{ return &Config{
DatabaseURL: dbURL, DatabaseURL: dbURL,
Port: port, Port: port,
UsersFile: usersFile,
JWTSecret: jwtSecret,
} }
} }
+18 -9
View File
@@ -2,16 +2,25 @@ package domain
import "time" 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 { type Fruit struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
OSDBNumber string `json:"osdb_number"` OSDBNumber string `json:"osdb_number"`
Comment *string `json:"comment"` Comment *string `json:"comment"`
FruitType string `json:"fruit_type"` FruitType string `json:"fruit_type"`
Synonyms []string `json:"synonyms"` Synonyms []string `json:"synonyms"`
Images []FruitImage `json:"images"` Images []FruitImage `json:"images"`
CreatedAt time.Time `json:"created_at"` ThumbnailURL *string `json:"thumbnail_url"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type FruitImage struct { type FruitImage struct {
+47
View File
@@ -0,0 +1,47 @@
package domain
import "time"
type Publication struct {
ID int `json:"id"`
Title string `json:"title"`
Author *string `json:"author"`
OSDBPubID string `json:"osdb_pub_id"`
ImageURL *string `json:"image_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PublicationWriteDTO struct {
Title string `json:"title"`
Author *string `json:"author"`
OSDBPubID string `json:"osdb_pub_id"`
}
type PublicationFruit struct {
FruitID int `json:"fruit_id"`
Name string `json:"name"`
OSDBNumber string `json:"osdb_number"`
}
type PublicationDescription struct {
ID int `json:"id"`
PublicationID int `json:"publication_id"`
FruitID int `json:"fruit_id"`
FruitName string `json:"fruit_name"`
PublicationTitle string `json:"publication_title"`
URL string `json:"url"`
CreatedAt time.Time `json:"created_at"`
}
type PublicationFruitImage struct {
ID int `json:"id"`
PublicationID int `json:"publication_id"`
PublicationTitle string `json:"publication_title"`
PublicationAuthor *string `json:"publication_author"`
FruitID int `json:"fruit_id"`
Filename *string `json:"filename"`
ImageType string `json:"image_type"`
URL string `json:"url"`
CreatedAt time.Time `json:"created_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,
})
}
+44
View File
@@ -0,0 +1,44 @@
package handler
import (
"net/http"
"github.com/labstack/echo/v4"
"osdb/internal/auth"
)
// AuthHandler handles authentication endpoints.
type AuthHandler struct {
users map[string]string
jwtSecret string
}
// NewAuthHandler creates an AuthHandler with the given user map and JWT secret.
func NewAuthHandler(users map[string]string, jwtSecret string) *AuthHandler {
return &AuthHandler{users: users, jwtSecret: jwtSecret}
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// Login handles POST /api/v1/auth/login.
func (h *AuthHandler) Login(c echo.Context) error {
var req loginRequest
if err := c.Bind(&req); err != nil || req.Username == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
}
if !auth.VerifyPassword(h.users, req.Username, req.Password) {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
}
token, err := auth.GenerateJWT(req.Username, h.jwtSecret)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "token generation failed"})
}
return c.JSON(http.StatusOK, map[string]string{"token": token})
}
@@ -0,0 +1,75 @@
package handler_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"github.com/labstack/echo/v4"
"osdb/internal/handler"
)
func makeUsersMap(username, password string) map[string]string {
h, _ := bcrypt.GenerateFromPassword([]byte(username+":"+password), bcrypt.MinCost)
return map[string]string{username: string(h)}
}
func TestAuthHandler_Login_ValidCredentials(t *testing.T) {
users := makeUsersMap("admin", "secret")
h := handler.NewAuthHandler(users, "jwttest")
body := `{"username":"admin","password":"secret"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c := echo.New().NewContext(req, rec)
if err := h.Login(c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d — body: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "token") {
t.Errorf("expected 'token' in response, got: %s", rec.Body.String())
}
}
func TestAuthHandler_Login_InvalidCredentials(t *testing.T) {
users := makeUsersMap("admin", "secret")
h := handler.NewAuthHandler(users, "jwttest")
body := `{"username":"admin","password":"wrong"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c := echo.New().NewContext(req, rec)
if err := h.Login(c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
func TestAuthHandler_Login_MissingBody(t *testing.T) {
users := makeUsersMap("admin", "secret")
h := handler.NewAuthHandler(users, "jwttest")
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c := echo.New().NewContext(req, rec)
if err := h.Login(c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
+31 -2
View File
@@ -21,7 +21,7 @@ var (
// FruitRepository is the consumer-defined interface the handler depends on. // FruitRepository is the consumer-defined interface the handler depends on.
// The pg implementation in the repository package satisfies this structurally. // The pg implementation in the repository package satisfies this structurally.
type FruitRepository interface { 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) Get(ctx context.Context, id int) (domain.Fruit, error)
Create(ctx context.Context, dto domain.FruitWriteDTO) (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) 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) 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) 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) 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 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 { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) 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) 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)) all := make([]domain.Fruit, 0, len(r.fruits))
for _, f := range 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) all = append(all, f)
} }
total := len(all) total := len(all)
@@ -171,6 +194,14 @@ func (r *fakeRepo) GetImageData(_ context.Context, fruitID, imageID int) ([]byte
return r.imageData[imageID], nil 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 { func (r *fakeRepo) DeleteImage(_ context.Context, fruitID, imageID int) error {
img, ok := r.images[imageID] img, ok := r.images[imageID]
if !ok || img.FruitID != fruitID { 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 -- // -- Get --
func TestFruitGet_Found(t *testing.T) { func TestFruitGet_Found(t *testing.T) {
@@ -0,0 +1,450 @@
package handler
import (
"context"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/labstack/echo/v4"
"osdb/internal/domain"
)
var (
ErrDuplicateOSDBPubID = errors.New("duplicate osdb_pub_id")
ErrDuplicatePubDescription = errors.New("duplicate publication description for this fruit")
)
// PublicationRepository is the consumer-defined interface the handler depends on.
type PublicationRepository interface {
List(ctx context.Context) ([]domain.Publication, error)
Get(ctx context.Context, id int) (domain.Publication, error)
Create(ctx context.Context, dto domain.PublicationWriteDTO) (domain.Publication, error)
Update(ctx context.Context, id int, dto domain.PublicationWriteDTO) (domain.Publication, error)
Delete(ctx context.Context, id int) error
SetImage(ctx context.Context, pubID int, data []byte) error
GetImageData(ctx context.Context, pubID int) ([]byte, error)
DeleteImage(ctx context.Context, pubID int) error
ListFruits(ctx context.Context, pubID int) ([]domain.PublicationFruit, error)
LinkFruit(ctx context.Context, pubID, fruitID int) error
UnlinkFruit(ctx context.Context, pubID, fruitID int) error
ListDescriptions(ctx context.Context, pubID int) ([]domain.PublicationDescription, error)
AddDescription(ctx context.Context, pubID, fruitID int, data []byte) (domain.PublicationDescription, error)
GetDescriptionData(ctx context.Context, pubID, descID int) ([]byte, error)
DeleteDescription(ctx context.Context, pubID, descID int) error
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)
GetFruitPublicationImages(ctx context.Context, fruitID int) ([]domain.PublicationFruitImage, error)
}
type PublicationHandler struct {
repo PublicationRepository
}
func NewPublicationHandler(repo PublicationRepository) *PublicationHandler {
return &PublicationHandler{repo: repo}
}
func validatePublication(dto domain.PublicationWriteDTO) []string {
var errs []string
if strings.TrimSpace(dto.Title) == "" {
errs = append(errs, "title is required")
}
if strings.TrimSpace(dto.OSDBPubID) == "" {
errs = append(errs, "osdb_pub_id is required")
}
return errs
}
func mapPubRepoError(c echo.Context, err error) error {
switch {
case errors.Is(err, ErrNotFound):
return c.JSON(http.StatusNotFound, map[string]string{"error": "not found"})
case errors.Is(err, ErrDuplicateOSDBPubID):
return c.JSON(http.StatusConflict, map[string]string{"error": "osdb_pub_id already exists"})
case errors.Is(err, ErrDuplicatePubDescription):
return c.JSON(http.StatusConflict, map[string]string{"error": "description already exists for this fruit in this publication"})
default:
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
}
func (h *PublicationHandler) List(c echo.Context) error {
pubs, err := h.repo.List(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
if pubs == nil {
pubs = []domain.Publication{}
}
return c.JSON(http.StatusOK, pubs)
}
func (h *PublicationHandler) Get(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
pub, err := h.repo.Get(c.Request().Context(), id)
if err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusOK, pub)
}
func (h *PublicationHandler) Create(c echo.Context) error {
var dto domain.PublicationWriteDTO
if err := c.Bind(&dto); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if errs := validatePublication(dto); len(errs) > 0 {
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
}
pub, err := h.repo.Create(c.Request().Context(), dto)
if err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusCreated, pub)
}
func (h *PublicationHandler) Update(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
var dto domain.PublicationWriteDTO
if err := c.Bind(&dto); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if errs := validatePublication(dto); len(errs) > 0 {
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
}
pub, err := h.repo.Update(c.Request().Context(), id, dto)
if err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusOK, pub)
}
func (h *PublicationHandler) Delete(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
if err := h.repo.Delete(c.Request().Context(), id); err != nil {
return mapPubRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *PublicationHandler) UploadCoverImage(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
file, err := c.FormFile("image")
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "image file is required"})
}
src, err := file.Open()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
if err := h.repo.SetImage(c.Request().Context(), pubID, data); err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusCreated, map[string]string{"message": "image uploaded"})
}
func (h *PublicationHandler) ServeCoverImage(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
data, err := h.repo.GetImageData(c.Request().Context(), pubID)
if err != nil {
return mapPubRepoError(c, err)
}
contentType := http.DetectContentType(data)
return c.Blob(http.StatusOK, contentType, data)
}
func (h *PublicationHandler) DeleteCoverImage(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
if err := h.repo.DeleteImage(c.Request().Context(), pubID); err != nil {
return mapPubRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *PublicationHandler) ListFruits(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
fruits, err := h.repo.ListFruits(c.Request().Context(), pubID)
if err != nil {
return mapPubRepoError(c, err)
}
if fruits == nil {
fruits = []domain.PublicationFruit{}
}
return c.JSON(http.StatusOK, fruits)
}
func (h *PublicationHandler) LinkFruit(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
var body struct {
FruitID int `json:"fruit_id"`
}
if err := c.Bind(&body); err != nil || body.FruitID == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "fruit_id is required"})
}
if err := h.repo.LinkFruit(c.Request().Context(), pubID, body.FruitID); err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusCreated, map[string]int{"fruit_id": body.FruitID})
}
func (h *PublicationHandler) UnlinkFruit(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
fruitID, err := parseID(c, "fruitId")
if err != nil {
return err
}
if err := h.repo.UnlinkFruit(c.Request().Context(), pubID, fruitID); err != nil {
return mapPubRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *PublicationHandler) ListDescriptions(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
descs, err := h.repo.ListDescriptions(c.Request().Context(), pubID)
if err != nil {
return mapPubRepoError(c, err)
}
if descs == nil {
descs = []domain.PublicationDescription{}
}
return c.JSON(http.StatusOK, descs)
}
func (h *PublicationHandler) UploadDescription(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
fruitIDStr := c.FormValue("fruit_id")
fruitID, convErr := strconv.Atoi(fruitIDStr)
if convErr != nil || fruitID == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "fruit_id is required"})
}
file, err := c.FormFile("pdf")
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "pdf file is required"})
}
src, err := file.Open()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
desc, err := h.repo.AddDescription(c.Request().Context(), pubID, fruitID, data)
if err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusCreated, desc)
}
func (h *PublicationHandler) ServeDescription(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
descID, err := parseID(c, "descId")
if err != nil {
return err
}
data, err := h.repo.GetDescriptionData(c.Request().Context(), pubID, descID)
if err != nil {
return mapPubRepoError(c, err)
}
return c.Blob(http.StatusOK, "application/pdf", data)
}
func (h *PublicationHandler) DeleteDescription(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
descID, err := parseID(c, "descId")
if err != nil {
return err
}
if err := h.repo.DeleteDescription(c.Request().Context(), pubID, descID); err != nil {
return mapPubRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *PublicationHandler) ListFruitImages(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
imgs, err := h.repo.ListFruitImages(c.Request().Context(), pubID)
if err != nil {
return mapPubRepoError(c, err)
}
if imgs == nil {
imgs = []domain.PublicationFruitImage{}
}
return c.JSON(http.StatusOK, imgs)
}
func (h *PublicationHandler) UploadFruitImage(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
return err
}
fruitIDStr := c.FormValue("fruit_id")
fruitID, convErr := strconv.Atoi(fruitIDStr)
if convErr != nil || fruitID == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "fruit_id is required"})
}
file, err := c.FormFile("image")
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "image file is required"})
}
src, err := file.Open()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
var filename *string
if file.Filename != "" {
s := file.Filename
filename = &s
}
img, err := h.repo.AddFruitImage(c.Request().Context(), pubID, fruitID, filename, data)
if err != nil {
return mapPubRepoError(c, err)
}
return c.JSON(http.StatusCreated, img)
}
func (h *PublicationHandler) ServeFruitImage(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.GetFruitImageData(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) 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 {
return err
}
imgID, err := parseID(c, "imgId")
if err != nil {
return err
}
if err := h.repo.DeleteFruitImage(c.Request().Context(), pubID, imgID); err != nil {
return mapPubRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *PublicationHandler) GetFruitDescriptions(c echo.Context) error {
fruitID, err := parseID(c, "id")
if err != nil {
return err
}
descs, err := h.repo.GetFruitDescriptions(c.Request().Context(), fruitID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
if descs == nil {
descs = []domain.PublicationDescription{}
}
return c.JSON(http.StatusOK, descs)
}
func (h *PublicationHandler) GetFruitPublicationImages(c echo.Context) error {
fruitID, err := parseID(c, "id")
if err != nil {
return err
}
imgs, err := h.repo.GetFruitPublicationImages(c.Request().Context(), fruitID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
if imgs == nil {
imgs = []domain.PublicationFruitImage{}
}
return c.JSON(http.StatusOK, imgs)
}
File diff suppressed because it is too large Load Diff
+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")
}
}
+27
View File
@@ -0,0 +1,27 @@
package middleware
import (
"net/http"
"strings"
"github.com/labstack/echo/v4"
"osdb/internal/auth"
)
// JWTAuth returns an Echo middleware that requires a valid Bearer JWT.
func JWTAuth(secret string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
header := c.Request().Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "missing token"})
}
tokenStr := strings.TrimPrefix(header, "Bearer ")
if _, err := auth.ValidateJWT(tokenStr, secret); err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid token"})
}
return next(c)
}
}
}
@@ -0,0 +1,82 @@
package middleware_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"osdb/internal/auth"
mw "osdb/internal/middleware"
)
const testSecret = "testsecret"
func okHandler(c echo.Context) error {
return c.String(http.StatusOK, "ok")
}
func TestJWTAuth_NoToken_Returns401(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
h := mw.JWTAuth(testSecret)(okHandler)
_ = h(c)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
func TestJWTAuth_ValidToken_Passes(t *testing.T) {
token, _ := auth.GenerateJWT("admin", testSecret)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
h := mw.JWTAuth(testSecret)(okHandler)
if err := h(c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
}
func TestJWTAuth_InvalidToken_Returns401(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Authorization", "Bearer invalid.token.here")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
h := mw.JWTAuth(testSecret)(okHandler)
_ = h(c)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
func TestJWTAuth_WrongSecret_Returns401(t *testing.T) {
token, _ := auth.GenerateJWT("admin", "other-secret")
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
h := mw.JWTAuth(testSecret)(okHandler)
_ = h(c)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
+102 -10
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
@@ -11,6 +12,7 @@ import (
"osdb/internal/domain" "osdb/internal/domain"
"osdb/internal/handler" "osdb/internal/handler"
"osdb/internal/imaging"
) )
type FruitRepo struct { type FruitRepo struct {
@@ -25,6 +27,10 @@ func imageURL(fruitID, imageID int) string {
return fmt.Sprintf("/api/v1/fruits/%d/images/%d", fruitID, imageID) 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 { func mapPgError(err error) error {
var pgErr *pgconn.PgError var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { if errors.As(err, &pgErr) && pgErr.Code == "23505" {
@@ -33,15 +39,42 @@ func mapPgError(err error) error {
return err 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 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 return nil, 0, err
} }
rows, err := r.pool.Query(ctx, rows, err := r.pool.Query(ctx,
`SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at `SELECT DISTINCT f.id, f.name, f.osdb_number, f.comment, f.fruit_type, f.created_at, f.updated_at,
FROM fruits ORDER BY id LIMIT $1 OFFSET $2`, limit, offset) 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 { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -50,10 +83,13 @@ func (r *FruitRepo) List(ctx context.Context, limit, offset int) ([]domain.Fruit
fruits := []domain.Fruit{} fruits := []domain.Fruit{}
for rows.Next() { for rows.Next() {
var f domain.Fruit 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 return nil, 0, err
} }
f.Synonyms = []string{} if f.Synonyms == nil {
f.Synonyms = []string{}
}
f.Images = []domain.FruitImage{} f.Images = []domain.FruitImage{}
fruits = append(fruits, f) 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) { 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 var exists bool
if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil { if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil {
return domain.FruitImage{}, err return domain.FruitImage{}, err
@@ -252,12 +287,14 @@ func (r *FruitRepo) AddImage(ctx context.Context, fruitID int, filename *string,
return domain.FruitImage{}, handler.ErrNotFound return domain.FruitImage{}, handler.ErrNotFound
} }
thumbnailData, _ := imaging.CropToContent(data)
var img domain.FruitImage var img domain.FruitImage
err := r.pool.QueryRow(ctx, err := r.pool.QueryRow(ctx,
`INSERT INTO fruit_images (fruit_id, filename, data, image_type, title) `INSERT INTO fruit_images (fruit_id, filename, data, thumbnail_data, image_type, title)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, fruit_id, filename, image_type, title, created_at`, 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) Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt)
if err != nil { if err != nil {
return domain.FruitImage{}, err return domain.FruitImage{}, err
@@ -279,6 +316,24 @@ func (r *FruitRepo) GetImageData(ctx context.Context, fruitID, imageID int) ([]b
return data, nil 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 { 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) tag, err := r.pool.Exec(ctx, `DELETE FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID)
if err != nil { if err != nil {
@@ -289,3 +344,40 @@ func (r *FruitRepo) DeleteImage(ctx context.Context, fruitID, imageID int) error
} }
return nil 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 // List
fruits, total, err := repo.List(ctx, 50, 0) fruits, total, err := repo.List(ctx, 50, 0, "", nil)
if err != nil { if err != nil {
t.Fatalf("List: %v", err) t.Fatalf("List: %v", err)
} }
@@ -0,0 +1,488 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"osdb/internal/domain"
"osdb/internal/handler"
"osdb/internal/imaging"
)
type PublicationRepo struct {
pool *pgxpool.Pool
}
func NewPublicationRepo(pool *pgxpool.Pool) *PublicationRepo {
return &PublicationRepo{pool: pool}
}
func pubImageURL(pubID int) string {
return fmt.Sprintf("/api/v1/publications/%d/image", pubID)
}
func descURL(pubID, descID int) string {
return fmt.Sprintf("/api/v1/publications/%d/descriptions/%d", pubID, descID)
}
func pubFruitImgURL(pubID, imgID int) string {
return fmt.Sprintf("/api/v1/publications/%d/fruit-images/%d", pubID, imgID)
}
func mapPubPgError(err error) error {
return mapPgError(err)
}
func mapPgErrorToPub(err error) error {
e := mapPgError(err)
if errors.Is(e, handler.ErrDuplicateOSDBNumber) {
return handler.ErrDuplicateOSDBPubID
}
return e
}
func mapPgErrorToDesc(err error) error {
e := mapPgError(err)
if errors.Is(e, handler.ErrDuplicateOSDBNumber) {
return handler.ErrDuplicatePubDescription
}
return e
}
func (r *PublicationRepo) List(ctx context.Context) ([]domain.Publication, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, title, author, osdb_pub_id,
CASE WHEN image_data IS NOT NULL THEN true ELSE false END AS has_image,
created_at, updated_at
FROM publications ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
pubs := []domain.Publication{}
for rows.Next() {
var p domain.Publication
var hasImage bool
if err := rows.Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &hasImage, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
if hasImage {
url := pubImageURL(p.ID)
p.ImageURL = &url
}
pubs = append(pubs, p)
}
return pubs, rows.Err()
}
func (r *PublicationRepo) Get(ctx context.Context, id int) (domain.Publication, error) {
var p domain.Publication
var hasImage bool
err := r.pool.QueryRow(ctx,
`SELECT id, title, author, osdb_pub_id,
CASE WHEN image_data IS NOT NULL THEN true ELSE false END,
created_at, updated_at
FROM publications WHERE id=$1`, id).
Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &hasImage, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.Publication{}, handler.ErrNotFound
}
if err != nil {
return domain.Publication{}, err
}
if hasImage {
url := pubImageURL(p.ID)
p.ImageURL = &url
}
return p, nil
}
func (r *PublicationRepo) Create(ctx context.Context, dto domain.PublicationWriteDTO) (domain.Publication, error) {
var p domain.Publication
err := r.pool.QueryRow(ctx,
`INSERT INTO publications (title, author, osdb_pub_id)
VALUES ($1, $2, $3)
RETURNING id, title, author, osdb_pub_id, created_at, updated_at`,
dto.Title, dto.Author, dto.OSDBPubID).
Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return domain.Publication{}, mapPgErrorToPub(err)
}
return p, nil
}
func (r *PublicationRepo) Update(ctx context.Context, id int, dto domain.PublicationWriteDTO) (domain.Publication, error) {
var p domain.Publication
var hasImage bool
err := r.pool.QueryRow(ctx,
`UPDATE publications SET title=$1, author=$2, osdb_pub_id=$3, updated_at=NOW()
WHERE id=$4
RETURNING id, title, author, osdb_pub_id,
CASE WHEN image_data IS NOT NULL THEN true ELSE false END,
created_at, updated_at`,
dto.Title, dto.Author, dto.OSDBPubID, id).
Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &hasImage, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.Publication{}, handler.ErrNotFound
}
if err != nil {
return domain.Publication{}, mapPgErrorToPub(err)
}
if hasImage {
url := pubImageURL(p.ID)
p.ImageURL = &url
}
return p, nil
}
func (r *PublicationRepo) Delete(ctx context.Context, id int) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM publications WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return handler.ErrNotFound
}
return nil
}
func (r *PublicationRepo) SetImage(ctx context.Context, pubID int, data []byte) error {
tag, err := r.pool.Exec(ctx,
`UPDATE publications SET image_data=$1, updated_at=NOW() WHERE id=$2`, data, pubID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return handler.ErrNotFound
}
return nil
}
func (r *PublicationRepo) GetImageData(ctx context.Context, pubID int) ([]byte, error) {
var data []byte
err := r.pool.QueryRow(ctx,
`SELECT image_data FROM publications WHERE id=$1`, pubID).Scan(&data)
if errors.Is(err, pgx.ErrNoRows) {
return nil, handler.ErrNotFound
}
if err != nil {
return nil, err
}
if data == nil {
return nil, handler.ErrNotFound
}
return data, nil
}
func (r *PublicationRepo) DeleteImage(ctx context.Context, pubID int) error {
var data []byte
err := r.pool.QueryRow(ctx,
`UPDATE publications SET image_data=NULL, updated_at=NOW() WHERE id=$1 RETURNING image_data`, pubID).
Scan(&data)
if errors.Is(err, pgx.ErrNoRows) {
return handler.ErrNotFound
}
return err
}
func (r *PublicationRepo) ListFruits(ctx context.Context, pubID int) ([]domain.PublicationFruit, error) {
if err := r.requirePublication(ctx, pubID); err != nil {
return nil, err
}
rows, err := r.pool.Query(ctx,
`SELECT f.id, f.name, f.osdb_number
FROM fruits f
JOIN publication_fruits pf ON pf.fruit_id = f.id
WHERE pf.publication_id=$1 ORDER BY f.name`, pubID)
if err != nil {
return nil, err
}
defer rows.Close()
fruits := []domain.PublicationFruit{}
for rows.Next() {
var pf domain.PublicationFruit
if err := rows.Scan(&pf.FruitID, &pf.Name, &pf.OSDBNumber); err != nil {
return nil, err
}
fruits = append(fruits, pf)
}
return fruits, rows.Err()
}
func (r *PublicationRepo) LinkFruit(ctx context.Context, pubID, fruitID int) error {
if err := r.requirePublication(ctx, pubID); err != nil {
return err
}
_, err := r.pool.Exec(ctx,
`INSERT INTO publication_fruits (publication_id, fruit_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`, pubID, fruitID)
return err
}
func (r *PublicationRepo) UnlinkFruit(ctx context.Context, pubID, fruitID int) error {
tag, err := r.pool.Exec(ctx,
`DELETE FROM publication_fruits WHERE publication_id=$1 AND fruit_id=$2`, pubID, fruitID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return handler.ErrNotFound
}
return nil
}
func (r *PublicationRepo) ListDescriptions(ctx context.Context, pubID int) ([]domain.PublicationDescription, error) {
if err := r.requirePublication(ctx, pubID); err != nil {
return nil, err
}
rows, err := r.pool.Query(ctx,
`SELECT pd.id, pd.publication_id, pd.fruit_id, f.name, p.title, pd.created_at
FROM publication_descriptions pd
JOIN fruits f ON f.id = pd.fruit_id
JOIN publications p ON p.id = pd.publication_id
WHERE pd.publication_id=$1 ORDER BY pd.id`, pubID)
if err != nil {
return nil, err
}
defer rows.Close()
descs := []domain.PublicationDescription{}
for rows.Next() {
var d domain.PublicationDescription
if err := rows.Scan(&d.ID, &d.PublicationID, &d.FruitID, &d.FruitName, &d.PublicationTitle, &d.CreatedAt); err != nil {
return nil, err
}
d.URL = descURL(d.PublicationID, d.ID)
descs = append(descs, d)
}
return descs, rows.Err()
}
func (r *PublicationRepo) AddDescription(ctx context.Context, pubID, fruitID int, data []byte) (domain.PublicationDescription, error) {
if err := r.requirePublication(ctx, pubID); err != nil {
return domain.PublicationDescription{}, err
}
var d domain.PublicationDescription
err := r.pool.QueryRow(ctx,
`INSERT INTO publication_descriptions (publication_id, fruit_id, pdf_data)
VALUES ($1, $2, $3)
RETURNING id, publication_id, fruit_id, created_at`,
pubID, fruitID, data).
Scan(&d.ID, &d.PublicationID, &d.FruitID, &d.CreatedAt)
if err != nil {
return domain.PublicationDescription{}, mapPgErrorToDesc(err)
}
d.URL = descURL(d.PublicationID, d.ID)
return d, nil
}
func (r *PublicationRepo) GetDescriptionData(ctx context.Context, pubID, descID int) ([]byte, error) {
var data []byte
err := r.pool.QueryRow(ctx,
`SELECT pdf_data FROM publication_descriptions WHERE id=$1 AND publication_id=$2`,
descID, pubID).Scan(&data)
if errors.Is(err, pgx.ErrNoRows) {
return nil, handler.ErrNotFound
}
return data, err
}
func (r *PublicationRepo) DeleteDescription(ctx context.Context, pubID, descID int) error {
tag, err := r.pool.Exec(ctx,
`DELETE FROM publication_descriptions WHERE id=$1 AND publication_id=$2`, descID, pubID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return handler.ErrNotFound
}
return nil
}
func (r *PublicationRepo) ListFruitImages(ctx context.Context, pubID int) ([]domain.PublicationFruitImage, error) {
if err := r.requirePublication(ctx, pubID); err != nil {
return nil, err
}
rows, err := r.pool.Query(ctx,
`SELECT pfi.id, pfi.publication_id, p.title, p.author, pfi.fruit_id, pfi.filename, pfi.image_type, pfi.created_at
FROM publication_fruit_images pfi
JOIN publications p ON p.id = pfi.publication_id
WHERE pfi.publication_id=$1 ORDER BY pfi.id`, pubID)
if err != nil {
return nil, err
}
defer rows.Close()
imgs := []domain.PublicationFruitImage{}
for rows.Next() {
var img domain.PublicationFruitImage
if err := rows.Scan(&img.ID, &img.PublicationID, &img.PublicationTitle, &img.PublicationAuthor,
&img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt); err != nil {
return nil, err
}
img.URL = pubFruitImgURL(img.PublicationID, img.ID)
imgs = append(imgs, img)
}
return imgs, rows.Err()
}
func (r *PublicationRepo) AddFruitImage(ctx context.Context, pubID, fruitID int, filename *string, data []byte) (domain.PublicationFruitImage, error) {
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, 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, thumbnailData).
Scan(&img.ID, &img.PublicationID, &img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt)
if err != nil {
return domain.PublicationFruitImage{}, err
}
img.URL = pubFruitImgURL(img.PublicationID, img.ID)
return img, nil
}
func (r *PublicationRepo) GetFruitImageData(ctx context.Context, pubID, imgID int) ([]byte, error) {
var data []byte
err := r.pool.QueryRow(ctx,
`SELECT data FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`,
imgID, pubID).Scan(&data)
if errors.Is(err, pgx.ErrNoRows) {
return nil, handler.ErrNotFound
}
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)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return handler.ErrNotFound
}
return nil
}
func (r *PublicationRepo) GetFruitDescriptions(ctx context.Context, fruitID int) ([]domain.PublicationDescription, error) {
rows, err := r.pool.Query(ctx,
`SELECT pd.id, pd.publication_id, pd.fruit_id, f.name, p.title, pd.created_at
FROM publication_descriptions pd
JOIN fruits f ON f.id = pd.fruit_id
JOIN publications p ON p.id = pd.publication_id
WHERE pd.fruit_id=$1 ORDER BY pd.id`, fruitID)
if err != nil {
return nil, err
}
defer rows.Close()
descs := []domain.PublicationDescription{}
for rows.Next() {
var d domain.PublicationDescription
if err := rows.Scan(&d.ID, &d.PublicationID, &d.FruitID, &d.FruitName, &d.PublicationTitle, &d.CreatedAt); err != nil {
return nil, err
}
d.URL = descURL(d.PublicationID, d.ID)
descs = append(descs, d)
}
return descs, rows.Err()
}
func (r *PublicationRepo) GetFruitPublicationImages(ctx context.Context, fruitID int) ([]domain.PublicationFruitImage, error) {
rows, err := r.pool.Query(ctx,
`SELECT pfi.id, pfi.publication_id, p.title, p.author, pfi.fruit_id, pfi.filename, pfi.image_type, pfi.created_at
FROM publication_fruit_images pfi
JOIN publications p ON p.id = pfi.publication_id
WHERE pfi.fruit_id=$1 ORDER BY pfi.id`, fruitID)
if err != nil {
return nil, err
}
defer rows.Close()
imgs := []domain.PublicationFruitImage{}
for rows.Next() {
var img domain.PublicationFruitImage
if err := rows.Scan(&img.ID, &img.PublicationID, &img.PublicationTitle, &img.PublicationAuthor,
&img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt); err != nil {
return nil, err
}
img.URL = pubFruitImgURL(img.PublicationID, img.ID)
imgs = append(imgs, img)
}
return imgs, rows.Err()
}
func (r *PublicationRepo) requirePublication(ctx context.Context, pubID int) error {
var exists bool
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM publications WHERE id=$1)`, pubID).Scan(&exists)
if err != nil {
return err
}
if !exists {
return handler.ErrNotFound
}
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,183 @@
package repository_test
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"osdb/internal/domain"
"osdb/internal/repository"
)
func TestPublicationRepo_Integration(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("DATABASE_URL not set — skipping integration tests")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
repo := repository.NewPublicationRepo(pool)
fruitRepo := repository.NewFruitRepo(pool)
// Pre-cleanup stale test data from crashed prior runs
_, _ = pool.Exec(ctx, "DELETE FROM fruits WHERE osdb_number = $1", "TEST-PUB-001")
// Seed a fruit for linking
fruit, err := fruitRepo.Create(ctx, domain.FruitWriteDTO{
Name: "Testfrucht",
OSDBNumber: "TEST-PUB-001",
FruitType: "Apfelsorten",
Synonyms: []string{},
})
if err != nil {
t.Fatalf("seed fruit: %v", err)
}
t.Cleanup(func() { fruitRepo.Delete(ctx, fruit.ID) }) //nolint:errcheck
t.Run("create and get publication", func(t *testing.T) {
pub, err := repo.Create(ctx, domain.PublicationWriteDTO{
Title: "Pomologie Test",
OSDBPubID: "TEST-PUB-INTEG-001",
})
if err != nil {
t.Fatalf("create: %v", err)
}
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
if pub.ID == 0 {
t.Fatal("want non-zero ID")
}
got, err := repo.Get(ctx, pub.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Title != "Pomologie Test" {
t.Fatalf("want 'Pomologie Test' got %s", got.Title)
}
})
t.Run("duplicate osdb_pub_id → ErrDuplicateOSDBPubID", func(t *testing.T) {
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Dup", OSDBPubID: "TEST-PUB-DUP-001"})
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
_, err := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Dup2", OSDBPubID: "TEST-PUB-DUP-001"})
if err == nil {
t.Fatal("want error for duplicate osdb_pub_id")
}
})
t.Run("cover image round-trip", func(t *testing.T) {
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Img Test", OSDBPubID: "TEST-PUB-IMG-001"})
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
imgData := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic
if err := repo.SetImage(ctx, pub.ID, imgData); err != nil {
t.Fatalf("set image: %v", err)
}
data, err := repo.GetImageData(ctx, pub.ID)
if err != nil {
t.Fatalf("get image data: %v", err)
}
if string(data) != string(imgData) {
t.Fatal("image data mismatch")
}
if err := repo.DeleteImage(ctx, pub.ID); err != nil {
t.Fatalf("delete image: %v", err)
}
if _, err := repo.GetImageData(ctx, pub.ID); err == nil {
t.Fatal("want not found after delete")
}
})
t.Run("link and unlink fruit", func(t *testing.T) {
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Link Test", OSDBPubID: "TEST-PUB-LINK-001"})
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
if err := repo.LinkFruit(ctx, pub.ID, fruit.ID); err != nil {
t.Fatalf("link fruit: %v", err)
}
fruits, err := repo.ListFruits(ctx, pub.ID)
if err != nil {
t.Fatalf("list fruits: %v", err)
}
if len(fruits) != 1 {
t.Fatalf("want 1 fruit got %d", len(fruits))
}
if err := repo.UnlinkFruit(ctx, pub.ID, fruit.ID); err != nil {
t.Fatalf("unlink fruit: %v", err)
}
fruits, _ = repo.ListFruits(ctx, pub.ID)
if len(fruits) != 0 {
t.Fatalf("want 0 fruits after unlink got %d", len(fruits))
}
})
t.Run("description upload and serve", func(t *testing.T) {
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Desc Test", OSDBPubID: "TEST-PUB-DESC-001"})
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
pdfData := []byte("%PDF-1.4 test content")
desc, err := repo.AddDescription(ctx, pub.ID, fruit.ID, pdfData)
if err != nil {
t.Fatalf("add description: %v", err)
}
data, err := repo.GetDescriptionData(ctx, pub.ID, desc.ID)
if err != nil {
t.Fatalf("get desc data: %v", err)
}
if string(data) != string(pdfData) {
t.Fatal("pdf data mismatch")
}
if err := repo.DeleteDescription(ctx, pub.ID, desc.ID); err != nil {
t.Fatalf("delete desc: %v", err)
}
})
t.Run("fruit image upload and serve", func(t *testing.T) {
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "FruitImg Test", OSDBPubID: "TEST-PUB-FI-001"})
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
imgData := []byte{0x89, 0x50, 0x4e, 0x47}
fname := "test.png"
img, err := repo.AddFruitImage(ctx, pub.ID, fruit.ID, &fname, imgData)
if err != nil {
t.Fatalf("add fruit image: %v", err)
}
if img.ImageType != "fruit" {
t.Fatalf("want image_type=fruit got %s", img.ImageType)
}
data, err := repo.GetFruitImageData(ctx, pub.ID, img.ID)
if err != nil {
t.Fatalf("get fruit image data: %v", err)
}
if string(data) != string(imgData) {
t.Fatal("image data mismatch")
}
if err := repo.DeleteFruitImage(ctx, pub.ID, img.ID); err != nil {
t.Fatalf("delete fruit image: %v", err)
}
})
t.Run("GetFruitDescriptions returns descriptions across publications", func(t *testing.T) {
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "CrossPub", OSDBPubID: "TEST-PUB-CROSS-001"})
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
_, _ = repo.AddDescription(ctx, pub.ID, fruit.ID, []byte("%PDF-1.4"))
descs, err := repo.GetFruitDescriptions(ctx, fruit.ID)
if err != nil {
t.Fatalf("get fruit descriptions: %v", err)
}
if len(descs) == 0 {
t.Fatal("want at least 1 description")
}
})
}
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS publication_fruit_images;
DROP TABLE IF EXISTS publication_descriptions;
DROP TABLE IF EXISTS publication_fruits;
DROP TABLE IF EXISTS publications;
@@ -0,0 +1,34 @@
CREATE TABLE publications (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
author VARCHAR(255),
osdb_pub_id VARCHAR(50) NOT NULL UNIQUE,
image_data BYTEA,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE publication_fruits (
publication_id INTEGER NOT NULL REFERENCES publications(id) ON DELETE CASCADE,
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
PRIMARY KEY (publication_id, fruit_id)
);
CREATE TABLE publication_descriptions (
id SERIAL PRIMARY KEY,
publication_id INTEGER NOT NULL REFERENCES publications(id) ON DELETE CASCADE,
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
pdf_data BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (publication_id, fruit_id)
);
CREATE TABLE publication_fruit_images (
id SERIAL PRIMARY KEY,
publication_id INTEGER NOT NULL REFERENCES publications(id) ON DELETE CASCADE,
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
filename VARCHAR(255),
data BYTEA NOT NULL,
image_type VARCHAR(20) NOT NULL DEFAULT 'fruit',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -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;
+2
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import NavBar from './components/NavBar.vue'
</script> </script>
<template> <template>
<NavBar />
<RouterView /> <RouterView />
</template> </template>
+15
View File
@@ -0,0 +1,15 @@
export interface LoginResponse {
token: string
}
export async function login(username: string, password: string): Promise<LoginResponse> {
const resp = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!resp.ok) {
throw new Error('Invalid credentials')
}
return resp.json()
}
+16
View File
@@ -0,0 +1,16 @@
const TOKEN_KEY = 'auth_token'
export function bearerHeader(): Record<string, string> {
try {
const token = localStorage.getItem(TOKEN_KEY)
return token ? { Authorization: `Bearer ${token}` } : {}
} catch {
return {}
}
}
export function handleUnauthorized(): never {
localStorage.removeItem(TOKEN_KEY)
window.location.href = '/login'
throw new Error('Unauthorized')
}
+19 -10
View File
@@ -19,18 +19,17 @@ type MockResponse = {
const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => { const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => {
const method = init?.method ?? 'GET' 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({ return Promise.resolve({
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }), json: async () => ({
}) items: [],
} total: 0,
if (url === '/api/v1/fruits?limit=10&offset=20' && method === 'GET') { limit: Number(params.get('limit') ?? 50),
return Promise.resolve({ offset: Number(params.get('offset') ?? 0),
ok: true, }),
status: 200,
json: async () => ({ items: [], total: 0, limit: 10, offset: 20 }),
}) })
} }
if (url === '/api/v1/fruits/1' && method === 'GET') { if (url === '/api/v1/fruits/1' && method === 'GET') {
@@ -99,9 +98,19 @@ describe('listFruits', () => {
}) })
it('passes custom limit and offset', async () => { 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) 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', () => { describe('getFruit', () => {
+20 -7
View File
@@ -38,6 +38,7 @@ export interface Fruit {
fruit_type: string fruit_type: string
synonyms: string[] synonyms: string[]
images: FruitImage[] images: FruitImage[]
thumbnail_url: string | null
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -61,8 +62,11 @@ export function imageUrl(fruitId: number, imageId: number): string {
return `/api/v1/fruits/${fruitId}/images/${imageId}` return `/api/v1/fruits/${fruitId}/images/${imageId}`
} }
import { bearerHeader, handleUnauthorized } from './authHeader'
async function checkOk(res: Response): Promise<Response> { async function checkOk(res: Response): Promise<Response> {
if (!res.ok) { if (!res.ok) {
if (res.status === 401) handleUnauthorized()
let msg = `${res.status}` let msg = `${res.status}`
try { try {
const body = await res.json() const body = await res.json()
@@ -77,8 +81,17 @@ async function checkOk(res: Response): Promise<Response> {
return res return res
} }
export async function listFruits(limit = 50, offset = 0): Promise<FruitListResponse> { export async function listFruits(params?: {
const res = await fetch(`/api/v1/fruits?limit=${limit}&offset=${offset}`) 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() return (await checkOk(res)).json()
} }
@@ -90,7 +103,7 @@ export async function getFruit(id: number): Promise<Fruit> {
export async function createFruit(dto: FruitWriteDTO): Promise<Fruit> { export async function createFruit(dto: FruitWriteDTO): Promise<Fruit> {
const res = await fetch('/api/v1/fruits', { const res = await fetch('/api/v1/fruits', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', ...bearerHeader() },
body: JSON.stringify(dto), body: JSON.stringify(dto),
}) })
return (await checkOk(res)).json() return (await checkOk(res)).json()
@@ -99,14 +112,14 @@ export async function createFruit(dto: FruitWriteDTO): Promise<Fruit> {
export async function updateFruit(id: number, dto: FruitWriteDTO): Promise<Fruit> { export async function updateFruit(id: number, dto: FruitWriteDTO): Promise<Fruit> {
const res = await fetch(`/api/v1/fruits/${id}`, { const res = await fetch(`/api/v1/fruits/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', ...bearerHeader() },
body: JSON.stringify(dto), body: JSON.stringify(dto),
}) })
return (await checkOk(res)).json() return (await checkOk(res)).json()
} }
export async function deleteFruit(id: number): Promise<void> { export async function deleteFruit(id: number): Promise<void> {
const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE' }) const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE', headers: bearerHeader() })
await checkOk(res) await checkOk(res)
} }
@@ -126,11 +139,11 @@ export async function addImage(
form.append('image_type', imageType) form.append('image_type', imageType)
if (title) form.append('title', title) if (title) form.append('title', title)
// Do NOT set Content-Type — browser sets it with the correct multipart boundary // Do NOT set Content-Type — browser sets it with the correct multipart boundary
const res = await fetch(`/api/v1/fruits/${fruitId}/images`, { method: 'POST', body: form }) const res = await fetch(`/api/v1/fruits/${fruitId}/images`, { method: 'POST', body: form, headers: bearerHeader() })
return (await checkOk(res)).json() return (await checkOk(res)).json()
} }
export async function deleteImage(fruitId: number, imageId: number): Promise<void> { export async function deleteImage(fruitId: number, imageId: number): Promise<void> {
const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE' }) const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE', headers: bearerHeader() })
await checkOk(res) await checkOk(res)
} }
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
listPublications,
getPublication,
createPublication,
updatePublication,
deletePublication,
pubImageUrl,
pubDescriptionUrl,
pubFruitImageUrl,
} from './publications'
function mockFetch(body: unknown, status = 200) {
return vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
text: () => Promise.resolve(String(body)),
})
}
beforeEach(() => {
vi.restoreAllMocks()
})
function stubFetch(body: unknown, status = 200) {
vi.stubGlobal('fetch', mockFetch(body, status))
}
describe('listPublications', () => {
it('returns array of publications', async () => {
stubFetch([{ id: 1, title: 'Pomologie', osdb_pub_id: 'P001' }])
const pubs = await listPublications()
expect(pubs).toHaveLength(1)
expect(pubs[0].title).toBe('Pomologie')
})
})
describe('getPublication', () => {
it('returns publication by id', async () => {
stubFetch({ id: 1, title: 'Pomologie', osdb_pub_id: 'P001' })
const pub = await getPublication(1)
expect(pub.id).toBe(1)
})
it('throws on 404', async () => {
stubFetch({ error: 'not found' }, 404)
await expect(getPublication(99)).rejects.toThrow()
})
})
describe('createPublication', () => {
it('returns created publication', async () => {
stubFetch({ id: 2, title: 'New', osdb_pub_id: 'P002' }, 201)
const pub = await createPublication({ title: 'New', osdb_pub_id: 'P002' })
expect(pub.id).toBe(2)
})
it('throws 409 on duplicate osdb_pub_id', async () => {
stubFetch({ error: 'osdb_pub_id already exists' }, 409)
await expect(createPublication({ title: 'New', osdb_pub_id: 'P001' })).rejects.toThrow()
})
})
describe('updatePublication', () => {
it('returns updated publication', async () => {
stubFetch({ id: 1, title: 'Updated', osdb_pub_id: 'P001' })
const pub = await updatePublication(1, { title: 'Updated', osdb_pub_id: 'P001' })
expect(pub.title).toBe('Updated')
})
})
describe('deletePublication', () => {
it('resolves on 204', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }))
await expect(deletePublication(1)).resolves.toBeUndefined()
})
})
describe('URL helpers', () => {
it('pubImageUrl returns correct path', () => {
expect(pubImageUrl(3)).toBe('/api/v1/publications/3/image')
})
it('pubDescriptionUrl returns correct path', () => {
expect(pubDescriptionUrl(3, 7)).toBe('/api/v1/publications/3/descriptions/7')
})
it('pubFruitImageUrl returns correct path', () => {
expect(pubFruitImageUrl(3, 9)).toBe('/api/v1/publications/3/fruit-images/9')
})
})
+184
View File
@@ -0,0 +1,184 @@
export interface Publication {
id: number
title: string
author: string | null
osdb_pub_id: string
image_url: string | null
created_at: string
updated_at: string
}
export interface PublicationWriteDTO {
title: string
author?: string | null
osdb_pub_id: string
}
export interface PublicationFruit {
fruit_id: number
name: string
osdb_number: string
}
export interface PublicationDescription {
id: number
publication_id: number
fruit_id: number
fruit_name: string
publication_title: string
url: string
created_at: string
}
export interface PublicationFruitImage {
id: number
publication_id: number
publication_title: string
publication_author: string | null
fruit_id: number
filename: string | null
image_type: string
url: string
created_at: string
}
export function pubImageUrl(pubId: number): string {
return `/api/v1/publications/${pubId}/image`
}
export function pubDescriptionUrl(pubId: number, descId: number): string {
return `/api/v1/publications/${pubId}/descriptions/${descId}`
}
export function pubFruitImageUrl(pubId: number, imgId: number): string {
return `/api/v1/publications/${pubId}/fruit-images/${imgId}`
}
import { bearerHeader, handleUnauthorized } from './authHeader'
async function checkOk(res: Response): Promise<Response> {
if (!res.ok) {
if (res.status === 401) handleUnauthorized()
let msg = `${res.status}`
try {
const body = await res.json()
msg = body.error ?? body.errors?.join(', ') ?? msg
} catch {
// ignore
}
const err = new Error(msg) as Error & { status: number }
err.status = res.status
throw err
}
return res
}
export async function listPublications(): Promise<Publication[]> {
const res = await fetch('/api/v1/publications')
return (await checkOk(res)).json()
}
export async function getPublication(id: number): Promise<Publication> {
const res = await fetch(`/api/v1/publications/${id}`)
return (await checkOk(res)).json()
}
export async function createPublication(dto: PublicationWriteDTO): Promise<Publication> {
const res = await fetch('/api/v1/publications', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
body: JSON.stringify(dto),
})
return (await checkOk(res)).json()
}
export async function updatePublication(id: number, dto: PublicationWriteDTO): Promise<Publication> {
const res = await fetch(`/api/v1/publications/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
body: JSON.stringify(dto),
})
return (await checkOk(res)).json()
}
export async function deletePublication(id: number): Promise<void> {
const res = await fetch(`/api/v1/publications/${id}`, { method: 'DELETE', headers: bearerHeader() })
await checkOk(res)
}
export async function uploadCoverImage(pubId: number, file: File): Promise<void> {
const form = new FormData()
form.append('image', file)
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'POST', body: form, headers: bearerHeader() })
await checkOk(res)
}
export async function deleteCoverImage(pubId: number): Promise<void> {
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'DELETE', headers: bearerHeader() })
await checkOk(res)
}
export async function listPubFruits(pubId: number): Promise<PublicationFruit[]> {
const res = await fetch(`/api/v1/publications/${pubId}/fruits`)
return (await checkOk(res)).json()
}
export async function linkFruit(pubId: number, fruitId: number): Promise<void> {
const res = await fetch(`/api/v1/publications/${pubId}/fruits`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
body: JSON.stringify({ fruit_id: fruitId }),
})
await checkOk(res)
}
export async function unlinkFruit(pubId: number, fruitId: number): Promise<void> {
const res = await fetch(`/api/v1/publications/${pubId}/fruits/${fruitId}`, { method: 'DELETE', headers: bearerHeader() })
await checkOk(res)
}
export async function listDescriptions(pubId: number): Promise<PublicationDescription[]> {
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`)
return (await checkOk(res)).json()
}
export async function uploadDescription(pubId: number, fruitId: number, file: File): Promise<PublicationDescription> {
const form = new FormData()
form.append('pdf', file)
form.append('fruit_id', String(fruitId))
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`, { method: 'POST', body: form, headers: bearerHeader() })
return (await checkOk(res)).json()
}
export async function deleteDescription(pubId: number, descId: number): Promise<void> {
const res = await fetch(`/api/v1/publications/${pubId}/descriptions/${descId}`, { method: 'DELETE', headers: bearerHeader() })
await checkOk(res)
}
export async function listPubFruitImages(pubId: number): Promise<PublicationFruitImage[]> {
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`)
return (await checkOk(res)).json()
}
export async function uploadPubFruitImage(pubId: number, fruitId: number, file: File): Promise<PublicationFruitImage> {
const form = new FormData()
form.append('image', file)
form.append('fruit_id', String(fruitId))
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`, { method: 'POST', body: form, headers: bearerHeader() })
return (await checkOk(res)).json()
}
export async function deletePubFruitImage(pubId: number, imgId: number): Promise<void> {
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images/${imgId}`, { method: 'DELETE', headers: bearerHeader() })
await checkOk(res)
}
export async function getFruitDescriptions(fruitId: number): Promise<PublicationDescription[]> {
const res = await fetch(`/api/v1/fruits/${fruitId}/descriptions`)
return (await checkOk(res)).json()
}
export async function getFruitPublicationImages(fruitId: number): Promise<PublicationFruitImage[]> {
const res = await fetch(`/api/v1/fruits/${fruitId}/publication-images`)
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>
@@ -0,0 +1,21 @@
<script setup lang="ts">
defineProps<{ src: string; alt?: string }>()
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
@click.self="emit('close')"
>
<div class="relative max-h-screen max-w-screen-lg p-4">
<button
class="absolute right-2 top-2 rounded bg-white/90 px-2 py-1 text-sm font-bold shadow hover:bg-white"
@click="emit('close')"
>
</button>
<img :src="src" :alt="alt ?? 'Bild'" class="max-h-[85vh] max-w-full rounded shadow-lg object-contain" />
</div>
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/authStore'
const auth = useAuthStore()
const router = useRouter()
function logout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<nav class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6 text-sm">
<RouterLink to="/fruits" class="font-medium text-gray-700 hover:text-green-700">Früchte</RouterLink>
<RouterLink to="/publications" class="font-medium text-gray-700 hover:text-green-700">Publikationen</RouterLink>
<RouterLink v-if="auth.isLoggedIn" to="/admin" class="font-medium text-gray-700 hover:text-green-700">Administration</RouterLink>
<div class="ml-auto">
<button
v-if="auth.isLoggedIn"
@click="logout"
class="text-gray-600 hover:text-red-600 transition-colors"
>
Abmelden
</button>
<RouterLink
v-else
to="/login"
class="text-gray-600 hover:text-green-700 transition-colors"
>
Anmelden
</RouterLink>
</div>
</nav>
</template>
+41 -1
View File
@@ -1,8 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/authStore'
import HelloWorld from '../views/HelloWorld.vue' import HelloWorld from '../views/HelloWorld.vue'
import FruitList from '../views/FruitList.vue' import FruitList from '../views/FruitList.vue'
import FruitCreate from '../views/FruitCreate.vue' import FruitCreate from '../views/FruitCreate.vue'
import FruitDetail from '../views/FruitDetail.vue' import FruitDetail from '../views/FruitDetail.vue'
import PublicationList from '../views/PublicationList.vue'
import PublicationCreate from '../views/PublicationCreate.vue'
import PublicationDetail from '../views/PublicationDetail.vue'
import LoginView from '../views/LoginView.vue'
import AdminView from '../views/AdminView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -11,21 +17,55 @@ const router = createRouter({
path: '/', path: '/',
component: HelloWorld, component: HelloWorld,
}, },
{
path: '/login',
component: LoginView,
},
{ {
path: '/fruits', path: '/fruits',
component: FruitList, component: FruitList,
}, },
{ {
// /fruits/new must come before /:id so vue-router v5 doesn't capture "new" as a param // /fruits/new must come before /:id so vue-router doesn't capture "new" as a param
path: '/fruits/new', path: '/fruits/new',
component: FruitCreate, component: FruitCreate,
meta: { requiresAuth: true },
}, },
{ {
path: '/fruits/:id', path: '/fruits/:id',
component: FruitDetail, component: FruitDetail,
props: true, props: true,
}, },
{
path: '/publications',
component: PublicationList,
},
{
// /publications/new must come before /:id
path: '/publications/new',
component: PublicationCreate,
meta: { requiresAuth: true },
},
{
path: '/publications/:id',
component: PublicationDetail,
props: true,
},
{
path: '/admin',
component: AdminView,
meta: { requiresAuth: true },
},
], ],
}) })
router.beforeEach((to) => {
if (to.meta.requiresAuth) {
const auth = useAuthStore()
if (!auth.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
}
}
})
export default router export default router
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from './authStore'
const fetchMock = vi.fn()
function makeLocalStorageMock() {
const store: Record<string, string> = {}
return {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => { store[k] = v },
removeItem: (k: string) => { delete store[k] },
}
}
describe('authStore', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', makeLocalStorageMock())
setActivePinia(createPinia())
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('starts unauthenticated when localStorage is empty', () => {
const store = useAuthStore()
expect(store.isLoggedIn).toBe(false)
expect(store.token).toBeNull()
})
it('restores auth state from localStorage on init', () => {
localStorage.setItem('auth_token', 'my-stored-token')
const store = useAuthStore()
expect(store.isLoggedIn).toBe(true)
expect(store.token).toBe('my-stored-token')
})
it('login sets token and isLoggedIn on success', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ token: 'new-jwt-token' }),
})
const store = useAuthStore()
await store.login('admin', 'secret')
expect(store.isLoggedIn).toBe(true)
expect(store.token).toBe('new-jwt-token')
expect(localStorage.getItem('auth_token')).toBe('new-jwt-token')
})
it('login throws on invalid credentials', async () => {
fetchMock.mockResolvedValueOnce({ ok: false })
const store = useAuthStore()
await expect(store.login('admin', 'wrong')).rejects.toThrow()
expect(store.isLoggedIn).toBe(false)
})
it('logout clears token and isLoggedIn', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ token: 'tok' }),
})
const store = useAuthStore()
await store.login('admin', 'secret')
store.logout()
expect(store.isLoggedIn).toBe(false)
expect(store.token).toBeNull()
expect(localStorage.getItem('auth_token')).toBeNull()
})
})
+23
View File
@@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login as apiLogin } from '../api/auth'
const TOKEN_KEY = 'auth_token'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const isLoggedIn = computed(() => token.value !== null)
async function login(username: string, password: string) {
const resp = await apiLogin(username, password)
token.value = resp.token
localStorage.setItem(TOKEN_KEY, resp.token)
}
function logout() {
token.value = null
localStorage.removeItem(TOKEN_KEY)
}
return { token, isLoggedIn, login, logout }
})
+12
View File
@@ -11,6 +11,7 @@ const makeFruit = (id: number, name = 'Boskop'): Fruit => ({
fruit_type: 'Apfelsorten', fruit_type: 'Apfelsorten',
synonyms: [], synonyms: [],
images: [], images: [],
thumbnail_url: null,
created_at: '2024-01-01T00:00:00Z', created_at: '2024-01-01T00:00:00Z',
updated_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.fruits.find((f) => f.id === 1)).toBeUndefined()
expect(store.total).toBe(1) 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 current = ref<Fruit | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const searchName = ref('')
const searchType = ref('')
async function fetchFruits(lim = limit.value, off = offset.value) { async function fetchFruits(lim = limit.value, off = offset.value) {
loading.value = true loading.value = true
error.value = null error.value = null
try { 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 fruits.value = resp.items
total.value = resp.total total.value = resp.total
limit.value = resp.limit 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) { async function fetchFruit(id: number) {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -71,5 +85,5 @@ export const useFruitStore = defineStore('fruit', () => {
if (current.value?.id === id) current.value = null 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 }
}) })
+65
View File
@@ -0,0 +1,65 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
listPublications,
getPublication,
createPublication,
updatePublication,
deletePublication,
type Publication,
type PublicationWriteDTO,
} from '../api/publications'
export const usePublicationStore = defineStore('publication', () => {
const publications = ref<Publication[]>([])
const current = ref<Publication | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchPublications() {
loading.value = true
error.value = null
try {
publications.value = await listPublications()
} catch (e) {
error.value = e instanceof Error ? e.message : 'unknown error'
} finally {
loading.value = false
}
}
async function fetchPublication(id: number) {
loading.value = true
error.value = null
try {
current.value = await getPublication(id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'unknown error'
current.value = null
} finally {
loading.value = false
}
}
async function create(dto: PublicationWriteDTO): Promise<Publication> {
const pub = await createPublication(dto)
publications.value = [pub, ...publications.value]
return pub
}
async function update(id: number, dto: PublicationWriteDTO): Promise<Publication> {
const pub = await updatePublication(id, dto)
const idx = publications.value.findIndex((p) => p.id === id)
if (idx !== -1) publications.value[idx] = pub
if (current.value?.id === id) current.value = pub
return pub
}
async function remove(id: number) {
await deletePublication(id)
publications.value = publications.value.filter((p) => p.id !== id)
if (current.value?.id === id) current.value = null
}
return { publications, current, loading, error, fetchPublications, fetchPublication, create, update, remove }
})
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { ref } from 'vue'
import { bearerHeader, handleUnauthorized } from '../api/authHeader'
const running = ref(false)
const result = ref<string | null>(null)
const error = ref<string | null>(null)
async function backfill() {
running.value = true
result.value = null
error.value = null
try {
const resp = await fetch('/api/v1/admin/backfill-thumbnails', {
method: 'POST',
headers: bearerHeader(),
})
if (resp.status === 401) handleUnauthorized()
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
result.value = JSON.stringify(data, null, 2)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Fehler'
} finally {
running.value = false
}
}
</script>
<template>
<div class="p-6 max-w-xl mx-auto">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Administration</h1>
<section class="border rounded p-4 bg-gray-50 space-y-3">
<h2 class="font-semibold text-gray-800">Thumbnails rückfüllen</h2>
<p class="text-sm text-gray-600">Erzeugt Thumbnails für alle Bilder, die noch keines haben.</p>
<button
@click="backfill"
:disabled="running"
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 transition-colors text-sm"
>
{{ running ? 'Läuft...' : 'Starten' }}
</button>
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
<pre v-if="result" class="text-xs bg-white border rounded p-2 overflow-auto">{{ result }}</pre>
</section>
</div>
</template>
+46 -3
View File
@@ -2,12 +2,16 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useFruitStore } from '../stores/fruitStore' import { useFruitStore } from '../stores/fruitStore'
import { useAuthStore } from '../stores/authStore'
import { addImage, deleteImage, FRUIT_TYPES } from '../api/fruits' import { addImage, deleteImage, FRUIT_TYPES } from '../api/fruits'
import type { Fruit } from '../api/fruits' import type { Fruit } from '../api/fruits'
import { getFruitDescriptions, getFruitPublicationImages } from '../api/publications'
import type { PublicationDescription, PublicationFruitImage } from '../api/publications'
const props = defineProps<{ id: string }>() const props = defineProps<{ id: string }>()
const router = useRouter() const router = useRouter()
const store = useFruitStore() const store = useFruitStore()
const auth = useAuthStore()
const editing = ref(false) const editing = ref(false)
const editName = ref('') const editName = ref('')
@@ -26,9 +30,19 @@ const imageTitle = ref('')
const uploading = ref(false) const uploading = ref(false)
const uploadError = ref<string | null>(null) const uploadError = ref<string | null>(null)
const pubDescriptions = ref<PublicationDescription[]>([])
const pubFruitImages = ref<PublicationFruitImage[]>([])
onMounted(async () => { onMounted(async () => {
await store.fetchFruit(Number(props.id)) await store.fetchFruit(Number(props.id))
if (store.current) startEdit(store.current) if (store.current) startEdit(store.current)
const id = Number(props.id)
const [descs, imgs] = await Promise.all([
getFruitDescriptions(id),
getFruitPublicationImages(id),
])
pubDescriptions.value = descs
pubFruitImages.value = imgs
}) })
function startEdit(f: Fruit) { function startEdit(f: Fruit) {
@@ -122,7 +136,7 @@ async function removeImage(imageId: number) {
<div v-else> <div v-else>
<div class="flex items-start justify-between mb-6"> <div class="flex items-start justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">{{ store.current.name }}</h1> <h1 class="text-2xl font-bold text-gray-900">{{ store.current.name }}</h1>
<div class="flex gap-2"> <div v-if="auth.isLoggedIn" class="flex gap-2">
<button <button
v-if="!editing" v-if="!editing"
@click="editing = true" @click="editing = true"
@@ -217,7 +231,7 @@ async function removeImage(imageId: number) {
<div v-else class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6"> <div v-else class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div v-for="img in store.current.images" :key="img.id" class="relative group border rounded overflow-hidden"> <div v-for="img in store.current.images" :key="img.id" class="relative group border rounded overflow-hidden">
<img :src="img.url" :alt="img.title ?? img.image_type" class="w-full h-32 object-cover" /> <img :src="img.url" :alt="img.title ?? img.image_type" class="w-full h-32 object-cover" />
<div class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div v-if="auth.isLoggedIn" class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
@click="removeImage(img.id)" @click="removeImage(img.id)"
class="bg-red-600 text-white rounded-full w-6 h-6 text-xs flex items-center justify-center hover:bg-red-700" class="bg-red-600 text-white rounded-full w-6 h-6 text-xs flex items-center justify-center hover:bg-red-700"
@@ -230,7 +244,7 @@ async function removeImage(imageId: number) {
</div> </div>
<!-- Upload form --> <!-- Upload form -->
<div class="border rounded p-4 bg-gray-50"> <div v-if="auth.isLoggedIn" class="border rounded p-4 bg-gray-50">
<h3 class="text-sm font-medium text-gray-700 mb-3">Bild hochladen</h3> <h3 class="text-sm font-medium text-gray-700 mb-3">Bild hochladen</h3>
<div v-if="uploadError" class="mb-3 text-red-600 text-sm">{{ uploadError }}</div> <div v-if="uploadError" class="mb-3 text-red-600 text-sm">{{ uploadError }}</div>
<div class="space-y-3"> <div class="space-y-3">
@@ -258,6 +272,35 @@ async function removeImage(imageId: number) {
</div> </div>
</div> </div>
<!-- Publication descriptions -->
<div v-if="pubDescriptions.length > 0" class="mt-8">
<h2 class="text-lg font-semibold text-gray-800 mb-3">Beschreibungen in Publikationen</h2>
<ul class="space-y-1">
<li v-for="d in pubDescriptions" :key="d.id">
<a
:href="d.url"
target="_blank"
class="text-blue-600 hover:underline text-sm"
>
{{ d.publication_title }}
</a>
</li>
</ul>
</div>
<!-- Publication fruit images -->
<div v-if="pubFruitImages.length > 0" class="mt-8">
<h2 class="text-lg font-semibold text-gray-800 mb-3">Bilder aus Publikationen</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div v-for="img in pubFruitImages" :key="img.id" class="border rounded overflow-hidden">
<img :src="img.url" :alt="img.filename ?? 'Fruchtbild'" class="w-full h-32 object-cover" />
<div class="p-1 text-xs text-gray-500">
{{ img.publication_title }}{{ img.publication_author ? ' · ' + img.publication_author : '' }}
</div>
</div>
</div>
</div>
<div class="mt-6"> <div class="mt-6">
<RouterLink to="/fruits" class="text-green-700 hover:underline text-sm"> Zurück zur Liste</RouterLink> <RouterLink to="/fruits" class="text-green-700 hover:underline text-sm"> Zurück zur Liste</RouterLink>
</div> </div>
+78 -35
View File
@@ -1,14 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, computed } from 'vue' import { onMounted, onUnmounted, computed, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useFruitStore } from '../stores/fruitStore' import { useFruitStore } from '../stores/fruitStore'
import { useAuthStore } from '../stores/authStore'
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 store = useFruitStore()
const auth = useAuthStore()
const nameInput = ref(store.searchName)
const typeSelect = ref(store.searchType)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
onMounted(async () => { onMounted(async () => {
await store.fetchFruits() await store.fetchFruits()
}) })
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
const hasPrev = computed(() => store.offset > 0) const hasPrev = computed(() => store.offset > 0)
const hasNext = computed(() => store.offset + store.limit < store.total) const hasNext = computed(() => store.offset + store.limit < store.total)
@@ -19,6 +54,23 @@ async function prev() {
async function next() { async function next() {
await store.fetchFruits(store.limit, store.offset + store.limit) 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> </script>
<template> <template>
@@ -26,6 +78,7 @@ async function next() {
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Früchte</h1> <h1 class="text-2xl font-bold text-gray-900">Früchte</h1>
<RouterLink <RouterLink
v-if="auth.isLoggedIn"
to="/fruits/new" to="/fruits/new"
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition-colors" class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition-colors"
> >
@@ -33,48 +86,38 @@ async function next() {
</RouterLink> </RouterLink>
</div> </div>
<!-- Search placeholder (story #06) --> <div class="mb-4 flex gap-3">
<div class="mb-4">
<input <input
v-model="nameInput"
type="text" type="text"
placeholder="Suche... (kommt in Story #06)" placeholder="Name oder Synonym suchen..."
disabled class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
class="w-full border border-gray-300 rounded px-3 py-2 bg-gray-100 text-gray-400 cursor-not-allowed" @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>
<div v-if="store.loading" class="text-gray-500">Wird geladen...</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-if="store.error" class="text-red-600">{{ store.error }}</div>
<div v-else> <div v-else>
<table class="w-full border-collapse border border-gray-200 rounded"> <div v-if="store.fruits.length === 0" class="text-center text-gray-400 py-16">
<thead class="bg-gray-50"> Keine Früchte vorhanden.
<tr> </div>
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Name</th> <div
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">OSDB-Kürzel</th> v-else
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Typ</th> class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
</tr> >
</thead> <FruitCard v-for="fruit in store.fruits" :key="fruit.id" :fruit="fruit" />
<tbody> </div>
<tr v-if="store.fruits.length === 0">
<td colspan="3" class="border border-gray-200 px-4 py-8 text-center text-gray-400">
Keine Früchte vorhanden.
</td>
</tr>
<tr
v-for="fruit in store.fruits"
:key="fruit.id"
class="hover:bg-gray-50 cursor-pointer"
>
<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>
<div class="flex items-center justify-between mt-4 text-sm text-gray-600"> <div class="flex items-center justify-between mt-4 text-sm text-gray-600">
<span>{{ store.total }} Früchte gesamt</span> <span>{{ store.total }} Früchte gesamt</span>
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/authStore'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const username = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
async function submit() {
error.value = null
loading.value = true
try {
await auth.login(username.value, password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/fruits'
router.push(redirect)
} catch {
error.value = 'Ungültige Zugangsdaten'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<form
@submit.prevent="submit"
class="w-full max-w-sm bg-white rounded shadow p-8 space-y-5"
>
<h1 class="text-xl font-bold text-gray-900">Anmelden</h1>
<div v-if="error" class="p-3 bg-red-50 border border-red-300 rounded text-red-700 text-sm">
{{ error }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Benutzername</label>
<input
v-model="username"
type="text"
required
autocomplete="username"
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
<input
v-model="password"
type="password"
required
autocomplete="current-password"
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<button
type="submit"
:disabled="loading"
class="w-full bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:opacity-50 transition-colors"
>
{{ loading ? 'Anmelden...' : 'Anmelden' }}
</button>
</form>
</div>
</template>
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { usePublicationStore } from '../stores/publicationStore'
const router = useRouter()
const store = usePublicationStore()
const title = ref('')
const author = ref('')
const osdbPubId = ref('')
const saving = ref(false)
const serverError = ref<string | null>(null)
async function submit() {
serverError.value = null
saving.value = true
try {
const pub = await store.create({
title: title.value,
author: author.value || null,
osdb_pub_id: osdbPubId.value,
})
router.push(`/publications/${pub.id}`)
} catch (e) {
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
} finally {
saving.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-lg p-6">
<h1 class="mb-4 text-2xl font-bold">Neue Publikation</h1>
<div v-if="serverError" class="mb-4 rounded bg-red-50 p-3 text-red-700">{{ serverError }}</div>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="mb-1 block text-sm font-medium">Titel *</label>
<input v-model="title" required class="w-full rounded border px-3 py-2" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Autor</label>
<input v-model="author" class="w-full rounded border px-3 py-2" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">OSDB Kürzel *</label>
<input v-model="osdbPubId" required class="w-full rounded border px-3 py-2 font-mono" />
</div>
<div class="flex gap-2">
<button
type="submit"
:disabled="saving"
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{{ saving ? 'Speichern…' : 'Anlegen' }}
</button>
<router-link to="/publications" class="rounded border px-4 py-2 hover:bg-gray-50">
Abbrechen
</router-link>
</div>
</form>
</div>
</template>
+422
View File
@@ -0,0 +1,422 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { usePublicationStore } from '../stores/publicationStore'
import { useAuthStore } from '../stores/authStore'
import {
uploadCoverImage,
deleteCoverImage,
listPubFruits,
linkFruit,
unlinkFruit,
listDescriptions,
uploadDescription,
deleteDescription,
listPubFruitImages,
uploadPubFruitImage,
deletePubFruitImage,
type PublicationFruit,
type PublicationDescription,
type PublicationFruitImage,
} from '../api/publications'
import ImageOverlayDrawer from '../components/ImageOverlayDrawer.vue'
const props = defineProps<{ id: string }>()
const router = useRouter()
const store = usePublicationStore()
const auth = useAuthStore()
// Edit state
const editing = ref(false)
const editTitle = ref('')
const editAuthor = ref('')
const editOsdbPubId = ref('')
const saving = ref(false)
const deleting = ref(false)
const serverError = ref<string | null>(null)
// Cover image
const coverFile = ref<File | null>(null)
const uploadingCover = ref(false)
const coverError = ref<string | null>(null)
const overlayOpen = ref(false)
// Linked fruits
const fruits = ref<PublicationFruit[]>([])
const linkFruitId = ref('')
const linkingFruit = ref(false)
// Descriptions
const descriptions = ref<PublicationDescription[]>([])
const descFile = ref<File | null>(null)
const descFruitId = ref('')
const uploadingDesc = ref(false)
const descError = ref<string | null>(null)
// Fruit images
const fruitImages = ref<PublicationFruitImage[]>([])
const fruitImgFile = ref<File | null>(null)
const fruitImgFruitId = ref('')
const uploadingFruitImg = ref(false)
const fruitImgError = ref<string | null>(null)
const pubId = () => Number(props.id)
onMounted(async () => {
await store.fetchPublication(pubId())
if (store.current) {
editTitle.value = store.current.title
editAuthor.value = store.current.author ?? ''
editOsdbPubId.value = store.current.osdb_pub_id
}
await loadSubResources()
})
async function loadSubResources() {
const [f, d, fi] = await Promise.all([
listPubFruits(pubId()),
listDescriptions(pubId()),
listPubFruitImages(pubId()),
])
fruits.value = f
descriptions.value = d
fruitImages.value = fi
}
async function save() {
serverError.value = null
saving.value = true
try {
const updated = await store.update(pubId(), {
title: editTitle.value,
author: editAuthor.value || null,
osdb_pub_id: editOsdbPubId.value,
})
editTitle.value = updated.title
editAuthor.value = updated.author ?? ''
editing.value = false
} catch (e) {
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
} finally {
saving.value = false
}
}
async function remove() {
if (!confirm('Publikation wirklich löschen?')) return
deleting.value = true
try {
await store.remove(pubId())
router.push('/publications')
} catch (e) {
serverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
deleting.value = false
}
}
async function uploadCover() {
if (!coverFile.value) return
coverError.value = null
uploadingCover.value = true
try {
await uploadCoverImage(pubId(), coverFile.value)
coverFile.value = null
await store.fetchPublication(pubId())
} catch (e) {
coverError.value = e instanceof Error ? e.message : 'Fehler beim Upload'
} finally {
uploadingCover.value = false
}
}
async function removeCover() {
if (!confirm('Titelbild löschen?')) return
try {
await deleteCoverImage(pubId())
await store.fetchPublication(pubId())
} catch (e) {
coverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
}
}
async function doLinkFruit() {
const id = Number(linkFruitId.value)
if (!id) return
linkingFruit.value = true
try {
await linkFruit(pubId(), id)
linkFruitId.value = ''
fruits.value = await listPubFruits(pubId())
} catch {
// ignore fruit may not exist
} finally {
linkingFruit.value = false
}
}
async function doUnlinkFruit(fruitId: number) {
await unlinkFruit(pubId(), fruitId)
fruits.value = fruits.value.filter((f) => f.fruit_id !== fruitId)
}
async function uploadDesc() {
if (!descFile.value || !descFruitId.value) return
descError.value = null
uploadingDesc.value = true
try {
const d = await uploadDescription(pubId(), Number(descFruitId.value), descFile.value)
descriptions.value.push(d)
descFile.value = null
descFruitId.value = ''
} catch (e) {
descError.value = e instanceof Error ? e.message : 'Fehler beim Upload'
} finally {
uploadingDesc.value = false
}
}
async function deleteDesc(descId: number) {
await deleteDescription(pubId(), descId)
descriptions.value = descriptions.value.filter((d) => d.id !== descId)
}
async function uploadFruitImg() {
if (!fruitImgFile.value || !fruitImgFruitId.value) return
fruitImgError.value = null
uploadingFruitImg.value = true
try {
const img = await uploadPubFruitImage(pubId(), Number(fruitImgFruitId.value), fruitImgFile.value)
fruitImages.value.push(img)
fruitImgFile.value = null
fruitImgFruitId.value = ''
} catch (e) {
fruitImgError.value = e instanceof Error ? e.message : 'Fehler beim Upload'
} finally {
uploadingFruitImg.value = false
}
}
async function deleteFruitImg(imgId: number) {
await deletePubFruitImage(pubId(), imgId)
fruitImages.value = fruitImages.value.filter((i) => i.id !== imgId)
}
</script>
<template>
<div class="mx-auto max-w-3xl p-6">
<div v-if="store.loading" class="text-gray-500">Lade</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<template v-else-if="store.current">
<!-- Header -->
<div class="mb-6 flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold">{{ store.current.title }}</h1>
<p class="text-sm text-gray-500 font-mono">{{ store.current.osdb_pub_id }}</p>
<p v-if="store.current.author" class="text-gray-700">{{ store.current.author }}</p>
</div>
<div v-if="auth.isLoggedIn" class="flex gap-2">
<button
class="rounded border px-3 py-1 text-sm hover:bg-gray-50"
@click="editing = !editing"
>
{{ editing ? 'Abbrechen' : 'Bearbeiten' }}
</button>
<button
:disabled="deleting"
class="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700 disabled:opacity-50"
@click="remove"
>
Löschen
</button>
</div>
</div>
<!-- Edit form -->
<div v-if="auth.isLoggedIn && editing" class="mb-6 rounded border p-4">
<div v-if="serverError" class="mb-3 text-red-600">{{ serverError }}</div>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium">Titel *</label>
<input v-model="editTitle" class="w-full rounded border px-3 py-2" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Autor</label>
<input v-model="editAuthor" class="w-full rounded border px-3 py-2" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">OSDB Kürzel *</label>
<input v-model="editOsdbPubId" class="w-full rounded border px-3 py-2 font-mono" />
</div>
<button
:disabled="saving"
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
@click="save"
>
{{ saving ? 'Speichern…' : 'Speichern' }}
</button>
</div>
</div>
<!-- Cover image -->
<section class="mb-8">
<h2 class="mb-2 text-lg font-semibold">Titelbild</h2>
<div v-if="store.current.image_url" class="mb-3 flex items-start gap-4">
<img
:src="store.current.image_url"
alt="Titelbild"
class="h-32 w-24 cursor-pointer rounded border object-cover shadow hover:opacity-90"
@click="overlayOpen = true"
/>
<button
v-if="auth.isLoggedIn"
class="rounded border px-2 py-1 text-sm text-red-600 hover:bg-red-50"
@click="removeCover"
>
Löschen
</button>
</div>
<div v-else class="mb-3 text-sm text-gray-400">Kein Titelbild vorhanden.</div>
<div v-if="coverError" class="mb-2 text-red-600 text-sm">{{ coverError }}</div>
<div v-if="auth.isLoggedIn" class="flex items-center gap-2">
<input
type="file"
accept="image/*"
class="text-sm"
@change="coverFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
/>
<button
:disabled="!coverFile || uploadingCover"
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
@click="uploadCover"
>
{{ uploadingCover ? 'Hochladen…' : 'Hochladen' }}
</button>
</div>
</section>
<!-- Linked fruits -->
<section class="mb-8">
<h2 class="mb-2 text-lg font-semibold">Verknüpfte Früchte</h2>
<ul v-if="fruits.length" class="mb-3 space-y-1">
<li v-for="f in fruits" :key="f.fruit_id" class="flex items-center justify-between rounded border px-3 py-1 text-sm">
<span>{{ f.name }} <span class="text-gray-400 font-mono text-xs">({{ f.osdb_number }})</span></span>
<button v-if="auth.isLoggedIn" class="text-red-600 hover:underline text-xs" @click="doUnlinkFruit(f.fruit_id)">
Entfernen
</button>
</li>
</ul>
<div v-else class="mb-3 text-sm text-gray-400">Keine Früchte verknüpft.</div>
<div v-if="auth.isLoggedIn" class="flex gap-2">
<input
v-model="linkFruitId"
type="number"
placeholder="Frucht-ID"
class="w-32 rounded border px-3 py-1 text-sm"
/>
<button
:disabled="!linkFruitId || linkingFruit"
class="rounded bg-green-600 px-3 py-1 text-sm text-white hover:bg-green-700 disabled:opacity-50"
@click="doLinkFruit"
>
Verknüpfen
</button>
</div>
</section>
<!-- Descriptions (PDFs) -->
<section class="mb-8">
<h2 class="mb-2 text-lg font-semibold">Beschreibungen (PDF)</h2>
<ul v-if="descriptions.length" class="mb-3 space-y-1">
<li
v-for="d in descriptions"
:key="d.id"
class="flex items-center justify-between rounded border px-3 py-1 text-sm"
>
<a :href="d.url" target="_blank" class="text-blue-600 hover:underline">
{{ d.fruit_name || `Frucht #${d.fruit_id}` }}
</a>
<button v-if="auth.isLoggedIn" class="text-red-600 hover:underline text-xs" @click="deleteDesc(d.id)">
Löschen
</button>
</li>
</ul>
<div v-else class="mb-3 text-sm text-gray-400">Keine Beschreibungen vorhanden.</div>
<div v-if="descError" class="mb-2 text-red-600 text-sm">{{ descError }}</div>
<div v-if="auth.isLoggedIn" class="flex flex-wrap items-center gap-2">
<input
v-model="descFruitId"
type="number"
placeholder="Frucht-ID"
class="w-28 rounded border px-3 py-1 text-sm"
/>
<input
type="file"
accept="application/pdf"
class="text-sm"
@change="descFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
/>
<button
:disabled="!descFile || !descFruitId || uploadingDesc"
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
@click="uploadDesc"
>
{{ uploadingDesc ? 'Hochladen…' : 'PDF hochladen' }}
</button>
</div>
</section>
<!-- Fruit images -->
<section class="mb-8">
<h2 class="mb-2 text-lg font-semibold">Fruchtbilder</h2>
<div v-if="fruitImages.length" class="mb-3 flex flex-wrap gap-3">
<div
v-for="img in fruitImages"
:key="img.id"
class="flex flex-col items-center gap-1"
>
<img
:src="img.url"
:alt="img.filename ?? 'Fruchtbild'"
class="h-24 w-24 rounded border object-cover"
/>
<span class="text-xs text-gray-500">Frucht #{{ img.fruit_id }}</span>
<button v-if="auth.isLoggedIn" class="text-xs text-red-600 hover:underline" @click="deleteFruitImg(img.id)">
Löschen
</button>
</div>
</div>
<div v-else class="mb-3 text-sm text-gray-400">Keine Fruchtbilder vorhanden.</div>
<div v-if="fruitImgError" class="mb-2 text-red-600 text-sm">{{ fruitImgError }}</div>
<div v-if="auth.isLoggedIn" class="flex flex-wrap items-center gap-2">
<input
v-model="fruitImgFruitId"
type="number"
placeholder="Frucht-ID"
class="w-28 rounded border px-3 py-1 text-sm"
/>
<input
type="file"
accept="image/*"
class="text-sm"
@change="fruitImgFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
/>
<button
:disabled="!fruitImgFile || !fruitImgFruitId || uploadingFruitImg"
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
@click="uploadFruitImg"
>
{{ uploadingFruitImg ? 'Hochladen…' : 'Bild hochladen' }}
</button>
</div>
</section>
</template>
<!-- Full-size cover image overlay -->
<ImageOverlayDrawer
v-if="overlayOpen && store.current?.image_url"
:src="store.current.image_url"
alt="Titelbild (Vollansicht)"
@close="overlayOpen = false"
/>
</div>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { usePublicationStore } from '../stores/publicationStore'
import { useAuthStore } from '../stores/authStore'
const store = usePublicationStore()
const auth = useAuthStore()
onMounted(async () => {
try {
await store.fetchPublications()
} catch {
// error already set in store
}
})
</script>
<template>
<div class="mx-auto max-w-4xl p-6">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">Publikationen</h1>
<router-link
v-if="auth.isLoggedIn"
to="/publications/new"
class="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
>
Neue Publikation
</router-link>
</div>
<div v-if="store.loading" class="text-gray-500">Lade</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<div v-else-if="store.publications.length === 0" class="text-gray-500">
Keine Publikationen vorhanden.
</div>
<table v-else class="w-full border-collapse text-sm">
<thead>
<tr class="bg-gray-100 text-left">
<th class="border px-3 py-2">Titel</th>
<th class="border px-3 py-2">Autor</th>
<th class="border px-3 py-2">OSDB Kürzel</th>
<th class="border px-3 py-2"></th>
</tr>
</thead>
<tbody>
<tr v-for="pub in store.publications" :key="pub.id" class="hover:bg-gray-50">
<td class="border px-3 py-2">{{ pub.title }}</td>
<td class="border px-3 py-2">{{ pub.author ?? '—' }}</td>
<td class="border px-3 py-2 font-mono text-xs">{{ pub.osdb_pub_id }}</td>
<td class="border px-3 py-2">
<router-link :to="`/publications/${pub.id}`" class="text-blue-600 hover:underline">
Details
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</template>
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Usage: ./scripts/create_user.sh <username> <password>
# Prints a line suitable for appending to users.env
set -euo pipefail
if [ $# -ne 2 ]; then
echo "Usage: $0 <username> <password>" >&2
exit 1
fi
USERNAME="$1"
PASSWORD="$2"
# bcrypt the peppered input "username:password"
PEPPERED="${USERNAME}:${PASSWORD}"
# Try Python 3 with bcrypt first, then fall back to htpasswd
if python3 -c "import bcrypt" 2>/dev/null; then
HASH=$(python3 -c "
import bcrypt, sys
pw = sys.argv[1].encode()
print(bcrypt.hashpw(pw, bcrypt.gensalt(rounds=12)).decode())
" "$PEPPERED")
elif command -v htpasswd &>/dev/null; then
HASH=$(htpasswd -bnBC 12 "" "$PEPPERED" | tr -d ':\n' | sed 's/^\$/\$/')
else
echo "Error: install 'bcrypt' (pip install bcrypt) or 'htpasswd' (apache2-utils)" >&2
exit 1
fi
echo "${USERNAME}:${HASH}"
+3
View File
@@ -51,7 +51,10 @@ TYP_MAP = {
IMAGE_DIRS = [ IMAGE_DIRS = [
("einzelfruechte", "fruit"), ("einzelfruechte", "fruit"),
("osdb/fru", "fruit"),
("osdb/frucht", "fruit"),
("blueten", "flower"), ("blueten", "flower"),
("osdb/blu", "flower"),
("baume", "tree"), ("baume", "tree"),
] ]
+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()
+2
View File
@@ -0,0 +1,2 @@
# username:bcrypt_hash — generate entries with: ./scripts/create_user.sh <username> <password>
# admin:$2a$12$...