refactor: restructure project from bookmann to shelf
- Rename project from 'bookmann' to 'shelf' - Move all backend/ contents to root level (flatten structure) - Update Go module name from 'bookmann' to 'shelf' - Update all import paths to use new 'shelf' module - Update Dockerfile to work without backend/ subdirectory - Update docker-compose.yml to use new structure and rename containers - Update .gitignore for new file paths - Update README.md with new project name and structure - Regenerate database code with new module imports
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"shelf/internal/database"
|
||||
"shelf/internal/services"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *database.Queries
|
||||
scanner *services.EbookScanner
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewHandler(db *database.Queries) *Handler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Handler{
|
||||
db: db,
|
||||
scanner: services.NewEbookScanner(db),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// parseDate parses a date string in YYYY-MM-DD format
|
||||
func parseDate(dateStr string) time.Time {
|
||||
if dateStr == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", dateStr); err == nil {
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func SetupRoutes(g *echo.Group, db *database.Queries) {
|
||||
h := NewHandler(db)
|
||||
|
||||
g.GET("/ebooks", h.ListEbooks)
|
||||
g.GET("/ebooks/:id", h.GetEbook)
|
||||
g.POST("/ebooks", h.CreateEbook)
|
||||
g.PUT("/ebooks/:id", h.UpdateEbook)
|
||||
g.DELETE("/ebooks/:id", h.DeleteEbook)
|
||||
|
||||
g.GET("/ebooks/:id/progress", h.GetReadingProgress)
|
||||
g.PUT("/ebooks/:id/progress", h.UpdateReadingProgress)
|
||||
|
||||
g.GET("/ebooks/:id/rating", h.GetEbookRating)
|
||||
g.POST("/ebooks/:id/rating", h.CreateOrUpdateEbookRating)
|
||||
g.PUT("/ebooks/:id/rating", h.CreateOrUpdateEbookRating)
|
||||
g.DELETE("/ebooks/:id/rating", h.DeleteEbookRating)
|
||||
g.GET("/ebooks/:id/ratings", h.GetEbookRatings)
|
||||
|
||||
// Scanner routes
|
||||
g.POST("/scanner/scan", h.ScanEbooks)
|
||||
g.POST("/scanner/start", h.StartScanner)
|
||||
g.POST("/scanner/stop", h.StopScanner)
|
||||
}
|
||||
|
||||
// ListEbooks handles GET /api/ebooks
|
||||
func (h *Handler) ListEbooks(c echo.Context) error {
|
||||
limitStr := c.QueryParam("limit")
|
||||
offsetStr := c.QueryParam("offset")
|
||||
|
||||
limit := int32(20) // default
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil {
|
||||
limit = int32(l)
|
||||
}
|
||||
}
|
||||
|
||||
offset := int32(0)
|
||||
if offsetStr != "" {
|
||||
if o, err := strconv.Atoi(offsetStr); err == nil {
|
||||
offset = int32(o)
|
||||
}
|
||||
}
|
||||
|
||||
ebooks, err := h.db.ListEbooks(c.Request().Context(), database.ListEbooksParams{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, ebooks)
|
||||
}
|
||||
|
||||
// GetEbook handles GET /api/ebooks/:id
|
||||
func (h *Handler) GetEbook(c echo.Context) error {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
}
|
||||
|
||||
ebook, err := h.db.GetEbook(c.Request().Context(), pgtype.UUID{Bytes: id, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, ebook)
|
||||
}
|
||||
|
||||
// CreateEbookRequest represents the request for creating an ebook
|
||||
type CreateEbookRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
||||
Author string `json:"author"`
|
||||
ISBN string `json:"isbn"`
|
||||
Description string `json:"description"`
|
||||
FilePath string `json:"file_path" validate:"required"`
|
||||
FileSize int64 `json:"file_size" validate:"required,min=1"`
|
||||
MimeType string `json:"mime_type" validate:"required"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
Series string `json:"series"`
|
||||
SeriesNumber int32 `json:"series_number"`
|
||||
Tags string `json:"tags"`
|
||||
ASIN string `json:"asin"`
|
||||
DatePublished string `json:"date_published"`
|
||||
Publisher string `json:"publisher"`
|
||||
Contributors string `json:"contributors"`
|
||||
}
|
||||
|
||||
// CreateEbook handles POST /api/ebooks
|
||||
func (h *Handler) CreateEbook(c echo.Context) error {
|
||||
var req CreateEbookRequest
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
ebook, err := h.db.CreateEbook(c.Request().Context(), database.CreateEbookParams{
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
|
||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
||||
FilePath: req.FilePath,
|
||||
FileSize: pgtype.Int8{Int64: req.FileSize, Valid: req.FileSize > 0},
|
||||
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
|
||||
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
||||
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""},
|
||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, ebook)
|
||||
}
|
||||
|
||||
// UpdateEbookRequest represents the request for updating an ebook
|
||||
type UpdateEbookRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
||||
Author string `json:"author"`
|
||||
ISBN string `json:"isbn"`
|
||||
Description string `json:"description"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
Series string `json:"series"`
|
||||
SeriesNumber int32 `json:"series_number"`
|
||||
Tags string `json:"tags"`
|
||||
ASIN string `json:"asin"`
|
||||
DatePublished string `json:"date_published"`
|
||||
Publisher string `json:"publisher"`
|
||||
Contributors string `json:"contributors"`
|
||||
}
|
||||
|
||||
// UpdateEbook handles PUT /api/ebooks/:id
|
||||
func (h *Handler) UpdateEbook(c echo.Context) error {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
}
|
||||
|
||||
var req UpdateEbookRequest
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
ebook, err := h.db.UpdateEbook(c.Request().Context(), database.UpdateEbookParams{
|
||||
ID: pgtype.UUID{Bytes: id, Valid: true},
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
Isbn: pgtype.Text{String: req.ISBN, Valid: req.ISBN != ""},
|
||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
||||
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
||||
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""},
|
||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, ebook)
|
||||
}
|
||||
|
||||
// DeleteEbook handles DELETE /api/ebooks/:id
|
||||
func (h *Handler) DeleteEbook(c echo.Context) error {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
}
|
||||
|
||||
err = h.db.DeleteEbook(c.Request().Context(), pgtype.UUID{Bytes: id, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetReadingProgress handles GET /api/ebooks/:id/progress
|
||||
func (h *Handler) GetReadingProgress(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
progress, err := h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
// If no progress found, return default
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"ebook_id": ebookIdStr,
|
||||
"user_id": userID,
|
||||
"current_page": 0,
|
||||
"total_pages": nil,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, progress)
|
||||
}
|
||||
|
||||
// UpdateReadingProgressRequest represents the request for updating reading progress
|
||||
type UpdateReadingProgressRequest struct {
|
||||
CurrentPage int32 `json:"current_page" validate:"required,min=0"`
|
||||
TotalPages int32 `json:"total_pages" validate:"omitempty,min=1"`
|
||||
}
|
||||
|
||||
// UpdateReadingProgress handles PUT /api/ebooks/:id/progress
|
||||
func (h *Handler) UpdateReadingProgress(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
var req UpdateReadingProgressRequest
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
progress, err := h.db.UpdateReadingProgress(c.Request().Context(), database.UpdateReadingProgressParams{
|
||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, progress)
|
||||
}
|
||||
|
||||
// CreateOrUpdateEbookRatingRequest represents the request for creating/updating an ebook rating
|
||||
type CreateOrUpdateEbookRatingRequest struct {
|
||||
Rating int32 `json:"rating" validate:"required,min=1,max=5"`
|
||||
}
|
||||
|
||||
// GetEbookRating handles GET /api/ebooks/:id/rating
|
||||
func (h *Handler) GetEbookRating(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
rating, err := h.db.GetEbookRating(c.Request().Context(), database.GetEbookRatingParams{
|
||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
// If no rating found, return 404
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "rating not found"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, rating)
|
||||
}
|
||||
|
||||
// CreateOrUpdateEbookRating handles POST/PUT /api/ebooks/:id/rating
|
||||
func (h *Handler) CreateOrUpdateEbookRating(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
var req CreateOrUpdateEbookRatingRequest
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
rating, err := h.db.CreateEbookRating(c.Request().Context(), database.CreateEbookRatingParams{
|
||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Rating: req.Rating,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, rating)
|
||||
}
|
||||
|
||||
// DeleteEbookRating handles DELETE /api/ebooks/:id/rating
|
||||
func (h *Handler) DeleteEbookRating(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
userID := c.Get("user_id").(string)
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
err = h.db.DeleteEbookRating(c.Request().Context(), database.DeleteEbookRatingParams{
|
||||
EbookID: pgtype.UUID{Bytes: ebookId, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetEbookRatings handles GET /api/ebooks/:id/ratings
|
||||
func (h *Handler) GetEbookRatings(c echo.Context) error {
|
||||
ebookIdStr := c.Param("id")
|
||||
|
||||
ebookId, err := uuid.Parse(ebookIdStr)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid ebook id"})
|
||||
}
|
||||
|
||||
ratings, err := h.db.GetEbookRatings(c.Request().Context(), pgtype.UUID{Bytes: ebookId, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, ratings)
|
||||
}
|
||||
|
||||
// ScanEbooksRequest represents the request for scanning ebooks
|
||||
type ScanEbooksRequest struct {
|
||||
FolderPaths []string `json:"folder_paths" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
// ScanEbooks handles POST /api/scanner/scan
|
||||
func (h *Handler) ScanEbooks(c echo.Context) error {
|
||||
var req ScanEbooksRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Set the folder paths for scanning
|
||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
||||
}
|
||||
|
||||
// Perform the scan
|
||||
if err := h.scanner.ScanFolders(h.ctx); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "scan failed: " + err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "scan completed"})
|
||||
}
|
||||
|
||||
// StartScanner handles POST /api/scanner/start
|
||||
func (h *Handler) StartScanner(c echo.Context) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
var req ScanEbooksRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Set the folder paths
|
||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
||||
}
|
||||
|
||||
// Start watching for changes
|
||||
h.scanner.WatchChanges(h.ctx)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "scanner started"})
|
||||
}
|
||||
|
||||
// StopScanner handles POST /api/scanner/stop
|
||||
func (h *Handler) StopScanner(c echo.Context) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.cancel()
|
||||
h.ctx, h.cancel = context.WithCancel(context.Background())
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "scanner stopped"})
|
||||
}
|
||||
Reference in New Issue
Block a user