feat(books): rework cross-library moves with preview and progress

Replaces the implementation from #818 with a job-backed flow. Moves are
planned up front and surfaced as a preview the user confirms, then run
through a planner/executor split against a book_move_jobs table, so
progress is trackable and partial failures roll back.

The dialog-based flow from #818 is superseded: its module, service, and
repository are removed here in favour of the job-based equivalents. Its
e2e coverage is replaced by a book-move suite that exercises preview
classification, the collision policies, file relocation, preserved user
state, and the books-before-book_files update ordering that the
composite folder foreign key constrains.

Also adds dashboard shelf layout configuration.

Ref #257
This commit is contained in:
neon
2026-08-02 17:03:14 +00:00
parent 41fd6f1881
commit f4cfc3ca5d
93 changed files with 23743 additions and 2871 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ export enum AuditAction {
BookBulkUpdateTags = "book.bulk.update_tags",
BookBulkSetMetadataLock = "book.bulk.set_metadata_lock",
BookBulkEditMetadata = "book.bulk.edit_metadata",
BookBulkMove = "book.bulk.move",
BookBulkMoveLibrary = "book.bulk.move_library",
BookReadingStateReset = "book.reading_state.reset",
BookWriteAndRename = "book.write_and_rename",
+164
View File
@@ -0,0 +1,164 @@
import type { BookSelectionPayload } from "./book-selection";
import type { OrganizationMode } from "./library";
/** How a destination collision is resolved for a book. */
export type BookMoveCollisionPolicy = "keep_both" | "merge" | "skip";
export const BOOK_MOVE_COLLISION_POLICIES: readonly BookMoveCollisionPolicy[] = ["keep_both", "merge", "skip"] as const;
/**
* Job-level policy. "suggested" resolves per collision using each one's own
* suggestedPolicy, so identical copies merge while name-only clashes keep both.
* It is resolved on the server because the preview only carries a bounded sample
* of collisions, and the rest still need the same treatment.
*/
export type BookMoveJobCollisionPolicy = BookMoveCollisionPolicy | "suggested";
export const BOOK_MOVE_JOB_COLLISION_POLICIES: readonly BookMoveJobCollisionPolicy[] = ["suggested", "keep_both", "merge", "skip"] as const;
/** Why a selected book cannot be moved to the chosen destination. */
export type BookMoveIneligibleReason =
| "book_not_present"
| "no_content_file"
| "multi_file_target_per_file"
| "no_source_access"
| "pattern_unresolved"
| "path_too_long"
| "path_outside_target_root";
/** What kind of conflict the destination already has. */
export type BookMoveCollisionKind = "folder_path" | "file_path" | "hash_duplicate";
/**
* How the book's on-disk layout changes because the source and target libraries
* organize books differently. Null when both libraries use the same mode.
*/
export type BookMoveLayoutChange = "wrap_into_folder" | "dissolve_folder";
export interface BookMovePreviewReadyItem {
bookId: number;
title: string;
currentPath: string;
targetPath: string;
layoutChange: BookMoveLayoutChange | null;
}
export interface BookMovePreviewCollisionItem {
bookId: number;
title: string;
kind: BookMoveCollisionKind;
currentPath: string;
targetPath: string;
/** The book already occupying the destination, when known. */
existingBookId: number | null;
suggestedPolicy: BookMoveCollisionPolicy;
/** Destination path used when the collision is resolved with "keep_both". */
keepBothPath: string;
}
export interface BookMovePreviewIneligibleItem {
bookId: number;
title: string;
reason: BookMoveIneligibleReason;
/** Extra context for reasons that carry a count, such as file counts. */
detail?: string;
}
export interface BookMoveAccessLoser {
userId: number;
username: string;
bookCount: number;
}
export interface BookMoveKoboImpact {
userId: number;
username: string;
deviceCount: number;
bookCount: number;
}
export interface BookMoveFormatMismatch {
bookId: number;
title: string;
format: string;
}
export interface BookMoveLayoutSummary {
change: BookMoveLayoutChange;
bookCount: number;
}
export interface BookMoveWarnings {
/** Users who can see these books today but have no access to the target library. */
accessLosers: BookMoveAccessLoser[];
/** Users whose Kobo devices will drop these books on the next sync. */
koboImpact: BookMoveKoboImpact[];
/** Set when source and target libraries organize books differently. */
layout: BookMoveLayoutSummary | null;
/** Books whose format is not in the target library's allowed formats. */
formatMismatches: BookMoveFormatMismatch[];
/** True when any file would move across filesystems (copy + verify + delete). */
crossDevice: boolean;
}
export interface BookMovePreviewRequest {
selection: BookSelectionPayload;
targetLibraryId: number;
targetFolderId: number;
}
export interface BookMovePreviewResult {
targetLibraryId: number;
targetFolderId: number;
targetOrganizationMode: OrganizationMode;
totalSelected: number;
readyCount: number;
/** Bounded sample for display; readyCount carries the true total. */
ready: BookMovePreviewReadyItem[];
alreadyInTargetCount: number;
collisionCount: number;
collisions: BookMovePreviewCollisionItem[];
collisionsTruncated: boolean;
ineligibleCount: number;
ineligible: BookMovePreviewIneligibleItem[];
ineligibleTruncated: boolean;
warnings: BookMoveWarnings;
/** True when the client should show the full review step before moving. */
requiresReview: boolean;
}
export interface BookMoveCollisionOverride {
bookId: number;
policy: BookMoveCollisionPolicy;
}
export interface BookMoveExecuteRequest {
selection: BookSelectionPayload;
targetLibraryId: number;
targetFolderId: number;
collisionPolicy: BookMoveJobCollisionPolicy;
overrides?: BookMoveCollisionOverride[];
}
export type BookMoveBookStatus = "success" | "merged" | "failed" | "skipped";
export interface BookMoveSummary {
processed: number;
succeeded: number;
merged: number;
failed: number;
skipped: number;
cancelled: boolean;
}
export type BookMoveProgressEvent =
| { bookId: number; status: BookMoveBookStatus; reason?: string }
| ({ done: true } & BookMoveSummary);
export const BOOK_MOVE_PREVIEW_SAMPLE_LIMIT = 50;
export const BOOK_MOVE_DETAIL_LIMIT = 200;
-36
View File
@@ -8,39 +8,3 @@ export type BookSelectionQuery = {
};
export type BookSelectionPayload = { bookIds: number[]; query?: never } | { query: BookSelectionQuery; bookIds?: never };
export const MOVE_BOOK_OUTCOME_REASONS = [
"book_not_found",
"no_source_access",
"source_access_check_failed",
"book_processing",
"book_missing",
"already_in_target",
"format_not_allowed",
"target_path_taken",
"target_path_exists",
"path_escapes_target",
"database_update_failed",
"file_move_failed",
"file_move_failed_rollback_incomplete",
] as const;
export type MoveBookOutcomeReason = (typeof MOVE_BOOK_OUTCOME_REASONS)[number];
export interface MoveBookOutcome {
bookId: number;
status: "moved" | "skipped" | "failed";
reason?: MoveBookOutcomeReason;
}
export interface MoveBooksSummary {
total: number;
moved: number;
skipped: number;
failed: number;
cancelled: boolean;
}
export type MoveBooksDoneEvent = { done: true } & MoveBooksSummary;
export type MoveBooksProgressEvent = MoveBookOutcome | MoveBooksDoneEvent;
+1
View File
@@ -13,6 +13,7 @@ export * from "./metadata-lock";
export * from "./dashboard";
export * from "./book";
export * from "./book-duplicates";
export * from "./book-move";
export * from "./book-selection";
export * from "./audiobook";
export * from "./upload";