From dcd646654767034af40ac7bb779d6007fabe048d Mon Sep 17 00:00:00 2001 From: nyne Date: Sat, 24 May 2025 16:24:53 +0800 Subject: [PATCH] Improve performance of deleting favorites, coping favorites, moving favorites and deleting downloads. Close #365 --- lib/foundation/comic_source/models.dart | 20 +++ lib/foundation/favorites.dart | 150 +++++++++++++++++- lib/foundation/history.dart | 19 +++ lib/foundation/local.dart | 54 +++++++ lib/pages/favorites/local_favorites_page.dart | 53 +++---- lib/pages/favorites/side_bar.dart | 2 + lib/pages/local_comics_page.dart | 10 +- 7 files changed, 266 insertions(+), 42 deletions(-) diff --git a/lib/foundation/comic_source/models.dart b/lib/foundation/comic_source/models.dart index 5e2bc62..52cf276 100644 --- a/lib/foundation/comic_source/models.dart +++ b/lib/foundation/comic_source/models.dart @@ -116,6 +116,26 @@ class Comic { toString() => "$sourceKey@$id"; } +class ComicID { + final ComicType type; + + final String id; + + const ComicID(this.type, this.id); + + @override + bool operator ==(Object other) { + if (other is! ComicID) return false; + return other.type == type && other.id == id; + } + + @override + int get hashCode => type.hashCode ^ id.hashCode; + + @override + String toString() => "$type@$id"; +} + class ComicDetails with HistoryMixin { @override final String title; diff --git a/lib/foundation/favorites.dart b/lib/foundation/favorites.dart index 5e453f0..15b9e08 100644 --- a/lib/foundation/favorites.dart +++ b/lib/foundation/favorites.dart @@ -653,6 +653,102 @@ class LocalFavoritesManager with ChangeNotifier { notifyListeners(); } + void batchMoveFavorites( + String sourceFolder, String targetFolder, List items) { + _modifiedAfterLastCache = true; + + if (!existsFolder(sourceFolder)) { + throw Exception("Source folder does not exist"); + } + if (!existsFolder(targetFolder)) { + throw Exception("Target folder does not exist"); + } + + _db.execute("BEGIN TRANSACTION"); + var displayOrder = maxValue(targetFolder) + 1; + try { + for (var item in items) { + _db.execute(""" + insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order) + select id, name, author, type, tags, cover_path, time, ? + from "$sourceFolder" + where id == ? and type == ?; + """, [displayOrder, item.id, item.type.value]); + + _db.execute(""" + delete from "$sourceFolder" + where id == ? and type == ?; + """, [item.id, item.type.value]); + + displayOrder++; + } + notifyListeners(); + } catch (e) { + Log.error("Batch Move Favorites", e.toString()); + _db.execute("ROLLBACK"); + return; + } + _db.execute("COMMIT"); + + // Update counts + if (counts[targetFolder] == null) { + counts[targetFolder] = count(targetFolder); + } else { + counts[targetFolder] = counts[targetFolder]! + items.length; + } + + if (counts[sourceFolder] != null) { + counts[sourceFolder] = counts[sourceFolder]! - items.length; + } else { + counts[sourceFolder] = count(sourceFolder); + } + + notifyListeners(); + } + + void batchCopyFavorites( + String sourceFolder, String targetFolder, List items) { + _modifiedAfterLastCache = true; + + if (!existsFolder(sourceFolder)) { + throw Exception("Source folder does not exist"); + } + if (!existsFolder(targetFolder)) { + throw Exception("Target folder does not exist"); + } + + _db.execute("BEGIN TRANSACTION"); + var displayOrder = maxValue(targetFolder) + 1; + try { + for (var item in items) { + _db.execute(""" + insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order) + select id, name, author, type, tags, cover_path, time, ? + from "$sourceFolder" + where id == ? and type == ?; + """, [displayOrder, item.id, item.type.value]); + + displayOrder++; + } + notifyListeners(); + } catch (e) { + Log.error("Batch Copy Favorites", e.toString()); + _db.execute("ROLLBACK"); + return; + } + + _db.execute("COMMIT"); + + // Update counts + if (counts[targetFolder] == null) { + counts[targetFolder] = count(targetFolder); + } else { + counts[targetFolder] = counts[targetFolder]! + items.length; + } + + notifyListeners(); + } + /// delete a folder void deleteFolder(String name) { _modifiedAfterLastCache = true; @@ -667,11 +763,6 @@ class LocalFavoritesManager with ChangeNotifier { notifyListeners(); } - void deleteComic(String folder, FavoriteItem comic) { - _modifiedAfterLastCache = true; - deleteComicWithId(folder, comic.id, comic.type); - } - void deleteComicWithId(String folder, String id, ComicType type) { _modifiedAfterLastCache = true; LocalFavoriteImageProvider.delete(id, type.value); @@ -687,6 +778,55 @@ class LocalFavoritesManager with ChangeNotifier { notifyListeners(); } + void batchDeleteComics(String folder, List comics) { + _modifiedAfterLastCache = true; + _db.execute("BEGIN TRANSACTION"); + try { + for (var comic in comics) { + LocalFavoriteImageProvider.delete(comic.id, comic.type.value); + _db.execute(""" + delete from "$folder" + where id == ? and type == ?; + """, [comic.id, comic.type.value]); + } + if (counts[folder] != null) { + counts[folder] = counts[folder]! - comics.length; + } else { + counts[folder] = count(folder); + } + } catch (e) { + Log.error("Batch Delete Comics", e.toString()); + _db.execute("ROLLBACK"); + return; + } + _db.execute("COMMIT"); + notifyListeners(); + } + + void batchDeleteComicsInAllFolders(List comics) { + _modifiedAfterLastCache = true; + _db.execute("BEGIN TRANSACTION"); + var folderNames = _getFolderNamesWithDB(); + try { + for (var comic in comics) { + LocalFavoriteImageProvider.delete(comic.id, comic.type.value); + for (var folder in folderNames) { + _db.execute(""" + delete from "$folder" + where id == ? and type == ?; + """, [comic.id, comic.type.value]); + } + } + } catch (e) { + Log.error("Batch Delete Comics in All Folders", e.toString()); + _db.execute("ROLLBACK"); + return; + } + initCounts(); + _db.execute("COMMIT"); + notifyListeners(); + } + Future removeInvalid() async { int count = 0; await Future.microtask(() { diff --git a/lib/foundation/history.dart b/lib/foundation/history.dart index 3a1ae65..4ea0892 100644 --- a/lib/foundation/history.dart +++ b/lib/foundation/history.dart @@ -406,4 +406,23 @@ void clearUnfavoritedHistory() { isInitialized = false; _db.dispose(); } + + void batchDeleteHistories(List histories) { + if (histories.isEmpty) return; + _db.execute('BEGIN TRANSACTION;'); + try { + for (var history in histories) { + _db.execute(""" + delete from history + where id == ? and type == ?; + """, [history.id, history.type.value]); + } + _db.execute('COMMIT;'); + } catch (e) { + _db.execute('ROLLBACK;'); + rethrow; + } + updateCache(); + notifyListeners(); + } } diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index b52524f..206e8fb 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:isolate'; import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:path_provider/path_provider.dart'; @@ -546,6 +547,59 @@ class LocalManager with ChangeNotifier { remove(c.id, c.comicType); notifyListeners(); } + + void batchDeleteComics(List comics, [bool removeFileOnDisk = true]) { + if (comics.isEmpty) { + return; + } + + var shouldRemovedDirs = []; + _db.execute('BEGIN TRANSACTION;'); + try { + for (var c in comics) { + if (removeFileOnDisk) { + var dir = Directory(FilePath.join(path, c.directory)); + if (dir.existsSync()) { + shouldRemovedDirs.add(dir); + } + } + _db.execute( + 'DELETE FROM comics WHERE id = ? AND comic_type = ?;', + [c.id, c.comicType.value], + ); + } + } + catch(e, s) { + Log.error("LocalManager", "Failed to batch delete comics: $e", s); + _db.execute('ROLLBACK;'); + return; + } + _db.execute('COMMIT;'); + + var comicIDs = comics.map((e) => ComicID(e.comicType, e.id)).toList(); + LocalFavoritesManager().batchDeleteComicsInAllFolders(comicIDs); + HistoryManager().batchDeleteHistories(comicIDs); + + notifyListeners(); + + if (removeFileOnDisk) { + _deleteDirectories(shouldRemovedDirs); + } + } + + static void _deleteDirectories(List directories) { + Isolate.run(() async { + for (var dir in directories) { + try { + if (dir.existsSync()) { + await dir.delete(recursive: true); + } + } catch (e) { + continue; + } + } + }); + } } enum LocalSortType { diff --git a/lib/pages/favorites/local_favorites_page.dart b/lib/pages/favorites/local_favorites_page.dart index fc45c0f..602f639 100644 --- a/lib/pages/favorites/local_favorites_page.dart +++ b/lib/pages/favorites/local_favorites_page.dart @@ -416,10 +416,12 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { "Selected @c comics".tlParams({"c": selectedComics.length})), actions: [ MenuButton(entries: [ + if (!isAllFolder) MenuEntry( icon: Icons.drive_file_move, text: "Move to folder".tl, onClick: () => favoriteOption('move')), + if (!isAllFolder) MenuEntry( icon: Icons.copy, text: "Copy to folder".tl, @@ -756,32 +758,26 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { return; } if (option == 'move') { - for (var c in selectedComics.keys) { - for (var s in selectedLocalFolders) { - LocalFavoritesManager().moveFavorite( - favPage.folder as String, - s, - c.id, - (c as FavoriteItem).type); - } + var comics = selectedComics.keys + .map((e) => e as FavoriteItem) + .toList(); + for (var f in selectedLocalFolders) { + LocalFavoritesManager().batchMoveFavorites( + favPage.folder as String, + f, + comics, + ); } } else { - for (var c in selectedComics.keys) { - for (var s in selectedLocalFolders) { - LocalFavoritesManager().addComic( - s, - FavoriteItem( - id: c.id, - name: c.title, - coverPath: c.cover, - author: c.subtitle ?? '', - type: ComicType((c.sourceKey == 'local' - ? 0 - : c.sourceKey.hashCode)), - tags: c.tags ?? [], - ), - ); - } + var comics = selectedComics.keys + .map((e) => e as FavoriteItem) + .toList(); + for (var f in selectedLocalFolders) { + LocalFavoritesManager().batchCopyFavorites( + favPage.folder as String, + f, + comics, + ); } } App.rootContext.pop(); @@ -817,13 +813,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { } void _deleteComicWithId() { - for (var c in selectedComics.keys) { - LocalFavoritesManager().deleteComicWithId( - widget.folder, - c.id, - (c as FavoriteItem).type, - ); - } + var toBeDeleted = selectedComics.keys.map((e) => e as FavoriteItem).toList(); + LocalFavoritesManager().batchDeleteComics(widget.folder, toBeDeleted); _cancel(); } } diff --git a/lib/pages/favorites/side_bar.dart b/lib/pages/favorites/side_bar.dart index 464ad75..1125b0a 100644 --- a/lib/pages/favorites/side_bar.dart +++ b/lib/pages/favorites/side_bar.dart @@ -42,6 +42,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList { folders = LocalFavoritesManager().folderNames; findNetworkFolders(); appdata.settings.addListener(updateFolders); + LocalFavoritesManager().addListener(updateFolders); super.initState(); } @@ -49,6 +50,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList { void dispose() { super.dispose(); appdata.settings.removeListener(updateFolders); + LocalFavoritesManager().removeListener(updateFolders); } @override diff --git a/lib/pages/local_comics_page.dart b/lib/pages/local_comics_page.dart index 22b4dd9..c958fee 100644 --- a/lib/pages/local_comics_page.dart +++ b/lib/pages/local_comics_page.dart @@ -377,12 +377,10 @@ class _LocalComicsPageState extends State { FilledButton( onPressed: () { context.pop(); - for (var comic in comics) { - LocalManager().deleteComic( - comic, - removeComicFile, - ); - } + LocalManager().batchDeleteComics( + comics, + removeComicFile, + ); isDeleted = true; }, child: Text("Confirm".tl),