Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d35da524e9 | ||
|
|
1b381d9385 | ||
|
|
649c9687ac | ||
|
|
2525e8b68d | ||
|
|
78c23557da | ||
|
|
50bfa30b06 | ||
|
|
e8d48d4fcb | ||
|
|
dfa3d91ab9 | ||
|
|
fce4d67cbe | ||
|
|
2e3f0f366c | ||
|
|
78892bf32d | ||
|
|
b0cb64bdaf |
@@ -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.
|
||||||
@@ -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/
|
||||||
|
|||||||
@@ -34,7 +34,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:
|
||||||
|
|||||||
@@ -6,16 +6,22 @@ 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
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DATABASE_URL=postgres://osdb:osdb@localhost:5432/osdb
|
||||||
|
PORT=3000
|
||||||
|
JWT_SECRET=change-me-use-a-long-random-string
|
||||||
|
USERS_FILE=../users.env
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,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', () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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'))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Import Scripts
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Postgres running and migrated (`make migrate-up`)
|
||||||
|
- `DATABASE_URL` set, e.g.:
|
||||||
|
```
|
||||||
|
export DATABASE_URL=postgres://user:pass@localhost:5432/osdb
|
||||||
|
```
|
||||||
|
- `03-data/` directory present at repo root
|
||||||
|
- `psycopg2` installed (`pip install psycopg2-binary`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Run Order
|
||||||
|
|
||||||
|
Scripts must be run in order — publications import depends on fruits being present.
|
||||||
|
|
||||||
|
### 1. Import fruits
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=... python3 scripts/import_fruits.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Imports fruits, synonyms, and fruit images from `03-data/obstsorten.xml`.
|
||||||
|
Idempotent: upserts fruits by `osdb_number`.
|
||||||
|
|
||||||
|
### 2. Import publications
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=... python3 scripts/import_publications.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Imports publications from `03-data/osws.xml` (only `<obj>` elements with `<osw>=1`).
|
||||||
|
For each publication: upserts the row, loads cover image, links fruits found by
|
||||||
|
filesystem scan of `03-data/osdb/{pubId}/`, and imports PDFs and fruit images.
|
||||||
|
Idempotent: clears and re-inserts linked data on re-run.
|
||||||
Executable
+31
@@ -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}"
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# username:bcrypt_hash — generate entries with: ./scripts/create_user.sh <username> <password>
|
||||||
|
# admin:$2a$12$...
|
||||||
Reference in New Issue
Block a user