feat(handlers): Add OPDS, collections, book matching, and sync handlers
- Add OPDS handler for device catalog and book downloads - Add collections handler for collection CRUD - Add book matching service for cross-device book linking - Add sidecar handler for Kobo metadata sync - Add sync handler for device synchronization
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
"bookmann/internal/services"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type CollectionHandler struct {
|
||||
db *database.Queries
|
||||
collectionService *services.CollectionService
|
||||
}
|
||||
|
||||
func NewCollectionHandler(db *database.Queries) *CollectionHandler {
|
||||
return &CollectionHandler{
|
||||
db: db,
|
||||
collectionService: services.NewCollectionService(db),
|
||||
}
|
||||
}
|
||||
|
||||
type CreateCollectionRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
Icon string `json:"icon"`
|
||||
AutoAssignRules []services.Rule `json:"auto_assign_rules"`
|
||||
ViewSettings map[string]interface{} `json:"view_settings"`
|
||||
}
|
||||
|
||||
type UpdateCollectionRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
Icon string `json:"icon"`
|
||||
AutoAssignRules []services.Rule `json:"auto_assign_rules"`
|
||||
ViewSettings map[string]interface{} `json:"view_settings"`
|
||||
}
|
||||
|
||||
type AddBooksRequest struct {
|
||||
BookIDs []string `json:"book_ids" validate:"required"`
|
||||
}
|
||||
|
||||
type CreateDeviceMappingRequest struct {
|
||||
CollectionID string `json:"collection_id" validate:"required"`
|
||||
DeviceShelfName string `json:"device_shelf_name" validate:"required"`
|
||||
SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"`
|
||||
}
|
||||
|
||||
type UpdateDeviceMappingRequest struct {
|
||||
DeviceShelfName string `json:"device_shelf_name" validate:"required"`
|
||||
SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"`
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) CreateCollection(c echo.Context) error {
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
var req CreateCollectionRequest
|
||||
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()})
|
||||
}
|
||||
|
||||
collection, err := h.collectionService.CreateCollection(
|
||||
c.Request().Context(),
|
||||
userUUID,
|
||||
req.Name,
|
||||
req.Description,
|
||||
req.Color,
|
||||
req.Icon,
|
||||
req.AutoAssignRules,
|
||||
req.ViewSettings,
|
||||
)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
bookCount := int32(0)
|
||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||
"name": collection.Name,
|
||||
"description": textToString(collection.Description),
|
||||
"color": textToString(collection.Color),
|
||||
"icon": textToString(collection.Icon),
|
||||
"auto_assign_rules": collection.AutoAssignRules,
|
||||
"view_settings": collection.ViewSettings,
|
||||
"book_count": bookCount,
|
||||
"created_at": collection.CreatedAt.Time.String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) GetCollections(c echo.Context) error {
|
||||
includeAuto := c.QueryParam("include_auto") == "true"
|
||||
sortBy := c.QueryParam("sort_by")
|
||||
|
||||
collections, err := h.GetCollectionsData(c)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
type CollectionResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
Icon string `json:"icon"`
|
||||
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
response := make([]CollectionResponse, 0, len(collections))
|
||||
for _, col := range collections {
|
||||
if !includeAuto && len(col.AutoAssignRules) > 0 {
|
||||
continue
|
||||
}
|
||||
response = append(response, CollectionResponse{
|
||||
ID: uuid.UUID(col.ID.Bytes),
|
||||
Name: col.Name,
|
||||
Description: textToString(col.Description),
|
||||
Color: textToString(col.Color),
|
||||
Icon: textToString(col.Icon),
|
||||
AutoAssignRules: json.RawMessage(col.AutoAssignRules),
|
||||
CreatedAt: col.CreatedAt.Time.String(),
|
||||
})
|
||||
}
|
||||
|
||||
if sortBy == "name" {
|
||||
for i := 0; i < len(response); i++ {
|
||||
for j := i + 1; j < len(response); j++ {
|
||||
if response[i].Name > response[j].Name {
|
||||
response[i], response[j] = response[j], response[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"collections": response,
|
||||
"total": len(response),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) GetCollection(c echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
|
||||
collection, err := h.GetCollectionData(c, collectionID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||
}
|
||||
|
||||
books, err := h.GetCollectionBooksData(c, collectionID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
type BookInfo struct {
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
}
|
||||
|
||||
bookList := make([]BookInfo, 0, len(books))
|
||||
for _, book := range books {
|
||||
bookList = append(bookList, BookInfo{
|
||||
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
||||
Title: book.Title,
|
||||
Author: textToString(book.Author),
|
||||
CoverImagePath: textToString(book.CoverImagePath),
|
||||
})
|
||||
}
|
||||
|
||||
var viewSettings map[string]interface{}
|
||||
if len(collection.ViewSettings) > 0 {
|
||||
json.Unmarshal(collection.ViewSettings, &viewSettings)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||
"name": collection.Name,
|
||||
"description": textToString(collection.Description),
|
||||
"color": textToString(collection.Color),
|
||||
"icon": textToString(collection.Icon),
|
||||
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
||||
"view_settings": viewSettings,
|
||||
"books": bookList,
|
||||
"book_count": len(bookList),
|
||||
"created_at": collection.CreatedAt.Time.String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) UpdateCollection(c echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
|
||||
var req UpdateCollectionRequest
|
||||
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()})
|
||||
}
|
||||
|
||||
collection, err := h.collectionService.UpdateCollection(
|
||||
c.Request().Context(),
|
||||
collectionID,
|
||||
req.Name,
|
||||
req.Description,
|
||||
req.Color,
|
||||
req.Icon,
|
||||
req.AutoAssignRules,
|
||||
req.ViewSettings,
|
||||
)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||
"name": collection.Name,
|
||||
"description": textToString(collection.Description),
|
||||
"color": textToString(collection.Color),
|
||||
"icon": textToString(collection.Icon),
|
||||
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
||||
"view_settings": json.RawMessage(collection.ViewSettings),
|
||||
"created_at": collection.CreatedAt.Time.String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) DeleteCollection(c echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
|
||||
err = h.collectionService.DeleteCollection(c.Request().Context(), collectionID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) AddBooks(c echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
var req AddBooksRequest
|
||||
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()})
|
||||
}
|
||||
|
||||
for _, bookIDStr := range req.BookIDs {
|
||||
bookID, err := uuid.Parse(bookIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
h.collectionService.AddBookToCollection(c.Request().Context(), collectionID, bookID, userUUID)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) RemoveBook(c echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
|
||||
bookID, err := uuid.Parse(c.Param("bookId"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
||||
}
|
||||
|
||||
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) GetDeviceMappings(c echo.Context) error {
|
||||
deviceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
||||
}
|
||||
|
||||
mappings, err := h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
type MappingResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CollectionID uuid.UUID `json:"collection_id"`
|
||||
CollectionName string `json:"collection_name"`
|
||||
DeviceShelfName string `json:"device_shelf_name"`
|
||||
SyncDirection string `json:"sync_direction"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
response := make([]MappingResponse, 0, len(mappings))
|
||||
for _, m := range mappings {
|
||||
response = append(response, MappingResponse{
|
||||
ID: uuid.UUID(m.ID.Bytes),
|
||||
CollectionID: uuid.UUID(m.CollectionID.Bytes),
|
||||
CollectionName: m.CollectionName,
|
||||
DeviceShelfName: textToString(m.DeviceShelfName),
|
||||
SyncDirection: textToString(m.SyncDirection),
|
||||
CreatedAt: m.CreatedAt.Time.String(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"device_id": deviceID.String(),
|
||||
"mappings": response,
|
||||
"total": len(response),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) CreateDeviceMapping(c echo.Context) error {
|
||||
deviceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
||||
}
|
||||
|
||||
var req CreateDeviceMappingRequest
|
||||
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()})
|
||||
}
|
||||
|
||||
collectionID, err := uuid.Parse(req.CollectionID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
|
||||
mapping, err := h.collectionService.CreateDeviceShelfMapping(
|
||||
c.Request().Context(),
|
||||
collectionID,
|
||||
deviceID,
|
||||
req.DeviceShelfName,
|
||||
req.SyncDirection,
|
||||
)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||
"id": uuid.UUID(mapping.ID.Bytes).String(),
|
||||
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
|
||||
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
|
||||
"device_shelf_name": textToString(mapping.DeviceShelfName),
|
||||
"sync_direction": textToString(mapping.SyncDirection),
|
||||
"created_at": mapping.CreatedAt.Time.String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) UpdateDeviceMapping(c echo.Context) error {
|
||||
mappingID, err := uuid.Parse(c.Param("mappingId"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
|
||||
}
|
||||
|
||||
var req UpdateDeviceMappingRequest
|
||||
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()})
|
||||
}
|
||||
|
||||
mapping, err := h.collectionService.UpdateDeviceShelfMapping(
|
||||
c.Request().Context(),
|
||||
mappingID,
|
||||
req.DeviceShelfName,
|
||||
req.SyncDirection,
|
||||
)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": uuid.UUID(mapping.ID.Bytes).String(),
|
||||
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
|
||||
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
|
||||
"device_shelf_name": textToString(mapping.DeviceShelfName),
|
||||
"sync_direction": textToString(mapping.SyncDirection),
|
||||
"created_at": mapping.CreatedAt.Time.String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) DeleteDeviceMapping(c echo.Context) error {
|
||||
mappingID, err := uuid.Parse(c.Param("mappingId"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
|
||||
}
|
||||
|
||||
err = h.collectionService.DeleteDeviceShelfMapping(c.Request().Context(), mappingID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) GetBookCollections(c echo.Context) error {
|
||||
bookID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
||||
}
|
||||
|
||||
collections, err := h.collectionService.GetBookCollections(c.Request().Context(), bookID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
type CollectionResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
response := make([]CollectionResponse, 0, len(collections))
|
||||
for _, col := range collections {
|
||||
response = append(response, CollectionResponse{
|
||||
ID: uuid.UUID(col.ID.Bytes),
|
||||
Name: col.Name,
|
||||
Description: textToString(col.Description),
|
||||
Color: textToString(col.Color),
|
||||
Icon: textToString(col.Icon),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"book_id": bookID.String(),
|
||||
"collections": response,
|
||||
"total": len(response),
|
||||
})
|
||||
}
|
||||
|
||||
func textToString(t pgtype.Text) string {
|
||||
if t.Valid {
|
||||
return t.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) GetCollectionsData(c echo.Context) ([]database.Collections, error) {
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) GetCollectionData(c echo.Context, collectionID uuid.UUID) (database.Collections, error) {
|
||||
return h.collectionService.GetCollection(c.Request().Context(), collectionID)
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) GetCollectionBooksData(c echo.Context, collectionID uuid.UUID) ([]database.GetCollectionItemsRow, error) {
|
||||
return h.collectionService.GetCollectionBooks(c.Request().Context(), collectionID)
|
||||
}
|
||||
Reference in New Issue
Block a user