feat(sync): propagate KOReader annotation deletions + history API
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).
This commit is contained in:
@@ -158,24 +158,35 @@ type KOReaderProgressRequest struct {
|
||||
}
|
||||
|
||||
type KOReaderBookProgress struct {
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Authors []string `json:"authors,omitempty"`
|
||||
Progress float64 `json:"progress"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
LastRead string `json:"last_read,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
ContextText *string `json:"context_text,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Authors []string `json:"authors,omitempty"`
|
||||
Progress float64 `json:"progress"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
LastRead string `json:"last_read,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
ContextText *string `json:"context_text,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
// Device-side deletions, reported by dedup key. Keys refer to annotations
|
||||
// the device previously received from the server (or echoes of its own
|
||||
// pushes); the device only flags a deletion after observing the key in a
|
||||
// pull, so absence from these arrays is never interpreted as deletion.
|
||||
DeletedHighlights []KOReaderDeletedAnnotation `json:"deleted_highlights,omitempty"`
|
||||
DeletedBookmarks []KOReaderDeletedAnnotation `json:"deleted_bookmarks,omitempty"`
|
||||
}
|
||||
|
||||
// KOReaderDeletedAnnotation identifies a deleted annotation by dedup key.
|
||||
type KOReaderDeletedAnnotation struct {
|
||||
DedupKey string `json:"dedup_key"`
|
||||
}
|
||||
|
||||
type KOReaderDeviceInfo struct {
|
||||
@@ -734,6 +745,30 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
||||
DedupKey: dedupKey,
|
||||
})
|
||||
}
|
||||
|
||||
// Device-reported deletions: tombstone by dedup key. Tombstoned rows stay
|
||||
// in the history (restorable from the book page) and are echoed to other
|
||||
// devices as tombstones on their next pull. A device replay that pushes a
|
||||
// stale copy of the annotation cannot resurrect the tombstone (its save
|
||||
// carries no modification timestamp newer than the delete). Deletions run
|
||||
// after the upserts purely so a key present in both lists resolves to
|
||||
// "deleted" — the newer intent.
|
||||
for _, del := range book.DeletedHighlights {
|
||||
if del.DedupKey == "" {
|
||||
continue
|
||||
}
|
||||
if err := h.annotationSvc.TombstoneHighlight(ctx, userID, mediaItemID, del.DedupKey, "koreader"); err != nil {
|
||||
log.Printf("KOReader: tombstone highlight by dedup key failed: %v", err)
|
||||
}
|
||||
}
|
||||
for _, del := range book.DeletedBookmarks {
|
||||
if del.DedupKey == "" {
|
||||
continue
|
||||
}
|
||||
if err := h.annotationSvc.TombstoneBookmarkByDedupKey(ctx, userID, mediaItemID, del.DedupKey, "koreader"); err != nil {
|
||||
log.Printf("KOReader: tombstone bookmark by dedup key failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
|
||||
|
||||
Reference in New Issue
Block a user