From f0b1135eb73281785cc4fedbfa90f62dc43783bf Mon Sep 17 00:00:00 2001 From: nyne Date: Sat, 8 Feb 2025 18:23:49 +0800 Subject: [PATCH] Allow batch export. Close #179 --- assets/translation.json | 8 +- lib/components/message.dart | 121 +++++++++++++++--------- lib/pages/local_comics_page.dart | 154 ++++++++++++++++++------------- lib/utils/cbz.dart | 4 +- lib/utils/epub.dart | 27 +++--- lib/utils/pdf.dart | 10 +- 6 files changed, 189 insertions(+), 135 deletions(-) diff --git a/assets/translation.json b/assets/translation.json index 999477e..a279368 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -321,7 +321,9 @@ "Manage": "管理", "Verify": "验证", "Cloudflare verification required": "需要Cloudflare验证", - "Success": "成功" + "Success": "成功", + "Compressing": "压缩中", + "Exporting": "导出中" }, "zh_TW": { "Home": "首頁", @@ -645,6 +647,8 @@ "Manage": "管理", "Verify": "驗證", "Cloudflare verification required": "需要Cloudflare驗證", - "Success": "成功" + "Success": "成功", + "Compressing": "壓縮中", + "Exporting": "匯出中" } } \ No newline at end of file diff --git a/lib/components/message.dart b/lib/components/message.dart index 42b4374..777ec92 100644 --- a/lib/components/message.dart +++ b/lib/components/message.dart @@ -168,7 +168,15 @@ Future showConfirmDialog({ } class LoadingDialogController { - void Function()? closeDialog; + double? _progress; + + String? _message; + + void Function()? _closeDialog; + + void Function(double? value)? _serProgress; + + void Function(String message)? _setMessage; bool closed = false; @@ -177,63 +185,86 @@ class LoadingDialogController { return; } closed = true; - if (closeDialog == null) { - Future.microtask(closeDialog!); + if (_closeDialog == null) { + Future.microtask(_closeDialog!); } else { - closeDialog!(); + _closeDialog!(); } } + + void setProgress(double? value) { + if (closed) { + return; + } + _serProgress?.call(value); + } + + void setMessage(String message) { + if (closed) { + return; + } + _setMessage?.call(message); + } } -LoadingDialogController showLoadingDialog(BuildContext context, - {void Function()? onCancel, - bool barrierDismissible = true, - bool allowCancel = true, - String? message, - String cancelButtonText = "Cancel"}) { +LoadingDialogController showLoadingDialog( + BuildContext context, { + void Function()? onCancel, + bool barrierDismissible = true, + bool allowCancel = true, + String? message, + String cancelButtonText = "Cancel", + bool withProgress = false, +}) { var controller = LoadingDialogController(); + controller._message = message; + + if (withProgress) { + controller._progress = 0; + } var loadingDialogRoute = DialogRoute( - context: context, - barrierDismissible: barrierDismissible, - builder: (BuildContext context) { - return Dialog( - child: Container( - width: 100, - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - const SizedBox( - width: 30, - height: 30, - child: CircularProgressIndicator(), - ), - const SizedBox( - width: 16, - ), - Text( - message ?? 'Loading', - style: const TextStyle(fontSize: 16), - ), - const Spacer(), - if (allowCancel) - TextButton( - onPressed: () { - controller.close(); - onCancel?.call(); - }, - child: Text(cancelButtonText.tl)) - ], - ), - ), + context: context, + barrierDismissible: barrierDismissible, + builder: (BuildContext context) { + return StatefulBuilder(builder: (context, setState) { + controller._serProgress = (value) { + setState(() { + controller._progress = value; + }); + }; + controller._setMessage = (message) { + setState(() { + controller._message = message; + }); + }; + return ContentDialog( + title: controller._message ?? 'Loading', + content: LinearProgressIndicator( + value: controller._progress, + backgroundColor: context.colorScheme.surfaceContainer, + ).paddingHorizontal(16).paddingVertical(16), + actions: [ + FilledButton( + onPressed: allowCancel + ? () { + controller.close(); + onCancel?.call(); + } + : null, + child: Text(cancelButtonText.tl), + ) + ], ); }); + }, + ); var navigator = Navigator.of(context, rootNavigator: true); navigator.push(loadingDialogRoute).then((value) => controller.closed = true); - controller.closeDialog = () { + controller._closeDialog = () { navigator.removeRoute(loadingDialogRoute); }; @@ -444,9 +475,7 @@ Future showSelectDialog({ child: Text('Cancel'.tl), ), FilledButton( - onPressed: current == null - ? null - : context.pop, + onPressed: current == null ? null : context.pop, child: Text('Confirm'.tl), ), ], diff --git a/lib/pages/local_comics_page.dart b/lib/pages/local_comics_page.dart index a8a0794..cf5b6b6 100644 --- a/lib/pages/local_comics_page.dart +++ b/lib/pages/local_comics_page.dart @@ -12,6 +12,7 @@ import 'package:venera/utils/epub.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/pdf.dart'; import 'package:venera/utils/translations.dart'; +import 'package:zip_flutter/zip_flutter.dart'; class LocalComicsPage extends StatefulWidget { const LocalComicsPage({super.key}); @@ -147,13 +148,13 @@ class _LocalComicsPageState extends State { text: "View Detail".tl, onClick: () { context.to(() => ComicPage( - id: selectedComics.keys.first.id, - sourceKey: selectedComics.keys.first.sourceKey, - )); + id: selectedComics.keys.first.id, + sourceKey: selectedComics.keys.first.sourceKey, + )); }, ), - if (selectedComics.length == 1) - ...exportActions(selectedComics.keys.first), + if (selectedComics.isNotEmpty) + ...exportActions(selectedComics.keys.toList()), ]); } @@ -322,7 +323,7 @@ class _LocalComicsPageState extends State { }); }, ), - ...exportActions(c as LocalComic), + ...exportActions([c as LocalComic]), ]; }, ), @@ -390,79 +391,102 @@ class _LocalComicsPageState extends State { return isDeleted; } - List exportActions(LocalComic c) { + List exportActions(List comics) { return [ MenuEntry( - icon: Icons.outbox_outlined, - text: "Export as cbz".tl, - onClick: () async { - var controller = showLoadingDialog( - context, - allowCancel: false, - ); - try { - var file = await CBZ.export(c); - await saveFile(filename: file.name, file: file); - await file.delete(); - } catch (e, s) { - context.showMessage(message: e.toString()); - Log.error("CBZ Export", e, s); - } - controller.close(); - }), + icon: Icons.outbox_outlined, + text: "Export as cbz".tl, + onClick: () { + exportComics(comics, CBZ.export, ".cbz"); + }, + ), MenuEntry( icon: Icons.picture_as_pdf_outlined, text: "Export as pdf".tl, onClick: () async { - var cache = FilePath.join(App.cachePath, 'temp.pdf'); - var controller = showLoadingDialog( - context, - allowCancel: false, - ); - try { - await createPdfFromComicIsolate( - comic: c, - savePath: cache, - ); - await saveFile( - file: File(cache), - filename: "${c.title}.pdf", - ); - } catch (e, s) { - Log.error("PDF Export", e, s); - context.showMessage(message: e.toString()); - } finally { - controller.close(); - File(cache).deleteIgnoreError(); - } + exportComics(comics, createPdfFromComicIsolate, ".pdf"); }, ), MenuEntry( icon: Icons.import_contacts_outlined, text: "Export as epub".tl, onClick: () async { - var controller = showLoadingDialog( - context, - allowCancel: false, - ); - File? file; - try { - file = await createEpubWithLocalComic( - c, - ); - await saveFile( - file: file, - filename: "${c.title}.epub", - ); - } catch (e, s) { - Log.error("EPUB Export", e, s); - context.showMessage(message: e.toString()); - } finally { - controller.close(); - file?.deleteIgnoreError(); - } + exportComics(comics, createEpubWithLocalComic, ".epub"); }, ) ]; } + + /// Export given comics to a file + void exportComics( + List comics, ExportComicFunc export, String ext) async { + var current = 0; + var cacheDir = FilePath.join(App.cachePath, 'comics_export'); + var outFile = FilePath.join(App.cachePath, 'comics_export.zip'); + bool canceled = false; + if (Directory(cacheDir).existsSync()) { + Directory(cacheDir).deleteSync(recursive: true); + } + Directory(cacheDir).createSync(); + var loadingController = showLoadingDialog( + context, + allowCancel: true, + message: "${"Exporting".tl} $current/${comics.length}", + withProgress: comics.length > 1, + onCancel: () { + canceled = true; + }, + ); + try { + var fileName = ""; + // For each comic, export it to a file + for (var comic in comics) { + fileName = FilePath.join(cacheDir, sanitizeFileName(comic.title) + ext); + await export(comic, fileName); + current++; + if (comics.length > 1) { + loadingController + .setMessage("${"Exporting".tl} $current/${comics.length}"); + loadingController.setProgress(current / comics.length); + } + if (canceled) { + return; + } + } + // For single comic, just save the file + if (comics.length == 1) { + await saveFile( + file: File(fileName), + filename: File(fileName).name, + ); + Directory(cacheDir).deleteSync(recursive: true); + loadingController.close(); + return; + } + // For multiple comics, compress the folder + loadingController.setProgress(null); + loadingController.setMessage("Compressing".tl); + await ZipFile.compressFolderAsync(cacheDir, outFile); + if (canceled) { + File(outFile).deleteIgnoreError(); + return; + } + } catch (e, s) { + Log.error("Export Comics", e, s); + context.showMessage(message: e.toString()); + loadingController.close(); + return; + } finally { + Directory(cacheDir).deleteIgnoreError(recursive: true); + } + await saveFile( + file: File(outFile), + filename: "comics_export.zip", + ); + loadingController.close(); + File(outFile).deleteIgnoreError(); + } } + +typedef ExportComicFunc = Future Function( + LocalComic comic, String outFilePath); diff --git a/lib/utils/cbz.dart b/lib/utils/cbz.dart index 214c787..d36d54a 100644 --- a/lib/utils/cbz.dart +++ b/lib/utils/cbz.dart @@ -175,7 +175,7 @@ abstract class CBZ { return comic; } - static Future export(LocalComic comic) async { + static Future export(LocalComic comic, String outFilePath) async { var cache = Directory(FilePath.join(App.cachePath, 'cbz_export')); if (cache.existsSync()) cache.deleteSync(recursive: true); cache.createSync(); @@ -234,7 +234,7 @@ abstract class CBZ { ).toJson(), ), ); - var cbz = File(FilePath.join(App.cachePath, sanitizeFileName('${comic.title}.cbz'))); + var cbz = File(outFilePath); if (cbz.existsSync()) cbz.deleteSync(); await _compress(cache.path, cbz.path); cache.deleteSync(recursive: true); diff --git a/lib/utils/epub.dart b/lib/utils/epub.dart index 7505f00..42764a1 100644 --- a/lib/utils/epub.dart +++ b/lib/utils/epub.dart @@ -24,7 +24,8 @@ class EpubData { }); } -Future createEpubComic(EpubData data, String cacheDir) async { +Future createEpubComic( + EpubData data, String cacheDir, String outFilePath) async { final workingDir = Directory(FilePath.join(cacheDir, 'epub')); if (workingDir.existsSync()) { workingDir.deleteSync(recursive: true); @@ -109,8 +110,7 @@ ${images.map((e) => ' $e').join('\n')} } // content.opf - final contentOpf = - File(FilePath.join(workingDir.path, 'content.opf')); + final contentOpf = File(FilePath.join(workingDir.path, 'content.opf')); final uuid = const Uuid().v4(); var spineStrBuilder = StringBuffer(); for (var i = 0; i < chapterIndex; i++) { @@ -171,16 +171,15 @@ ${navMapStrBuilder.toString()} '''); - // zip - final zipPath = FilePath.join(cacheDir, '${data.title}.epub'); - ZipFile.compressFolder(workingDir.path, zipPath); + ZipFile.compressFolder(workingDir.path, outFilePath); workingDir.deleteSync(recursive: true); - return File(zipPath); + return File(outFilePath); } -Future createEpubWithLocalComic(LocalComic comic) async { +Future createEpubWithLocalComic( + LocalComic comic, String outFilePath) async { var chapters = >{}; if (comic.chapters == null) { chapters[comic.title] = @@ -188,11 +187,11 @@ Future createEpubWithLocalComic(LocalComic comic) async { .map((e) => File(e)) .toList(); } else { - for (var chapter in comic.chapters!.keys) { - chapters[comic.chapters![chapter]!] = (await LocalManager() - .getImages(comic.id, comic.comicType, chapter)) - .map((e) => File(e)) - .toList(); + for (var chapter in comic.downloadedChapters) { + chapters[comic.chapters![chapter]!] = + (await LocalManager().getImages(comic.id, comic.comicType, chapter)) + .map((e) => File(e)) + .toList(); } } var data = EpubData( @@ -205,6 +204,6 @@ Future createEpubWithLocalComic(LocalComic comic) async { final cacheDir = App.cachePath; return Isolate.run(() => overrideIO(() async { - return createEpubComic(data, cacheDir); + return createEpubComic(data, cacheDir, outFilePath); })); } diff --git a/lib/utils/pdf.dart b/lib/utils/pdf.dart index 085ea1f..9ce0493 100644 --- a/lib/utils/pdf.dart +++ b/lib/utils/pdf.dart @@ -49,7 +49,7 @@ Future _createPdfFromComic({ images.add(file.path); } } else { - for (var chapter in comic.chapters!.keys) { + for (var chapter in comic.downloadedChapters) { var files = Directory(FilePath.join(baseDir, chapter)).listSync(); reorderFiles(files); for (var file in files) { @@ -112,10 +112,7 @@ Future _runIsolate( ); } -Future createPdfFromComicIsolate({ - required LocalComic comic, - required String savePath, -}) async { +Future createPdfFromComicIsolate(LocalComic comic, String savePath) async { var receivePort = ReceivePort(); SendPort? sendPort; Isolate? isolate; @@ -134,7 +131,8 @@ Future createPdfFromComicIsolate({ } }); isolate = await _runIsolate(comic, savePath, receivePort.sendPort); - return completer.future; + await completer.future; + return File(savePath); } class PdfGenerator {