forked from akai/readest
fix(library): clear nested-folder groups when deleting from bookshelf (#4226)
* fix(library): clear nested-folder groups when deleting from bookshelf
Deleting a group from the bookshelf right-click menu used to leave the group on screen whenever the import had any sub-directories. The cause: getBooksToDelete matched only `book.groupId === id`, but the bookshelf renders a top-level group with id = md5("MyDir") while books imported from a sub-folder carry groupId = md5("MyDir/sub"). Sub-folder books never got marked for deletion, refreshGroups re-built the parent group from their groupName on the next render, and the user saw an undeletable folder.
Fix: when an id resolves to a known group via getGroupName, also collect every book whose groupName equals that path or starts with `${path}/`. Hash-based dedup keeps a book from being queued twice when both rules match. Single-book deletes and flat-folder group deletes are unaffected.
* refactor: expand group selections into book hashes at intake
Address review feedback on #4226: instead of re-deriving which books
belong to a group inside the deletion path with a path-prefix sweep,
resolve group ids into their constituent book hashes upstream where the
selection enters the deletion pipeline.
* New helper `expandBookshelfSelection(ids, items)` in libraryUtils:
group ids resolve to every (non-soft-deleted) book in the rendered
rollup; standalone book hashes pass through. Tested in isolation.
* `Bookshelf.deleteSelectedBooks` runs select-mode picks through the
helper before populating `bookIdsToDelete`.
* `BookshelfItem` right-click group delete dispatches the
constituent hashes from `group.books` directly, so the receiver
is a simple pass-through.
* `getBooksToDelete` collapses to a flat hash lookup — no prefix
sweep, no `getGroupName` call in the deletion path, no dedup set.
The nested-folder fix still holds because `generateBookshelfItems`
already rolls "MyDir/sub" books into the top-level "MyDir" group;
expanding via the rendered `group.books` picks them up automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
ensureLibraryGroupByType,
|
||||
findGroupById,
|
||||
getGroupDisplayName,
|
||||
expandBookshelfSelection,
|
||||
} from '../../app/library/utils/libraryUtils';
|
||||
import { Book, BooksGroup } from '../../types/book';
|
||||
import { LibraryGroupByType, LibrarySortByType } from '../../types/settings';
|
||||
@@ -983,3 +984,84 @@ describe('getGroupDisplayName', () => {
|
||||
expect(getGroupDisplayName(items, 'non-existent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandBookshelfSelection', () => {
|
||||
const createMockGroup = (overrides: Partial<BooksGroup> = {}): BooksGroup => ({
|
||||
id: 'test-group',
|
||||
name: 'Test Group',
|
||||
displayName: 'Test Display Name',
|
||||
books: [],
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('passes through standalone book hashes unchanged', () => {
|
||||
const items: (Book | BooksGroup)[] = [
|
||||
createMockBook({ hash: 'book-1' }),
|
||||
createMockBook({ hash: 'book-2' }),
|
||||
];
|
||||
|
||||
expect(expandBookshelfSelection(['book-1', 'book-2'], items)).toEqual(['book-1', 'book-2']);
|
||||
});
|
||||
|
||||
it('expands a group id into every book hash in the rendered rollup', () => {
|
||||
// generateBookshelfItems rolls nested-folder books into the top-level
|
||||
// group, so the rendered BooksGroup.books already includes them. The
|
||||
// helper must rely on that rollup so deleting "MyDir" also catches
|
||||
// books whose own groupName is "MyDir/sub".
|
||||
const items: (Book | BooksGroup)[] = [
|
||||
createMockGroup({
|
||||
id: 'group-mydir',
|
||||
name: 'MyDir',
|
||||
books: [
|
||||
createMockBook({ hash: 'direct-book', groupName: 'MyDir', groupId: 'group-mydir' }),
|
||||
createMockBook({ hash: 'nested-book', groupName: 'MyDir/sub', groupId: 'group-sub' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(expandBookshelfSelection(['group-mydir'], items).sort()).toEqual([
|
||||
'direct-book',
|
||||
'nested-book',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates when a book hash and its parent group are both selected', () => {
|
||||
const items: (Book | BooksGroup)[] = [
|
||||
createMockGroup({
|
||||
id: 'group-1',
|
||||
books: [createMockBook({ hash: 'book-a' }), createMockBook({ hash: 'book-b' })],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(expandBookshelfSelection(['book-a', 'group-1'], items).sort()).toEqual([
|
||||
'book-a',
|
||||
'book-b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips soft-deleted books inside a group', () => {
|
||||
const items: (Book | BooksGroup)[] = [
|
||||
createMockGroup({
|
||||
id: 'group-1',
|
||||
books: [
|
||||
createMockBook({ hash: 'alive' }),
|
||||
createMockBook({ hash: 'gone', deletedAt: Date.now() }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(expandBookshelfSelection(['group-1'], items)).toEqual(['alive']);
|
||||
});
|
||||
|
||||
it('returns an empty array when no ids are given', () => {
|
||||
expect(expandBookshelfSelection([], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves unknown ids so callers can surface stale selections', () => {
|
||||
// Defensive: if the rendered items no longer contain a selected id (e.g.
|
||||
// it was a hash from another view), the helper leaves it alone rather
|
||||
// than silently dropping it.
|
||||
expect(expandBookshelfSelection(['ghost-hash'], [])).toEqual(['ghost-hash']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
createWithinGroupSorter,
|
||||
ensureLibraryGroupByType,
|
||||
ensureLibrarySortByType,
|
||||
expandBookshelfSelection,
|
||||
getBookSortValue,
|
||||
getGroupSortValue,
|
||||
compareSortValues,
|
||||
@@ -359,16 +360,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// `bookIdsToDelete` always holds book hashes by the time we get here —
|
||||
// group ids are expanded into their constituent hashes at intake (see
|
||||
// `deleteSelectedBooks` and `handleDeleteBooksIntent`), so a top-level
|
||||
// folder is now resolved against the rendered group's `books` rollup,
|
||||
// which already includes nested sub-folder books.
|
||||
const getBooksToDelete = () => {
|
||||
const booksToDelete: Book[] = [];
|
||||
bookIdsToDelete.forEach((id) => {
|
||||
for (const book of filteredBooks.filter((book) => book.hash === id || book.groupId === id)) {
|
||||
if (book && !book.deletedAt) {
|
||||
booksToDelete.push(book);
|
||||
}
|
||||
}
|
||||
});
|
||||
return booksToDelete;
|
||||
const wanted = new Set(bookIdsToDelete);
|
||||
return filteredBooks.filter((book) => wanted.has(book.hash) && !book.deletedAt);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
@@ -390,7 +389,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
};
|
||||
|
||||
const deleteSelectedBooks = () => {
|
||||
setBookIdsToDelete(getSelectedBooks());
|
||||
// Expand any group ids in the selection into the book hashes they
|
||||
// visually represent — `generateBookshelfItems` rolls nested-folder
|
||||
// books into the parent group, and we want every one of them queued
|
||||
// for deletion, not just the books whose own `groupId` happens to
|
||||
// match the top-level group's id.
|
||||
setBookIdsToDelete(expandBookshelfSelection(getSelectedBooks(), sortedBookshelfItems));
|
||||
setShowSelectModeActions(false);
|
||||
setShowDeleteAlert(true);
|
||||
};
|
||||
|
||||
@@ -317,7 +317,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const deleteGroupMenuItem = await MenuItem.new({
|
||||
text: _('Delete'),
|
||||
action: async () => {
|
||||
eventDispatcher.dispatch('delete-books', { ids: [group.id] });
|
||||
// Dispatch the constituent book hashes — `group.books` is the
|
||||
// rendered rollup and already includes books from nested sub-
|
||||
// folders, so the deletion path doesn't need to re-derive what
|
||||
// belongs to the group from the id alone.
|
||||
const ids = group.books.filter((book) => !book.deletedAt).map((book) => book.hash);
|
||||
eventDispatcher.dispatch('delete-books', { ids });
|
||||
},
|
||||
});
|
||||
const menu = await Menu.new();
|
||||
|
||||
@@ -59,6 +59,32 @@ export const getGroupDisplayName = (
|
||||
return group?.displayName || group?.name;
|
||||
};
|
||||
|
||||
/**
|
||||
* Expand a list of selection ids (book hashes or group ids from the rendered
|
||||
* bookshelf) into the unique book hashes those ids represent.
|
||||
*
|
||||
* Group ids resolve to every (non-soft-deleted) book in the group's visible
|
||||
* rollup — `generateBookshelfItems` already folds nested-folder books into
|
||||
* their top-level group, so the rendered `BooksGroup.books` is the source of
|
||||
* truth. Standalone book hashes (and any unknown ids) pass through unchanged,
|
||||
* letting callers like the bookshelf delete flow collect the right set up
|
||||
* front instead of re-deriving it later.
|
||||
*/
|
||||
export const expandBookshelfSelection = (ids: string[], items: (Book | BooksGroup)[]): string[] => {
|
||||
const hashes = new Set<string>();
|
||||
for (const id of ids) {
|
||||
const group = findGroupById(items, id);
|
||||
if (group) {
|
||||
for (const book of group.books) {
|
||||
if (!book.deletedAt) hashes.add(book.hash);
|
||||
}
|
||||
} else {
|
||||
hashes.add(id);
|
||||
}
|
||||
}
|
||||
return [...hashes];
|
||||
};
|
||||
|
||||
export const createBookFilter = (queryTerm: string | null) => (item: Book) => {
|
||||
if (!queryTerm) return true;
|
||||
if (item.deletedAt) return false;
|
||||
|
||||
Reference in New Issue
Block a user