mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
Allow batch export. Close #179
This commit is contained in:
@@ -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": "匯出中"
|
||||
}
|
||||
}
|
@@ -168,7 +168,15 @@ Future<void> 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!();
|
||||
}
|
||||
}
|
||||
|
||||
LoadingDialogController showLoadingDialog(BuildContext context,
|
||||
{void Function()? onCancel,
|
||||
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"}) {
|
||||
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: () {
|
||||
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();
|
||||
},
|
||||
child: Text(cancelButtonText.tl))
|
||||
}
|
||||
: 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<int?> showSelectDialog({
|
||||
child: Text('Cancel'.tl),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: current == null
|
||||
? null
|
||||
: context.pop,
|
||||
onPressed: current == null ? null : context.pop,
|
||||
child: Text('Confirm'.tl),
|
||||
),
|
||||
],
|
||||
|
@@ -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});
|
||||
@@ -152,8 +153,8 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
));
|
||||
},
|
||||
),
|
||||
if (selectedComics.length == 1)
|
||||
...exportActions(selectedComics.keys.first),
|
||||
if (selectedComics.isNotEmpty)
|
||||
...exportActions(selectedComics.keys.toList()),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -322,7 +323,7 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
});
|
||||
},
|
||||
),
|
||||
...exportActions(c as LocalComic),
|
||||
...exportActions([c as LocalComic]),
|
||||
];
|
||||
},
|
||||
),
|
||||
@@ -390,79 +391,102 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
return isDeleted;
|
||||
}
|
||||
|
||||
List<MenuEntry> exportActions(LocalComic c) {
|
||||
List<MenuEntry> exportActions(List<LocalComic> 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();
|
||||
}),
|
||||
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<LocalComic> 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<File> Function(
|
||||
LocalComic comic, String outFilePath);
|
||||
|
@@ -175,7 +175,7 @@ abstract class CBZ {
|
||||
return comic;
|
||||
}
|
||||
|
||||
static Future<File> export(LocalComic comic) async {
|
||||
static Future<File> 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);
|
||||
|
@@ -24,7 +24,8 @@ class EpubData {
|
||||
});
|
||||
}
|
||||
|
||||
Future<File> createEpubComic(EpubData data, String cacheDir) async {
|
||||
Future<File> 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) => ' <img src="$e" alt="$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()}
|
||||
</ncx>
|
||||
''');
|
||||
|
||||
// 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<File> createEpubWithLocalComic(LocalComic comic) async {
|
||||
Future<File> createEpubWithLocalComic(
|
||||
LocalComic comic, String outFilePath) async {
|
||||
var chapters = <String, List<File>>{};
|
||||
if (comic.chapters == null) {
|
||||
chapters[comic.title] =
|
||||
@@ -188,9 +187,9 @@ Future<File> 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))
|
||||
for (var chapter in comic.downloadedChapters) {
|
||||
chapters[comic.chapters![chapter]!] =
|
||||
(await LocalManager().getImages(comic.id, comic.comicType, chapter))
|
||||
.map((e) => File(e))
|
||||
.toList();
|
||||
}
|
||||
@@ -205,6 +204,6 @@ Future<File> createEpubWithLocalComic(LocalComic comic) async {
|
||||
final cacheDir = App.cachePath;
|
||||
|
||||
return Isolate.run(() => overrideIO(() async {
|
||||
return createEpubComic(data, cacheDir);
|
||||
return createEpubComic(data, cacheDir, outFilePath);
|
||||
}));
|
||||
}
|
||||
|
@@ -49,7 +49,7 @@ Future<void> _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<Isolate> _runIsolate(
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> createPdfFromComicIsolate({
|
||||
required LocalComic comic,
|
||||
required String savePath,
|
||||
}) async {
|
||||
Future<File> createPdfFromComicIsolate(LocalComic comic, String savePath) async {
|
||||
var receivePort = ReceivePort();
|
||||
SendPort? sendPort;
|
||||
Isolate? isolate;
|
||||
@@ -134,7 +131,8 @@ Future<void> createPdfFromComicIsolate({
|
||||
}
|
||||
});
|
||||
isolate = await _runIsolate(comic, savePath, receivePort.sendPort);
|
||||
return completer.future;
|
||||
await completer.future;
|
||||
return File(savePath);
|
||||
}
|
||||
|
||||
class PdfGenerator {
|
||||
|
Reference in New Issue
Block a user