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..2e63b6d 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 ` @@ -11334,7 +11334,9 @@ UPDATE media_bookmarks SET last_modified_at = $11, last_modified_source = $12, device_sync_data = $13, - created_at = created_at + created_at = created_at, + deleted = FALSE, + deleted_at = NULL WHERE id = $1 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 ` @@ -11474,7 +11476,9 @@ UPDATE media_highlights SET last_modified_at = $12, last_modified_source = $13, device_sync_data = $14, - updated_at = NOW() + updated_at = NOW(), + deleted = FALSE, + deleted_at = NULL WHERE id = $1 RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at ` @@ -12152,7 +12156,9 @@ UPDATE media_notes SET last_modified_at = $10, last_modified_source = $11, device_sync_data = $12, - updated_at = NOW() + updated_at = NOW(), + deleted = FALSE, + deleted_at = NULL WHERE id = $1 RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at ` diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 9058c4c..3e4cd3b 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -802,7 +802,9 @@ UPDATE media_highlights SET last_modified_at = $12, last_modified_source = $13, device_sync_data = $14, - updated_at = NOW() + updated_at = NOW(), + deleted = FALSE, + deleted_at = NULL WHERE id = $1 RETURNING *; @@ -857,7 +859,9 @@ UPDATE media_notes SET last_modified_at = $10, last_modified_source = $11, device_sync_data = $12, - updated_at = NOW() + updated_at = NOW(), + deleted = FALSE, + deleted_at = NULL WHERE id = $1 RETURNING *; @@ -913,7 +917,9 @@ UPDATE media_bookmarks SET last_modified_at = $11, last_modified_source = $12, device_sync_data = $13, - created_at = created_at + created_at = created_at, + deleted = FALSE, + deleted_at = NULL WHERE id = $1 RETURNING *; @@ -2481,7 +2487,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..f4c7251 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -115,20 +115,51 @@ type UpdateMediaNoteRequest struct { // CreateMediaHighlightRequest represents the request for creating a media highlight type CreateMediaHighlightRequest struct { - SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"` - StartPosition string `json:"start_position" validate:"required,max=100"` - EndPosition string `json:"end_position" validate:"required,max=100"` - Color string `json:"color" validate:"omitempty,len=7"` - NoteID string `json:"note_id"` + SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"` + StartPosition string `json:"start_position" validate:"max=1000"` + EndPosition string `json:"end_position" validate:"max=1000"` + EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"` + EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"` + Color string `json:"color" validate:"omitempty,len=7"` + NoteText string `json:"note_text" validate:"max=10000"` + NoteID string `json:"note_id"` + PercentageStart float64 `json:"percentage_start"` + PercentageEnd float64 `json:"percentage_end"` + ChapterReference int32 `json:"chapter_reference"` } // UpdateMediaHighlightRequest represents the request for updating a media highlight type UpdateMediaHighlightRequest struct { - SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"` - StartPosition string `json:"start_position" validate:"required,max=100"` - EndPosition string `json:"end_position" validate:"required,max=100"` - Color string `json:"color" validate:"omitempty,len=7"` - NoteID string `json:"note_id"` + SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"` + StartPosition string `json:"start_position" validate:"max=1000"` + EndPosition string `json:"end_position" validate:"max=1000"` + EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"` + EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"` + Color string `json:"color" validate:"omitempty,len=7"` + NoteText string `json:"note_text" validate:"max=10000"` + NoteID string `json:"note_id"` + PercentageStart float64 `json:"percentage_start"` + PercentageEnd float64 `json:"percentage_end"` + ChapterReference int32 `json:"chapter_reference"` +} + +// 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 { @@ -1557,14 +1588,20 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error { if mh.annotationSvc != nil { result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ - MediaItemID: pgMediaID, - UserID: pgUserID, - SelectionText: req.SelectionText, - StartPosition: req.StartPosition, - EndPosition: req.EndPosition, - Color: color, - Source: "web", - ModifiedAt: time.Now(), + MediaItemID: pgMediaID, + UserID: pgUserID, + SelectionText: req.SelectionText, + StartPosition: req.StartPosition, + EndPosition: req.EndPosition, + EpubcfiStart: req.EpubcfiStart, + EpubcfiEnd: req.EpubcfiEnd, + Color: color, + NoteText: req.NoteText, + PercentageStart: req.PercentageStart, + PercentageEnd: req.PercentageEnd, + ChapterReference: req.ChapterReference, + Source: "web", + ModifiedAt: time.Now(), }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -1637,6 +1674,42 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error { color = req.Color } + // Prefer the sync-aware path: the same selection text + CFI resolves to + // the same dedup key, so this performs an LWW update of the existing row + // (including note_text and CFI columns the plain query cannot touch). + if mh.annotationSvc != nil { + 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"}) + } + result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{ + MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, + UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, + SelectionText: req.SelectionText, + StartPosition: req.StartPosition, + EndPosition: req.EndPosition, + EpubcfiStart: req.EpubcfiStart, + EpubcfiEnd: req.EpubcfiEnd, + Color: color, + NoteText: req.NoteText, + PercentageStart: req.PercentageStart, + PercentageEnd: req.PercentageEnd, + 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.StatusOK, result.Highlight) + } + highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{ ID: pgtype.UUID{Bytes: highlightUUID, Valid: true}, SelectionText: req.SelectionText, @@ -1677,6 +1750,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/internal/services/reader.go b/internal/services/reader.go index 69b2919..8262fdf 100644 --- a/internal/services/reader.go +++ b/internal/services/reader.go @@ -392,18 +392,23 @@ func (s *ReaderService) UpdateSettings( func (s *ReaderService) getDefaultSettings() map[string]interface{} { return map[string]interface{}{ - "chrome_behavior": "auto-hide", - "progress_mode": "pages", - "chrome_theme": "tokyo-night", - "reading_theme": "dark", - "reading_font": "literata", - "font_size": 16, - "line_height": 1.6, - "margin_width": 20, - "tap_zone_size": 30, - "auto_scroll": false, - "panel_zoom_enabled": true, - "double_page_spread": true, + "chrome_behavior": "auto-hide", + "progress_mode": "pages", + "chrome_theme": "tokyo-night", + "reading_theme": "dark", + "reading_font": "literata", + "font_size": 16, + "line_height": 1.6, + "margin_width": 20, + "tap_zone_size": 30, + "auto_scroll": false, + "panel_zoom_enabled": true, + "double_page_spread": true, + "pdf_interaction_mode": "select", + "fx_brightness": 1, + "fx_contrast": 1, + "fx_invert": false, + "tap_zones_enabled": true, // Dockable panel defaults "panel_layout": map[string]interface{}{ diff --git a/internal/sync/annotations.go b/internal/sync/annotations.go index f30b353..bfd0bf9 100644 --- a/internal/sync/annotations.go +++ b/internal/sync/annotations.go @@ -100,10 +100,12 @@ func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlight } if existing.Deleted.Bool { - if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() { + if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) { return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil } - return s.createHighlight(ctx, req, dedupKey) + // Newer than the tombstone: a deliberate re-create. Resurrect via the + // LWW update (which clears deleted/deleted_at). + return s.applyLWW(ctx, req, existing, dedupKey) } return s.applyLWW(ctx, req, existing, dedupKey) @@ -361,7 +363,11 @@ func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) ( } if existing.Deleted.Valid && existing.Deleted.Bool { - return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil + if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) { + return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil + } + // Newer than the tombstone: a deliberate re-create. Resurrect. + return s.applyNoteLWW(ctx, req, existing, dedupKey) } return s.applyNoteLWW(ctx, req, existing, dedupKey) @@ -504,10 +510,13 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe } if existing.Deleted.Bool { - if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < s.tombstoneTTL() { + if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) { return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil } - return s.createBookmark(ctx, req, dedupKey) + // Newer than the tombstone: a deliberate re-create. Resurrect via the + // LWW update instead of INSERT (the tombstoned row still holds the + // UNIQUE(media_item_id, user_id, title) slot). + return s.applyBookmarkLWW(ctx, req, existing, dedupKey) } return s.applyBookmarkLWW(ctx, req, existing, dedupKey) @@ -717,6 +726,23 @@ func ComputeDedupKey(selectionText, epubcfiStart, startPosition string) string { return hex.EncodeToString(h.Sum(nil)) } +// incomingNewerThanTombstone reports whether an incoming save should +// resurrect a tombstoned annotation. A save carrying a modification time +// newer than the tombstone (e.g. the user deliberately re-adding on the web, +// or a device that genuinely re-created it) wins; a save with a missing or +// older timestamp is treated as a stale replay from a client that still has +// the deleted annotation, and the tombstone stands. +func incomingNewerThanTombstone(incoming time.Time, deletedAt, lastModifiedAt pgtype.Timestamptz) bool { + if incoming.IsZero() { + return false + } + tombstone := deletedAt.Time + if lastModifiedAt.Valid && lastModifiedAt.Time.After(tombstone) { + tombstone = lastModifiedAt.Time + } + return incoming.After(tombstone) +} + func normalizeText(s string) string { fields := strings.Fields(strings.ToLower(s)) return strings.Join(fields, " ") diff --git a/internal/sync/annotations_test.go b/internal/sync/annotations_test.go index bf0adde..2bc8e6e 100644 --- a/internal/sync/annotations_test.go +++ b/internal/sync/annotations_test.go @@ -341,3 +341,31 @@ func pgHighlights(text, color, note string, pctStart, pctEnd float64) database.M PercentageEnd: pgtype.Float8{Float64: pctEnd, Valid: pctEnd != 0}, } } + +func TestIncomingNewerThanTombstone(t *testing.T) { + base := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + delAt := pgtype.Timestamptz{Time: base, Valid: true} + lastMod := pgtype.Timestamptz{Time: base.Add(-time.Minute), Valid: true} + + tests := []struct { + name string + incoming time.Time + deleted pgtype.Timestamptz + lastMod pgtype.Timestamptz + want bool + }{ + {"newer than tombstone resurrects", base.Add(time.Hour), delAt, lastMod, true}, + {"older than tombstone is a stale replay", base.Add(-time.Hour), delAt, lastMod, false}, + {"missing timestamp never resurrects", time.Time{}, delAt, lastMod, false}, + {"exactly equal does not resurrect", base, delAt, lastMod, false}, + {"last_modified newer than deleted_at wins", base.Add(30 * time.Minute), delAt, pgtype.Timestamptz{Time: base.Add(90 * time.Minute), Valid: true}, false}, + {"invalid timestamps compare against deleted_at", base.Add(time.Hour), delAt, pgtype.Timestamptz{}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := incomingNewerThanTombstone(tt.incoming, tt.deleted, tt.lastMod); got != tt.want { + t.Errorf("incomingNewerThanTombstone() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/package.json b/package.json index 0010e72..5a352f2 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dev": "npm run build:ts:dev && npm run build:css" }, "dependencies": { - "@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#d4d87a9", + "@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#e448d36", "alpinejs": "^3.15.8", "chart.js": "^4.5.1", "highlight.js": "^11.11.1", diff --git a/templates/reader.templ b/templates/reader.templ index 75ad6b3..60a04a2 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,367 +64,689 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm - @ReaderChrome(user, metadata, progress) - -
- -