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,