KOReader push (processBookAnnotations) accepts deleted_highlights and deleted_bookmarks arrays of dedup keys and tombstones the matching rows, after the upserts so a key present in both lists resolves to 'deleted' (the newer intent). Deletions remain soft: rows stay restorable from the history and echo to other devices as tombstones on their next pull. A stale device replay of the annotation cannot resurrect the tombstone — device pushes carry no modification timestamp, so the save loses to the delete. Absence from these arrays is never a delete, keeping category toggles safe. New annotation-history endpoints (annotation_history.go, media.go): GET /api/media-items/:id/annotations/deleted POST /api/media-items/:id/annotations/:annotationId/restore DELETE /api/media-items/:id/annotations/:annotationId All scoped to the authenticated user and the route's book; the DELETE is the permanent purge (annotation_type required in query or body). MediaDetail gains DeletedAnnotations, populated by the book page route via the shared DeletedAnnotationsForBook builder, so the server-rendered history ships with the page instead of requiring a client round-trip. Binding tests cover the plugin's exact wire shape and the legacy plugin case (arrays omitted -> empty).
168 lines
6.2 KiB
Go
168 lines
6.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"bookhoard/internal/database"
|
|
wsync "bookhoard/internal/sync"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
// DeletedAnnotationResponse is one entry of the deleted-annotation history
|
|
// for a book (the book page's "recently deleted" list). Restoring returns the
|
|
// row to the active set; purging removes it permanently.
|
|
type DeletedAnnotationResponse struct {
|
|
ID string `json:"id"`
|
|
AnnotationType string `json:"annotation_type"`
|
|
DisplayText string `json:"display_text"`
|
|
SecondaryText string `json:"secondary_text,omitempty"`
|
|
Color string `json:"color,omitempty"`
|
|
DeletedAt time.Time `json:"deleted_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// DeletedAnnotationsForBook builds the deleted-annotation history for a user
|
|
// and book. Shared by the JSON API and the book page's server-rendered modal.
|
|
func DeletedAnnotationsForBook(ctx context.Context, db *database.Queries, userID, mediaItemID pgtype.UUID) []DeletedAnnotationResponse {
|
|
rows, err := db.ListDeletedAnnotationsForBook(ctx, database.ListDeletedAnnotationsForBookParams{
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
})
|
|
if err != nil {
|
|
return []DeletedAnnotationResponse{}
|
|
}
|
|
|
|
response := make([]DeletedAnnotationResponse, 0, len(rows))
|
|
for _, row := range rows {
|
|
entry := DeletedAnnotationResponse{
|
|
ID: uuid.UUID(row.ID.Bytes).String(),
|
|
AnnotationType: row.AnnotationType,
|
|
DisplayText: row.DisplayText,
|
|
SecondaryText: row.SecondaryText.String,
|
|
Color: row.Color.String,
|
|
}
|
|
if row.DeletedAt.Valid {
|
|
entry.DeletedAt = row.DeletedAt.Time
|
|
}
|
|
if row.CreatedAt.Valid {
|
|
entry.CreatedAt = row.CreatedAt.Time
|
|
}
|
|
response = append(response, entry)
|
|
}
|
|
return response
|
|
}
|
|
|
|
// GetDeletedAnnotations handles GET /api/media-items/:id/annotations/deleted
|
|
func (mh *MediaHandler) GetDeletedAnnotations(c *echo.Context) error {
|
|
userUUID, mediaUUID, err := mh.parseUserAndMediaIDs(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
response := DeletedAnnotationsForBook(c.Request().Context(), mh.db, userUUID, mediaUUID)
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"deleted_annotations": response,
|
|
"total": len(response),
|
|
})
|
|
}
|
|
|
|
// RestoreDeletedAnnotation handles POST /api/media-items/:id/annotations/:annotationId/restore
|
|
// Body/query: annotation_type=highlight|note|bookmark
|
|
func (mh *MediaHandler) RestoreDeletedAnnotation(c *echo.Context) error {
|
|
userUUID, mediaUUID, annotationUUID, kind, errResp := mh.parseAnnotationHistoryRequest(c, true)
|
|
if errResp != nil {
|
|
return errResp
|
|
}
|
|
|
|
if mh.annotationSvc == nil {
|
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "annotation service unavailable"})
|
|
}
|
|
|
|
restored, err := mh.annotationSvc.RestoreAnnotationByID(c.Request().Context(), kind, userUUID, mediaUUID, annotationUUID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to restore annotation"})
|
|
}
|
|
if !restored {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "deleted annotation not found"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"restored": true})
|
|
}
|
|
|
|
// PurgeDeletedAnnotation handles DELETE /api/media-items/:id/annotations/:annotationId
|
|
// Query: annotation_type=highlight|note|bookmark. Permanent — removes the
|
|
// tombstoned row from the history.
|
|
func (mh *MediaHandler) PurgeDeletedAnnotation(c *echo.Context) error {
|
|
userUUID, mediaUUID, annotationUUID, kind, errResp := mh.parseAnnotationHistoryRequest(c, false)
|
|
if errResp != nil {
|
|
return errResp
|
|
}
|
|
|
|
if mh.annotationSvc == nil {
|
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "annotation service unavailable"})
|
|
}
|
|
|
|
purged, err := mh.annotationSvc.PurgeAnnotationByID(c.Request().Context(), kind, userUUID, mediaUUID, annotationUUID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to purge annotation"})
|
|
}
|
|
if !purged {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "deleted annotation not found"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"purged": true})
|
|
}
|
|
|
|
// parseUserAndMediaIDs extracts the authenticated user and the media item
|
|
// from the route. A non-nil error has already been written as the response.
|
|
func (mh *MediaHandler) parseUserAndMediaIDs(c *echo.Context) (pgtype.UUID, pgtype.UUID, error) {
|
|
userID := c.Get("user_id").(string)
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return pgtype.UUID{}, pgtype.UUID{}, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
|
}
|
|
mediaID := c.Param("id")
|
|
mediaIDUUID, err := uuid.Parse(mediaID)
|
|
if err != nil {
|
|
return pgtype.UUID{}, pgtype.UUID{}, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
|
}
|
|
return pgtype.UUID{Bytes: userUUID, Valid: true}, pgtype.UUID{Bytes: mediaIDUUID, Valid: true}, nil
|
|
}
|
|
|
|
// parseAnnotationHistoryRequest extracts user, media item, annotation ID, and
|
|
// the annotation_type (from query param or JSON body — restore posts a body,
|
|
// purge uses a query param). A non-nil error has already been written.
|
|
func (mh *MediaHandler) parseAnnotationHistoryRequest(c *echo.Context, allowBody bool) (pgtype.UUID, pgtype.UUID, pgtype.UUID, string, error) {
|
|
userUUID, mediaUUID, err := mh.parseUserAndMediaIDs(c)
|
|
if err != nil {
|
|
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", err
|
|
}
|
|
|
|
annotationID := c.Param("annotationId")
|
|
annotationUUID, err := uuid.Parse(annotationID)
|
|
if err != nil {
|
|
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid annotation id"})
|
|
}
|
|
|
|
kind := c.QueryParam("annotation_type")
|
|
if kind == "" && allowBody {
|
|
var body struct {
|
|
AnnotationType string `json:"annotation_type"`
|
|
}
|
|
if c.Bind(&body) == nil {
|
|
kind = body.AnnotationType
|
|
}
|
|
}
|
|
if !wsync.ValidAnnotationKind(kind) {
|
|
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", c.JSON(http.StatusBadRequest, map[string]string{"error": "annotation_type must be highlight, note, or bookmark"})
|
|
}
|
|
|
|
return userUUID, mediaUUID, pgtype.UUID{Bytes: annotationUUID, Valid: true}, kind, nil
|
|
}
|