feat(books): rebuild duplicate books as a ledger

Sort groups by reclaimable space, pre-pick a keeper with a rule that states
its reasoning, and expand any row into a field-by-field comparison that shows
only what differs. Persist "not duplicates" so a judgement survives a rescan,
and review every copy before deleting. Reading progress no longer renders 0%.
This commit is contained in:
neon
2026-09-20 20:11:34 +00:00
parent 0007a9d84f
commit c239cfa2dd
57 changed files with 24922 additions and 1139 deletions
+28
View File
@@ -6,6 +6,12 @@ export type BookDuplicateMatchReason = (typeof BOOK_DUPLICATE_MATCH_REASONS)[num
export const BOOK_DUPLICATE_SCAN_STATUSES = ["queued", "running", "completed", "failed"] as const;
export type BookDuplicateScanStatus = (typeof BOOK_DUPLICATE_SCAN_STATUSES)[number];
export const BOOK_DUPLICATE_GROUP_SORTS = ["reclaimable", "copies", "confidence", "title"] as const;
export type BookDuplicateGroupSort = (typeof BOOK_DUPLICATE_GROUP_SORTS)[number];
export const BOOK_DUPLICATE_SORT_ORDERS = ["asc", "desc"] as const;
export type BookDuplicateSortOrder = (typeof BOOK_DUPLICATE_SORT_ORDERS)[number];
export type CreateBookDuplicateScanRequest = {
libraryId?: number;
similarityPercent: number;
@@ -21,6 +27,13 @@ export type BookDuplicateScan = {
totalBooks: number | null;
progressPercent: number | null;
totalGroups: number | null;
/** Copies beyond the first in every group, set when the scan completes. */
totalExtraCopies: number | null;
/**
* Bytes freed by keeping the largest copy in every group, set when the scan completes. A floor:
* keeping a smaller copy frees more.
*/
totalReclaimableBytes: number | null;
errorCode: string | null;
createdAt: string;
completedAt: string | null;
@@ -65,10 +78,25 @@ export type BookDuplicateGroup = {
id: number;
reasons: BookDuplicateMatchReason[];
maxTitleSimilarity: number | null;
/** Bytes freed by keeping the largest copy in this group. */
reclaimableBytes: number;
books: BookDuplicateCandidate[];
pairs: BookDuplicatePair[];
};
export type BookDuplicateDismissal = {
bookIdA: number;
bookIdB: number;
titleA: string | null;
titleB: string | null;
createdAt: string;
};
export type CreateBookDuplicateDismissalRequest = {
scanId: number;
groupId: number;
};
export type BookDuplicateGroupsResponse = {
groups: BookDuplicateGroup[];
total: number;