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.
This commit is contained in:
John O'Keefe
2026-09-21 18:09:05 -04:00
parent 64ef691d64
commit 45e3a256e2
7 changed files with 228 additions and 32 deletions
+12
View File
@@ -71,6 +71,18 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
<td class="px-4 py-3" style="color: var(--text-primary)">{ item.LibraryName }</td>
<td class="px-4 py-3" style="color: var(--text-secondary)">{ item.Status }</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
if item.MatchID != "" {
<button
type="button"
data-merge-source={ item.ID }
data-merge-target={ item.MatchID }
class="btn btn-ghost text-xs"
title={ "Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry" }
>
@Icon("copy", "h-3.5 w-3.5")
Merge
</button>
}
<button
type="button"
data-unarchive={ item.ID }