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": "管理",
|
"Manage": "管理",
|
||||||
"Verify": "验证",
|
"Verify": "验证",
|
||||||
"Cloudflare verification required": "需要Cloudflare验证",
|
"Cloudflare verification required": "需要Cloudflare验证",
|
||||||
"Success": "成功"
|
"Success": "成功",
|
||||||
|
"Compressing": "压缩中",
|
||||||
|
"Exporting": "导出中"
|
||||||
},
|
},
|
||||||
"zh_TW": {
|
"zh_TW": {
|
||||||
"Home": "首頁",
|
"Home": "首頁",
|
||||||
@@ -645,6 +647,8 @@
|
|||||||
"Manage": "管理",
|
"Manage": "管理",
|
||||||
"Verify": "驗證",
|
"Verify": "驗證",
|
||||||
"Cloudflare verification required": "需要Cloudflare驗證",
|
"Cloudflare verification required": "需要Cloudflare驗證",
|
||||||
"Success": "成功"
|
"Success": "成功",
|
||||||
|
"Compressing": "壓縮中",
|
||||||
|
"Exporting": "匯出中"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -168,7 +168,15 @@ Future<void> showConfirmDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
class LoadingDialogController {
|
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;
|
bool closed = false;
|
||||||
|
|
||||||
@@ -177,63 +185,86 @@ class LoadingDialogController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closed = true;
|
closed = true;
|
||||||
if (closeDialog == null) {
|
if (_closeDialog == null) {
|
||||||
Future.microtask(closeDialog!);
|
Future.microtask(_closeDialog!);
|
||||||
} else {
|
} 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,
|
LoadingDialogController showLoadingDialog(
|
||||||
{void Function()? onCancel,
|
BuildContext context, {
|
||||||
|
void Function()? onCancel,
|
||||||
bool barrierDismissible = true,
|
bool barrierDismissible = true,
|
||||||
bool allowCancel = true,
|
bool allowCancel = true,
|
||||||
String? message,
|
String? message,
|
||||||
String cancelButtonText = "Cancel"}) {
|
String cancelButtonText = "Cancel",
|
||||||
|
bool withProgress = false,
|
||||||
|
}) {
|
||||||
var controller = LoadingDialogController();
|
var controller = LoadingDialogController();
|
||||||
|
controller._message = message;
|
||||||
|
|
||||||
|
if (withProgress) {
|
||||||
|
controller._progress = 0;
|
||||||
|
}
|
||||||
|
|
||||||
var loadingDialogRoute = DialogRoute(
|
var loadingDialogRoute = DialogRoute(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: barrierDismissible,
|
barrierDismissible: barrierDismissible,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return Dialog(
|
return StatefulBuilder(builder: (context, setState) {
|
||||||
child: Container(
|
controller._serProgress = (value) {
|
||||||
width: 100,
|
setState(() {
|
||||||
padding: const EdgeInsets.all(16.0),
|
controller._progress = value;
|
||||||
child: Row(
|
});
|
||||||
children: [
|
};
|
||||||
const SizedBox(
|
controller._setMessage = (message) {
|
||||||
width: 30,
|
setState(() {
|
||||||
height: 30,
|
controller._message = message;
|
||||||
child: CircularProgressIndicator(),
|
});
|
||||||
),
|
};
|
||||||
const SizedBox(
|
return ContentDialog(
|
||||||
width: 16,
|
title: controller._message ?? 'Loading',
|
||||||
),
|
content: LinearProgressIndicator(
|
||||||
Text(
|
value: controller._progress,
|
||||||
message ?? 'Loading',
|
backgroundColor: context.colorScheme.surfaceContainer,
|
||||||
style: const TextStyle(fontSize: 16),
|
).paddingHorizontal(16).paddingVertical(16),
|
||||||
),
|
actions: [
|
||||||
const Spacer(),
|
FilledButton(
|
||||||
if (allowCancel)
|
onPressed: allowCancel
|
||||||
TextButton(
|
? () {
|
||||||
onPressed: () {
|
|
||||||
controller.close();
|
controller.close();
|
||||||
onCancel?.call();
|
onCancel?.call();
|
||||||
},
|
}
|
||||||
child: Text(cancelButtonText.tl))
|
: null,
|
||||||
|
child: Text(cancelButtonText.tl),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
var navigator = Navigator.of(context, rootNavigator: true);
|
var navigator = Navigator.of(context, rootNavigator: true);
|
||||||
|
|
||||||
navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
|
navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
|
||||||
|
|
||||||
controller.closeDialog = () {
|
controller._closeDialog = () {
|
||||||
navigator.removeRoute(loadingDialogRoute);
|
navigator.removeRoute(loadingDialogRoute);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -444,9 +475,7 @@ Future<int?> showSelectDialog({
|
|||||||
child: Text('Cancel'.tl),
|
child: Text('Cancel'.tl),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: current == null
|
onPressed: current == null ? null : context.pop,
|
||||||
? null
|
|
||||||
: context.pop,
|
|
||||||
child: Text('Confirm'.tl),
|
child: Text('Confirm'.tl),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@@ -12,6 +12,7 @@ import 'package:venera/utils/epub.dart';
|
|||||||
import 'package:venera/utils/io.dart';
|
import 'package:venera/utils/io.dart';
|
||||||
import 'package:venera/utils/pdf.dart';
|
import 'package:venera/utils/pdf.dart';
|
||||||
import 'package:venera/utils/translations.dart';
|
import 'package:venera/utils/translations.dart';
|
||||||
|
import 'package:zip_flutter/zip_flutter.dart';
|
||||||
|
|
||||||
class LocalComicsPage extends StatefulWidget {
|
class LocalComicsPage extends StatefulWidget {
|
||||||
const LocalComicsPage({super.key});
|
const LocalComicsPage({super.key});
|
||||||
@@ -152,8 +153,8 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
|||||||
));
|
));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (selectedComics.length == 1)
|
if (selectedComics.isNotEmpty)
|
||||||
...exportActions(selectedComics.keys.first),
|
...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;
|
return isDeleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MenuEntry> exportActions(LocalComic c) {
|
List<MenuEntry> exportActions(List<LocalComic> comics) {
|
||||||
return [
|
return [
|
||||||
MenuEntry(
|
MenuEntry(
|
||||||
icon: Icons.outbox_outlined,
|
icon: Icons.outbox_outlined,
|
||||||
text: "Export as cbz".tl,
|
text: "Export as cbz".tl,
|
||||||
onClick: () async {
|
onClick: () {
|
||||||
var controller = showLoadingDialog(
|
exportComics(comics, CBZ.export, ".cbz");
|
||||||
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();
|
|
||||||
}),
|
|
||||||
MenuEntry(
|
MenuEntry(
|
||||||
icon: Icons.picture_as_pdf_outlined,
|
icon: Icons.picture_as_pdf_outlined,
|
||||||
text: "Export as pdf".tl,
|
text: "Export as pdf".tl,
|
||||||
onClick: () async {
|
onClick: () async {
|
||||||
var cache = FilePath.join(App.cachePath, 'temp.pdf');
|
exportComics(comics, createPdfFromComicIsolate, ".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();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
MenuEntry(
|
MenuEntry(
|
||||||
icon: Icons.import_contacts_outlined,
|
icon: Icons.import_contacts_outlined,
|
||||||
text: "Export as epub".tl,
|
text: "Export as epub".tl,
|
||||||
onClick: () async {
|
onClick: () async {
|
||||||
var controller = showLoadingDialog(
|
exportComics(comics, createEpubWithLocalComic, ".epub");
|
||||||
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();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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;
|
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'));
|
var cache = Directory(FilePath.join(App.cachePath, 'cbz_export'));
|
||||||
if (cache.existsSync()) cache.deleteSync(recursive: true);
|
if (cache.existsSync()) cache.deleteSync(recursive: true);
|
||||||
cache.createSync();
|
cache.createSync();
|
||||||
@@ -234,7 +234,7 @@ abstract class CBZ {
|
|||||||
).toJson(),
|
).toJson(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
var cbz = File(FilePath.join(App.cachePath, sanitizeFileName('${comic.title}.cbz')));
|
var cbz = File(outFilePath);
|
||||||
if (cbz.existsSync()) cbz.deleteSync();
|
if (cbz.existsSync()) cbz.deleteSync();
|
||||||
await _compress(cache.path, cbz.path);
|
await _compress(cache.path, cbz.path);
|
||||||
cache.deleteSync(recursive: true);
|
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'));
|
final workingDir = Directory(FilePath.join(cacheDir, 'epub'));
|
||||||
if (workingDir.existsSync()) {
|
if (workingDir.existsSync()) {
|
||||||
workingDir.deleteSync(recursive: true);
|
workingDir.deleteSync(recursive: true);
|
||||||
@@ -109,8 +110,7 @@ ${images.map((e) => ' <img src="$e" alt="$e"/>').join('\n')}
|
|||||||
}
|
}
|
||||||
|
|
||||||
// content.opf
|
// content.opf
|
||||||
final contentOpf =
|
final contentOpf = File(FilePath.join(workingDir.path, 'content.opf'));
|
||||||
File(FilePath.join(workingDir.path, 'content.opf'));
|
|
||||||
final uuid = const Uuid().v4();
|
final uuid = const Uuid().v4();
|
||||||
var spineStrBuilder = StringBuffer();
|
var spineStrBuilder = StringBuffer();
|
||||||
for (var i = 0; i < chapterIndex; i++) {
|
for (var i = 0; i < chapterIndex; i++) {
|
||||||
@@ -171,16 +171,15 @@ ${navMapStrBuilder.toString()}
|
|||||||
</ncx>
|
</ncx>
|
||||||
''');
|
''');
|
||||||
|
|
||||||
// zip
|
ZipFile.compressFolder(workingDir.path, outFilePath);
|
||||||
final zipPath = FilePath.join(cacheDir, '${data.title}.epub');
|
|
||||||
ZipFile.compressFolder(workingDir.path, zipPath);
|
|
||||||
|
|
||||||
workingDir.deleteSync(recursive: true);
|
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>>{};
|
var chapters = <String, List<File>>{};
|
||||||
if (comic.chapters == null) {
|
if (comic.chapters == null) {
|
||||||
chapters[comic.title] =
|
chapters[comic.title] =
|
||||||
@@ -188,9 +187,9 @@ Future<File> createEpubWithLocalComic(LocalComic comic) async {
|
|||||||
.map((e) => File(e))
|
.map((e) => File(e))
|
||||||
.toList();
|
.toList();
|
||||||
} else {
|
} else {
|
||||||
for (var chapter in comic.chapters!.keys) {
|
for (var chapter in comic.downloadedChapters) {
|
||||||
chapters[comic.chapters![chapter]!] = (await LocalManager()
|
chapters[comic.chapters![chapter]!] =
|
||||||
.getImages(comic.id, comic.comicType, chapter))
|
(await LocalManager().getImages(comic.id, comic.comicType, chapter))
|
||||||
.map((e) => File(e))
|
.map((e) => File(e))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -205,6 +204,6 @@ Future<File> createEpubWithLocalComic(LocalComic comic) async {
|
|||||||
final cacheDir = App.cachePath;
|
final cacheDir = App.cachePath;
|
||||||
|
|
||||||
return Isolate.run(() => overrideIO(() async {
|
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);
|
images.add(file.path);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (var chapter in comic.chapters!.keys) {
|
for (var chapter in comic.downloadedChapters) {
|
||||||
var files = Directory(FilePath.join(baseDir, chapter)).listSync();
|
var files = Directory(FilePath.join(baseDir, chapter)).listSync();
|
||||||
reorderFiles(files);
|
reorderFiles(files);
|
||||||
for (var file in files) {
|
for (var file in files) {
|
||||||
@@ -112,10 +112,7 @@ Future<Isolate> _runIsolate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createPdfFromComicIsolate({
|
Future<File> createPdfFromComicIsolate(LocalComic comic, String savePath) async {
|
||||||
required LocalComic comic,
|
|
||||||
required String savePath,
|
|
||||||
}) async {
|
|
||||||
var receivePort = ReceivePort();
|
var receivePort = ReceivePort();
|
||||||
SendPort? sendPort;
|
SendPort? sendPort;
|
||||||
Isolate? isolate;
|
Isolate? isolate;
|
||||||
@@ -134,7 +131,8 @@ Future<void> createPdfFromComicIsolate({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
isolate = await _runIsolate(comic, savePath, receivePort.sendPort);
|
isolate = await _runIsolate(comic, savePath, receivePort.sendPort);
|
||||||
return completer.future;
|
await completer.future;
|
||||||
|
return File(savePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
class PdfGenerator {
|
class PdfGenerator {
|
||||||
|
Reference in New Issue
Block a user