From 0308e68a440c42030f9358d6616b3cba2b0a9cd9 Mon Sep 17 00:00:00 2001 From: nyne Date: Mon, 28 Oct 2024 22:59:35 +0800 Subject: [PATCH] cbz import & export --- assets/translation.json | 24 ++- lib/foundation/local.dart | 4 +- lib/pages/home_page.dart | 42 ++++- lib/pages/local_comics_page.dart | 32 +++- lib/utils/cbz.dart | 217 ++++++++++++++++++++++++ lib/utils/io.dart | 3 + linux/flutter/generated_plugins.cmake | 1 + pubspec.lock | 9 + pubspec.yaml | 3 + windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 lib/utils/cbz.dart diff --git a/assets/translation.json b/assets/translation.json index 74402e2..210c6dd 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -132,7 +132,17 @@ "Failed to import": "导入失败", "Cache Limit": "缓存限制", "Set Cache Limit": "设置缓存限制", - "Size in MB": "大小(MB)" + "Size in MB": "大小(MB)", + "Select a directory which contains the comic directories." : "选择一个包含漫画文件夹的目录", + "Help": "帮助", + "A directory is considered as a comic only if it matches one of the following conditions:" : "只有当目录满足以下条件之一时,才被视为漫画:", + "1. The directory only contains image files." : "1. 目录只包含图片文件。", + "2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目录包含多个包含图片文件的目录。每个目录被视为一个章节。", + "If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。", + "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles." : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。", + "Export as cbz": "导出为cbz", + "Select a cbz file." : "选择一个cbz文件", + "A cbz file" : "一个cbz文件" }, "zh_TW": { "Home": "首頁", @@ -267,6 +277,16 @@ "Failed to import": "匯入失敗", "Cache Limit": "緩存限制", "Set Cache Limit": "設置緩存限制", - "Size in MB": "大小(MB)" + "Size in MB": "大小(MB)", + "Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄", + "Help": "幫助", + "A directory is considered as a comic only if it matches one of the following conditions:" : "只有當目錄滿足以下條件之一時,才被視為漫畫:", + "1. The directory only contains image files." : "1. 目錄只包含圖片文件。", + "2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目錄包含多個包含圖片文件的目錄。每個目錄被視為一個章節。", + "If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。", + "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles." : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。", + "Export as cbz": "匯出為cbz", + "Select a cbz file." : "選擇一個cbz文件", + "A cbz file" : "一個cbz文件" } } \ No newline at end of file diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index 2f8a39f..c3ccc61 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -221,7 +221,7 @@ class LocalManager with ChangeNotifier { if (res.isEmpty) { return '1'; } - return ((res.first[0] as int) + 1).toString(); + return (int.parse((res.first[0])) + 1).toString(); } Future add(LocalComic comic, [String? id]) async { @@ -424,7 +424,7 @@ class LocalManager with ChangeNotifier { void deleteComic(LocalComic c) { var dir = Directory(FilePath.join(path, c.directory)); - dir.deleteSync(recursive: true); + dir.deleteIgnoreError(recursive: true); remove(c.id, c.comicType); notifyListeners(); } diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 821b7af..75b9bf0 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -15,6 +15,7 @@ import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/history_page.dart'; import 'package:venera/pages/search_page.dart'; +import 'package:venera/utils/cbz.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/translations.dart'; @@ -391,9 +392,11 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { @override Widget build(BuildContext context) { - String info = type == 0 - ? "Select a directory which contains the comic files.".tl - : "Select a directory which contains the comic directories.".tl; + String info = [ + "Select a directory which contains the comic files.".tl, + "Select a directory which contains the comic directories.".tl, + "Select a cbz file.".tl, + ][type]; return ContentDialog( dismissible: !loading, @@ -431,6 +434,16 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { }); }, ), + RadioListTile( + title: Text("A cbz file".tl), + value: 2, + groupValue: type, + onChanged: (value) { + setState(() { + type = value as int; + }); + }, + ), const SizedBox(height: 8), Text(info).paddingHorizontal(24), ], @@ -490,6 +503,21 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { } void selectAndImport() async { + if (type == 2) { + var xFile = await selectFile(ext: ['cbz']); + var controller = showLoadingDialog(context, allowCancel: false); + try { + var cache = FilePath.join(App.cachePath, xFile?.name ?? 'temp.cbz'); + await xFile!.saveTo(cache); + await CBZ.import(File(cache)); + await File(cache).delete(); + } catch (e, s) { + Log.error("Import Comic", e.toString(), s); + context.showMessage(message: e.toString()); + } + controller.close(); + return; + } height = key.currentContext!.size!.height; setState(() { loading = true; @@ -583,16 +611,16 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { Future checkSingleComic(Directory directory) async { if (!(await directory.exists())) return null; var name = directory.name; + if (LocalManager().findByName(name) != null) { + Log.info("Import Comic", "Comic already exists: $name"); + return null; + } bool hasChapters = false; var chapters = []; var coverPath = ''; // relative path to the cover image await for (var entry in directory.list()) { if (entry is Directory) { hasChapters = true; - if (LocalManager().findByName(entry.name) != null) { - Log.info("Import Comic", "Comic already exists: $name"); - return null; - } chapters.add(entry.name); await for (var file in entry.list()) { if (file is Directory) { diff --git a/lib/pages/local_comics_page.dart b/lib/pages/local_comics_page.dart index c62e12c..9b6a493 100644 --- a/lib/pages/local_comics_page.dart +++ b/lib/pages/local_comics_page.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:venera/components/components.dart'; +import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/pages/downloading_page.dart'; +import 'package:venera/utils/cbz.dart'; +import 'package:venera/utils/io.dart'; import 'package:venera/utils/translations.dart'; class LocalComicsPage extends StatefulWidget { @@ -60,12 +63,29 @@ class _LocalComicsPageState extends State { menuBuilder: (c) { return [ MenuEntry( - icon: Icons.delete, - text: "Delete".tl, - onClick: () { - LocalManager().deleteComic(c as LocalComic); - } - ), + icon: Icons.delete, + text: "Delete".tl, + onClick: () { + LocalManager().deleteComic(c as LocalComic); + }), + MenuEntry( + icon: Icons.delete, + text: "Export as cbz".tl, + onClick: () async { + var controller = showLoadingDialog( + context, + allowCancel: false, + ); + try { + var file = await CBZ.export(c as LocalComic); + await saveFile(filename: file.name, file: file); + await file.delete(); + } + catch (e) { + context.showMessage(message: e.toString()); + } + controller.close(); + }), ]; }, ), diff --git a/lib/utils/cbz.dart b/lib/utils/cbz.dart new file mode 100644 index 0000000..e3eccd8 --- /dev/null +++ b/lib/utils/cbz.dart @@ -0,0 +1,217 @@ +import 'dart:convert'; +import 'dart:isolate'; + +import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/comic_type.dart'; +import 'package:venera/foundation/local.dart'; +import 'package:venera/utils/ext.dart'; +import 'package:venera/utils/io.dart'; +import 'package:zip_flutter/zip_flutter.dart'; + +class ComicMetaData { + final String title; + + final String author; + + final List tags; + + final List? chapters; + + Map toJson() => { + 'title': title, + 'author': author, + 'tags': tags, + 'chapters': chapters?.map((e) => e.toJson()).toList() + }; + + ComicMetaData.fromJson(Map json) + : title = json['title'], + author = json['author'], + tags = List.from(json['tags']), + chapters = json['chapters'] == null + ? null + : List.from( + json['chapters'].map((e) => ComicChapter.fromJson(e))); + + ComicMetaData({ + required this.title, + required this.author, + required this.tags, + this.chapters, + }); +} + +class ComicChapter { + final String title; + + final int start; + + final int end; + + Map toJson() => {'title': title, 'start': start, 'end': end}; + + ComicChapter.fromJson(Map json) + : title = json['title'], + start = json['start'], + end = json['end']; + + ComicChapter({required this.title, required this.start, required this.end}); +} + +abstract class CBZ { + static Future import(File file) async { + var cache = Directory(FilePath.join(App.cachePath, 'cbz_import')); + if (cache.existsSync()) cache.deleteSync(recursive: true); + cache.createSync(); + await Isolate.run(() => ZipFile.openAndExtract(file.path, cache.path)); + var metaDataFile = File(FilePath.join(cache.path, 'metadata.json')); + ComicMetaData? metaData; + if (metaDataFile.existsSync()) { + try { + metaData = + ComicMetaData.fromJson(jsonDecode(metaDataFile.readAsStringSync())); + } catch (_) {} + } + metaData ??= ComicMetaData( + title: file.name.replaceLast('.cbz', ''), + author: "", + tags: [], + ); + var old = LocalManager().findByName(metaData.title); + if (old != null) { + throw Exception('Comic with name ${metaData.title} already exists'); + } + var files = cache.listSync().whereType().toList(); + files.removeWhere((e) { + var ext = e.path.split('.').last; + return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext); + }); + files.sort((a, b) => a.path.compareTo(b.path)); + var coverFile = files.firstWhereOrNull( + (element) => + element.path.endsWith('cover.${element.path.split('.').last}'), + ); + if (coverFile != null) { + files.remove(coverFile); + } else { + coverFile = files.first; + } + Map? cpMap; + var dest = Directory( + FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)), + ); + dest.createSync(); + coverFile.copy( + FilePath.join(dest.path, 'cover.${coverFile.path.split('.').last}')); + if (metaData.chapters == null) { + for (var i = 0; i < files.length; i++) { + var src = files[i]; + var dst = File( + FilePath.join(dest.path, '${i + 1}.${src.path.split('.').last}')); + src.copy(dst.path); + } + } else { + dest.createSync(); + var chapters = >{}; + for (var chapter in metaData.chapters!) { + chapters[chapter.title] = files.sublist(chapter.start - 1, chapter.end); + } + int i = 0; + cpMap = {}; + for (var chapter in chapters.entries) { + cpMap[i.toString()] = chapter.key; + var chapterDir = Directory(FilePath.join(dest.path, i.toString())); + chapterDir.createSync(); + for (var i = 0; i < chapter.value.length; i++) { + var src = chapter.value[i]; + var dst = File(FilePath.join( + chapterDir.path, '${i + 1}.${src.path.split('.').last}')); + src.copy(dst.path); + } + } + } + LocalManager().add( + LocalComic( + id: LocalManager().findValidId(ComicType.local), + title: metaData.title, + subtitle: metaData.author, + tags: metaData.tags, + comicType: ComicType.local, + directory: dest.name, + chapters: cpMap, + downloadedChapters: cpMap?.keys.toList() ?? [], + cover: 'cover.${coverFile.path.split('.').last}', + createdAt: DateTime.now(), + ), + ); + } + + static Future export(LocalComic comic) async { + var cache = Directory(FilePath.join(App.cachePath, 'cbz_export')); + if (cache.existsSync()) cache.deleteSync(recursive: true); + cache.createSync(); + List? chapters; + if (comic.chapters == null) { + var images = await LocalManager().getImages(comic.id, comic.comicType, 1); + int i = 1; + for (var image in images) { + var src = File(image.replaceFirst('file://', '')); + var width = images.length.toString().length; + var dstName = + '${i.toString().padLeft(width, '0')}.${image.split('.').last}'; + var dst = File(FilePath.join(cache.path, dstName)); + await src.copy(dst.path); + i++; + } + } else { + chapters = []; + var allImages = []; + for (var c in comic.downloadedChapters) { + var chapterName = comic.chapters![c]; + var images = await LocalManager().getImages( + comic.id, + comic.comicType, + c, + ); + allImages.addAll(images); + var chapter = ComicChapter( + title: chapterName!, + start: chapters.length + 1, + end: chapters.length + images.length, + ); + chapters.add(chapter); + } + int i = 1; + for (var image in allImages) { + var src = File(image.replaceFirst('file://', '')); + var width = allImages.length.toString().length; + var dstName = + '${i.toString().padLeft(width, '0')}.${image.split('.').last}'; + var dst = File(FilePath.join(cache.path, dstName)); + await src.copy(dst.path); + i++; + } + } + var cover = comic.coverFile; + await cover + .copy(FilePath.join(cache.path, 'cover.${cover.path.split('.').last}')); + await File(FilePath.join(cache.path, 'metadata.json')).writeAsString( + jsonEncode( + ComicMetaData( + title: comic.title, + author: comic.subtitle, + tags: comic.tags, + chapters: chapters, + ).toJson(), + ), + ); + var cbz = File(FilePath.join(App.cachePath, '${comic.title}.cbz')); + await _compress(cache.path, cbz.path); + cache.deleteSync(recursive: true); + return cbz; + } + + static _compress(String src, String dst) async { + await Isolate.run(() => ZipFile.compressFolder(src, dst)); + } +} diff --git a/lib/utils/io.dart b/lib/utils/io.dart index 2babf42..9479f90 100644 --- a/lib/utils/io.dart +++ b/lib/utils/io.dart @@ -73,6 +73,9 @@ extension DirectoryExtension on Directory { } String sanitizeFileName(String fileName) { + if(fileName.endsWith('.')) { + fileName = fileName.substring(0, fileName.length - 1); + } const maxLength = 255; final invalidChars = RegExp(r'[<>:"/\\|?*]'); final sanitizedFileName = fileName.replaceAll(invalidChars, ' '); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7f61925..c0fb5f6 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -14,6 +14,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + zip_flutter ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.lock b/pubspec.lock index 82a634d..623cf1d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -839,6 +839,15 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + zip_flutter: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: d5721f1fd8179ee4a5db59f932ae7c89d94e12a0 + url: "https://github.com/wgh136/zip_flutter" + source: git + version: "0.0.1" sdks: dart: ">=3.5.0 <4.0.0" flutter: ">=3.24.4" diff --git a/pubspec.yaml b/pubspec.yaml index 72ace46..7a97883 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,9 @@ dependencies: sliver_tools: ^0.2.12 flutter_file_dialog: ^3.0.2 file_selector: ^1.0.3 + zip_flutter: + git: + url: https://github.com/wgh136/zip_flutter dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 7ccdf82..693c576 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -16,6 +16,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + zip_flutter ) set(PLUGIN_BUNDLED_LIBRARIES)