From 45e3a256e274ac5c960dd9b28e12fa5eba85d3dd Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 21 Sep 2026 18:09:05 -0400 Subject: [PATCH] feat(collections): merge archived duplicates into their active copy Covers the copy-then-delete-later workflow: a user copies books to a new library, deletes the originals, and ends up with an active copy in the new library plus an archived twin holding the real reading history. Until now those twins could only be purged (destroying the history) or restored (showing a permanently broken entry). - POST /api/media-items/:id/merge (admin only, body {target_id}): validates the source is archived/missing, the target is active, and both share the same file_sha256; then re-parents every child row onto the target via the existing reparent_media_item_children function (the same machinery as hash-conflict resolution) and deletes the source row. Per-user collisions keep the active copy's data, mirroring that flow. Affected data: reading progress, speed, ratings, highlights, notes, bookmarks (including tombstoned deleted-annotation history), formats, collections, kobo shelves/entitlements, sync rows, panel data, processing issues, and device aliases. - GET /admin/archived: ListHiddenMediaItems' match_* columns surface each row's best active twin; rows with a twin get a confirm-guarded "Merge" button (data-merge-source/-target) next to Restore/Delete, wired in web/src/admin.ts like the existing unarchive/delete handlers. - templates.ArchivedItem gains MatchID/MatchTitle/MatchLibraryName; frontend.go populates them from the listing row. After a merge the archived row is gone, so the retention purge can never destroy the merged data. Deleted-annotation tombstones carry over and remain restorable from the target book's "recently deleted" history. --- internal/handlers/media.go | 77 +++++++++++++++++++++++ internal/router/frontend.go | 5 ++ internal/router/media.go | 1 + templates/admin_archived.templ | 12 ++++ templates/admin_archived_templ.go | 101 +++++++++++++++++++++++------- templates/types.go | 26 +++++--- web/src/admin.ts | 38 +++++++++++ 7 files changed, 228 insertions(+), 32 deletions(-) diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 0a8290d..6803b77 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -1354,6 +1354,83 @@ func (mh *MediaHandler) UnarchiveMediaItem(c *echo.Context) error { return c.NoContent(http.StatusNoContent) } +// MergeArchivedMediaItem handles POST /api/media-items/:id/merge (admin +// only). Merges an archived/missing item's reading data (progress, +// annotations, highlights, notes, bookmarks, ratings, collections, kobo +// shelves) into an active item with identical content (same SHA-256), then +// deletes the archived row. Covers the copy-then-delete-later workflow: the +// active copy exists, the archived twin holds the real reading history. +// Per-user collisions keep the active copy's data, mirroring hash-conflict +// resolution. +func (mh *MediaHandler) MergeArchivedMediaItem(c *echo.Context) error { + user := MustGetAuthenticatedUser(c) + + if user.Role != "admin" { + return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"}) + } + + sourceUUID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"}) + } + + var req struct { + TargetID string `json:"target_id"` + } + if err := c.Bind(&req); err != nil || req.TargetID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "target_id is required"}) + } + targetUUID, err := uuid.Parse(req.TargetID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid target_id"}) + } + if sourceUUID == targetUUID { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot merge an item into itself"}) + } + + ctx := c.Request().Context() + source, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "archived item not found"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + if !source.ArchivedAt.Valid && source.MissingScanCount == 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "source item is not archived or missing"}) + } + + target, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: targetUUID, Valid: true}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusNotFound, map[string]string{"error": "target item not found"}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + if target.ArchivedAt.Valid || target.MissingScanCount > 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "target item is not active"}) + } + + if !source.FileSha256.Valid || source.FileSha256.String == "" || + source.FileSha256.String != target.FileSha256.String { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "items do not share the same content hash"}) + } + + if err := mh.db.ReparentMediaItemChildren(ctx, database.ReparentMediaItemChildrenParams{ + Column1: pgtype.UUID{Bytes: targetUUID, Valid: true}, + Column2: pgtype.UUID{Bytes: sourceUUID, Valid: true}, + }); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to merge reading data: " + err.Error()}) + } + if err := mh.db.DeleteMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true}); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove archived item: " + err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]string{ + "message": "Merged reading progress, annotations, and collections into the active item", + }) +} + // PurgeArchivedMediaItems handles POST /api/media-items/purge-archived // (admin only). Hard-deletes every archived item (files missing from disk for // 2+ scans) together with its reading history. The archive retention window diff --git a/internal/router/frontend.go b/internal/router/frontend.go index e8e1601..1708b73 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -873,6 +873,11 @@ func registerFrontendRoutes(cfg *Config) { FilePath: row.FilePath, MissingScans: row.MissingScanCount, } + if row.MatchID.Valid { + item.MatchID = uuid.UUID(row.MatchID.Bytes).String() + item.MatchTitle = row.MatchTitle + item.MatchLibraryName = getText(row.MatchLibraryName) + } if row.ArchivedAt.Valid { archived := row.ArchivedAt.Time item.ArchivedAt = &archived diff --git a/internal/router/media.go b/internal/router/media.go index 6420a6b..6228bfb 100644 --- a/internal/router/media.go +++ b/internal/router/media.go @@ -64,6 +64,7 @@ func registerMediaRoutes(cfg *Config) { admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem) admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems) admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem) + admin.POST("/media-items/:id/merge", cfg.MediaHandler.MergeArchivedMediaItem) // Shelf management (protected) protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf) diff --git a/templates/admin_archived.templ b/templates/admin_archived.templ index 2cdefad..beb1680 100644 --- a/templates/admin_archived.templ +++ b/templates/admin_archived.templ @@ -71,6 +71,18 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) { { item.LibraryName } { item.Status } + if item.MatchID != "" { + + } ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Delete") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index e315eb4..a82d63c 100644 --- a/templates/types.go +++ b/templates/types.go @@ -52,17 +52,23 @@ type FolderData struct { } // ArchivedItem feeds the admin archived-items page: media hidden from -// libraries because their files vanished, with their pending fate. +// libraries because their files vanished, with their pending fate. When an +// active item with identical content (same SHA-256) exists, MatchID and +// friends are set so the UI can offer merging the archived row's reading +// data into its active twin. type ArchivedItem struct { - ID string - Title string - Author string - LibraryName string - FilePath string - MissingScans int32 - ArchivedAt *time.Time - PurgeAt *time.Time - Status string + ID string + Title string + Author string + LibraryName string + FilePath string + MissingScans int32 + ArchivedAt *time.Time + PurgeAt *time.Time + Status string + MatchID string + MatchTitle string + MatchLibraryName string } type DirEntry struct { diff --git a/web/src/admin.ts b/web/src/admin.ts index 150b2df..ed79791 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -339,6 +339,39 @@ async function deleteArchivedItem(id: string): Promise { } } +// Merge an archived item's reading data into its active duplicate with the +// same content hash (POST /api/media-items/:id/merge), then remove the +// archived entry. Covers copy-then-delete-later moves between libraries. +async function mergeArchivedItem(sourceId: string, targetId: string): Promise { + const token = localStorage.getItem("token"); + if (!token) return; + if ( + !window.confirm( + "Merge this archived copy's reading progress, notes, and highlights into the active copy? The archived entry is removed.", + ) + ) { + return; + } + try { + const resp = await fetch(`/api/media-items/${sourceId}/merge`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ target_id: targetId }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || "Failed to merge item"); + } + showToast("Reading data merged into active copy", "success"); + setTimeout(() => window.location.reload(), 500); + } catch (e) { + showToast(e instanceof Error ? e.message : "Failed to merge item", "error"); + } +} + // The archived-items page renders per-row buttons with data attributes // (escaping-safe); bind them here. Module scripts run after DOM parse. document.querySelectorAll("[data-unarchive]").forEach((el) => { @@ -349,6 +382,11 @@ document.querySelectorAll("[data-delete-archived]").forEach((el) => deleteArchivedItem(el.dataset.deleteArchived || ""), ); }); +document.querySelectorAll("[data-merge-source]").forEach((el) => { + el.addEventListener("click", () => + mergeArchivedItem(el.dataset.mergeSource || "", el.dataset.mergeTarget || ""), + ); +}); export { hideScanProgress,