diff --git a/internal/database/db.go b/internal/database/db.go index 486aa36..bdf4241 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package database diff --git a/internal/database/models.go b/internal/database/models.go index 0e7affe..1d937f6 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package database diff --git a/internal/database/querier.go b/internal/database/querier.go index ed1ffc7..da151e5 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package database diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 2de12a2..f2082a3 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: queries.sql package database @@ -11274,7 +11274,7 @@ SET title = $2, notes = $3, position = $4, - updated_at = NOW() + last_modified_at = NOW() WHERE id = $1 AND user_id = $5 RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at ` diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 9058c4c..bc1a513 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -2481,7 +2481,7 @@ SET title = $2, notes = $3, position = $4, - updated_at = NOW() + last_modified_at = NOW() WHERE id = $1 AND user_id = $5 RETURNING *; diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 6618b09..42a0f3d 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -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 diff --git a/internal/router/media.go b/internal/router/media.go index e5ac414..bbaf03c 100644 --- a/internal/router/media.go +++ b/internal/router/media.go @@ -41,6 +41,12 @@ func registerMediaRoutes(cfg *Config) { protected.PUT("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.UpdateMediaHighlight) protected.DELETE("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.DeleteMediaHighlight) + // Bookmark routes (all authenticated users) + protected.GET("/media-items/:id/bookmarks", cfg.MediaHandler.GetMediaBookmarks) + protected.POST("/media-items/:id/bookmarks", cfg.MediaHandler.CreateMediaBookmark) + protected.PUT("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.UpdateMediaBookmark) + protected.DELETE("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.DeleteMediaBookmark) + // Admin-only media routes admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem) admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem) diff --git a/templates/reader.templ b/templates/reader.templ index 75ad6b3..3beb7f7 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -5,7 +5,7 @@ import ( "fmt" ) -func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string { +func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) string { config := map[string]interface{}{ "mediaItemId": metadata.MediaItemID, "fileUrl": metadata.FileURL, @@ -27,6 +27,23 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string { config["savedTotalPages"] = progress.TotalPages } } + if len(bookmarks) > 0 { + items := make([]map[string]interface{}, 0, len(bookmarks)) + for _, b := range bookmarks { + var page any + if b.PageNumber != nil { + page = *b.PageNumber + } + items = append(items, map[string]interface{}{ + "id": b.ID, + "title": b.Title, + "positionLabel": b.Position, + "cfi": b.CfiPosition, + "page": page, + }) + } + config["bookmarks"] = items + } jsonBytes, _ := json.Marshal(config) return fmt.Sprintf("initReader(%s)", string(jsonBytes)) } @@ -47,36 +64,32 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
@ReaderChrome(user, metadata, progress) - -