|
|
|
@@ -131,6 +131,25 @@ type UpdateMediaHighlightRequest struct {
|
|
|
|
|
NoteID string `json:"note_id"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CreateMediaBookmarkRequest represents the request for creating a media bookmark
|
|
|
|
|
type CreateMediaBookmarkRequest struct {
|
|
|
|
|
Title string `json:"title" validate:"required,min=1,max=255"`
|
|
|
|
|
Position string `json:"position" validate:"max=100"`
|
|
|
|
|
Notes string `json:"notes" validate:"max=10000"`
|
|
|
|
|
CfiPosition string `json:"cfi_position" validate:"max=255"`
|
|
|
|
|
PageNumber int32 `json:"page_number"`
|
|
|
|
|
ChapterNumber int32 `json:"chapter_number"`
|
|
|
|
|
Percentage float64 `json:"percentage"`
|
|
|
|
|
ChapterReference int32 `json:"chapter_reference"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
|
|
|
|
|
type UpdateMediaBookmarkRequest struct {
|
|
|
|
|
Title string `json:"title" validate:"required,min=1,max=255"`
|
|
|
|
|
Notes string `json:"notes" validate:"max=10000"`
|
|
|
|
|
Position string `json:"position" validate:"max=100"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type MediaHandler struct {
|
|
|
|
|
db *database.Queries
|
|
|
|
|
worker *services.Worker
|
|
|
|
@@ -1677,6 +1696,152 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetMediaBookmarks handles GET /api/media-items/:id/bookmarks
|
|
|
|
|
func (mh *MediaHandler) GetMediaBookmarks(c *echo.Context) error {
|
|
|
|
|
userID := c.Get("user_id").(string)
|
|
|
|
|
userUUID, err := uuid.Parse(userID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mediaID := c.Param("id")
|
|
|
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bookmarks, err := mh.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
|
|
|
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, 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.JSON(http.StatusOK, bookmarks)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CreateMediaBookmark handles POST /api/media-items/:id/bookmarks
|
|
|
|
|
func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
|
|
|
|
|
userID := c.Get("user_id").(string)
|
|
|
|
|
userUUID, err := uuid.Parse(userID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mediaID := c.Param("id")
|
|
|
|
|
mediaUUID, err := uuid.Parse(mediaID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var req CreateMediaBookmarkRequest
|
|
|
|
|
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()})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The sync-aware path (dedup + LWW + tombstones) is preferred; fall back
|
|
|
|
|
// to the plain query when the service isn't wired (e.g. some tests).
|
|
|
|
|
if mh.annotationSvc != nil {
|
|
|
|
|
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
|
|
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
|
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
|
|
|
Title: req.Title,
|
|
|
|
|
Position: req.Position,
|
|
|
|
|
Notes: req.Notes,
|
|
|
|
|
PageNumber: req.PageNumber,
|
|
|
|
|
ChapterNumber: req.ChapterNumber,
|
|
|
|
|
CFIPosition: req.CfiPosition,
|
|
|
|
|
PercentageLoc: req.Percentage,
|
|
|
|
|
ChapterReference: req.ChapterReference,
|
|
|
|
|
Source: "web",
|
|
|
|
|
ModifiedAt: time.Now(),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
|
|
|
}
|
|
|
|
|
return c.JSON(http.StatusCreated, result.Bookmark)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bookmark, err := mh.db.CreateMediaBookmark(c.Request().Context(), database.CreateMediaBookmarkParams{
|
|
|
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
|
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
|
|
|
PageNumber: pgtype.Int4{Int32: req.PageNumber, Valid: req.PageNumber > 0},
|
|
|
|
|
ChapterNumber: pgtype.Int4{Int32: req.ChapterNumber, Valid: req.ChapterNumber > 0},
|
|
|
|
|
CfiPosition: pgtype.Text{String: req.CfiPosition, Valid: req.CfiPosition != ""},
|
|
|
|
|
Title: req.Title,
|
|
|
|
|
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
|
|
|
|
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
|
|
|
}
|
|
|
|
|
return c.JSON(http.StatusCreated, bookmark)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UpdateMediaBookmark handles PUT /api/media-items/:id/bookmarks/:bookmarkId
|
|
|
|
|
func (mh *MediaHandler) UpdateMediaBookmark(c *echo.Context) error {
|
|
|
|
|
userID := c.Get("user_id").(string)
|
|
|
|
|
userUUID, err := uuid.Parse(userID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bookmarkID := c.Param("bookmarkId")
|
|
|
|
|
bookmarkUUID, err := uuid.Parse(bookmarkID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var req UpdateMediaBookmarkRequest
|
|
|
|
|
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()})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bookmark, err := mh.db.UpdateMediaBookmark(c.Request().Context(), database.UpdateMediaBookmarkParams{
|
|
|
|
|
ID: pgtype.UUID{Bytes: bookmarkUUID, Valid: true},
|
|
|
|
|
Title: req.Title,
|
|
|
|
|
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
|
|
|
|
|
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
|
|
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.JSON(http.StatusOK, bookmark)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DeleteMediaBookmark handles DELETE /api/media-items/:id/bookmarks/:bookmarkId
|
|
|
|
|
func (mh *MediaHandler) DeleteMediaBookmark(c *echo.Context) error {
|
|
|
|
|
bookmarkID := c.Param("bookmarkId")
|
|
|
|
|
bookmarkUUID, err := uuid.Parse(bookmarkID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pgBookmarkID := pgtype.UUID{Bytes: bookmarkUUID, Valid: true}
|
|
|
|
|
|
|
|
|
|
if mh.annotationSvc != nil {
|
|
|
|
|
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgBookmarkID, "web"); err != nil {
|
|
|
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
|
|
|
}
|
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), pgBookmarkID); err != nil {
|
|
|
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SearchMediaItems handles GET /api/media-items/search
|
|
|
|
|
// Supports two modes:
|
|
|
|
|
// 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns
|
|
|
|
|