fix & improve importing comic

This commit is contained in:
2024-11-19 18:44:52 +08:00
parent 8402c1c9f3
commit 6aeaeadb10
10 changed files with 557 additions and 450 deletions

View File

@@ -8,7 +8,6 @@ import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.DocumentsContract
import android.provider.Settings import android.provider.Settings
import android.view.KeyEvent import android.view.KeyEvent
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
@@ -96,11 +95,7 @@ class MainActivity : FlutterFragmentActivity() {
if (pickedDirectoryUri == null) if (pickedDirectoryUri == null)
res.success(null) res.success(null)
else else
try { onPickedDirectory(pickedDirectoryUri, res)
res.success(onPickedDirectory(pickedDirectoryUri))
} catch (e: Exception) {
res.error("Failed to Copy Files", e.toString(), null)
}
} }
} }
@@ -134,8 +129,9 @@ class MainActivity : FlutterFragmentActivity() {
} }
val selectFileChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/select_file") val selectFileChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/select_file")
selectFileChannel.setMethodCallHandler { _, res -> selectFileChannel.setMethodCallHandler { req, res ->
openFile(res) val mimeType = req.arguments<String>()
openFile(res, mimeType!!)
} }
} }
@@ -166,26 +162,40 @@ class MainActivity : FlutterFragmentActivity() {
return super.onKeyDown(keyCode, event) return super.onKeyDown(keyCode, event)
} }
/// copy the directory to tmp directory, return copied directory /// Ensure that the directory is accessible by dart:io
private fun onPickedDirectory(uri: Uri): String { private fun onPickedDirectory(uri: Uri, result: MethodChannel.Result) {
if (!hasStoragePermission()) { if (hasStoragePermission()) {
// dart:io cannot access the directory without permission. var plain = uri.toString()
// so we need to copy the directory to cache directory if(plain.contains("%3A")) {
val contentResolver = contentResolver plain = Uri.decode(plain)
var tmp = cacheDir }
tmp = File(tmp, "getDirectoryPathTemp") val externalStoragePrefix = "content://com.android.externalstorage.documents/tree/primary:";
tmp.mkdir() if(plain.startsWith(externalStoragePrefix)) {
Thread { val path = plain.substring(externalStoragePrefix.length)
copyDirectory(contentResolver, uri, tmp) result.success(Environment.getExternalStorageDirectory().absolutePath + "/" + path)
}.start() }
// The uri cannot be parsed to plain path, use copy method
return tmp.absolutePath
} else {
val docId = DocumentsContract.getTreeDocumentId(uri)
val split: Array<String?> = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
return if ((split.size >= 2) && (split[1] != null)) split[1]!!
else File.separator
} }
// dart:io cannot access the directory without permission.
// so we need to copy the directory to cache directory
val contentResolver = contentResolver
var tmp = cacheDir
var dirName = DocumentFile.fromTreeUri(this, uri)?.name
tmp = File(tmp, dirName!!)
if(tmp.exists()) {
tmp.deleteRecursively()
}
tmp.mkdir()
Thread {
try {
copyDirectory(contentResolver, uri, tmp)
result.success(tmp.absolutePath)
}
catch (e: Exception) {
result.error("copy error", e.message, null)
}
}.start()
} }
private fun copyDirectory(resolver: ContentResolver, srcUri: Uri, destDir: File) { private fun copyDirectory(resolver: ContentResolver, srcUri: Uri, destDir: File) {
@@ -197,11 +207,12 @@ class MainActivity : FlutterFragmentActivity() {
copyDirectory(resolver, file.uri, newDir) copyDirectory(resolver, file.uri, newDir)
} else { } else {
val newFile = File(destDir, file.name!!) val newFile = File(destDir, file.name!!)
val inputStream = resolver.openInputStream(file.uri) ?: return resolver.openInputStream(file.uri)?.use { input ->
val outputStream = FileOutputStream(newFile) FileOutputStream(newFile).use { output ->
inputStream.copyTo(outputStream) input.copyTo(output, bufferSize = DEFAULT_BUFFER_SIZE)
inputStream.close() output.flush()
outputStream.close() }
}
} }
} }
} }
@@ -277,10 +288,10 @@ class MainActivity : FlutterFragmentActivity() {
} }
} }
private fun openFile(result: MethodChannel.Result) { private fun openFile(result: MethodChannel.Result, mimeType: String) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE) intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*" intent.type = mimeType
startContractForResult(ActivityResultContracts.StartActivityForResult(), intent){ activityResult -> startContractForResult(ActivityResultContracts.StartActivityForResult(), intent){ activityResult ->
if (activityResult.resultCode != Activity.RESULT_OK) { if (activityResult.resultCode != Activity.RESULT_OK) {
result.success(null) result.success(null)
@@ -312,20 +323,9 @@ class MainActivity : FlutterFragmentActivity() {
// ignore // ignore
} }
} }
// copy file to cache directory // use copy method
val cacheDir = cacheDir val filePath = FileUtils.getPathFromCopyOfFileFromUri(this, uri)
val newFile = File(cacheDir, fileName) result.success(filePath)
val inputStream = contentResolver.openInputStream(uri)
if (inputStream == null) {
result.success(null)
return@startContractForResult
}
val outputStream = FileOutputStream(newFile)
inputStream.copyTo(outputStream)
inputStream.close()
outputStream.close()
// send file path to flutter
result.success(newFile.absolutePath)
} }
} }
} }

View File

@@ -231,7 +231,8 @@
"Please check your settings": "请检查您的设置", "Please check your settings": "请检查您的设置",
"No Category Pages": "没有分类页面", "No Category Pages": "没有分类页面",
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 页" "Page @page": "第 @page 页",
"Also remove files on disk": "同时删除磁盘上的文件"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -465,6 +466,7 @@
"Please check your settings": "請檢查您的設定", "Please check your settings": "請檢查您的設定",
"No Category Pages": "沒有分類頁面", "No Category Pages": "沒有分類頁面",
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 頁" "Page @page": "第 @page 頁",
"Also remove files on disk": "同時刪除磁盤上的文件"
} }
} }

View File

@@ -453,7 +453,6 @@ class LocalManager with ChangeNotifier {
if(HistoryManager().findSync(c.id, c.comicType) != null) { if(HistoryManager().findSync(c.id, c.comicType) != null) {
HistoryManager().remove(c.id, c.comicType); HistoryManager().remove(c.id, c.comicType);
} }
assert(c.comicType == ComicType.local);
var folders = LocalFavoritesManager().find(c.id, c.comicType); var folders = LocalFavoritesManager().find(c.id, c.comicType);
for (var f in folders) { for (var f in folders) {
LocalFavoritesManager().deleteComicWithId(f, c.id, c.comicType); LocalFavoritesManager().deleteComicWithId(f, c.id, c.comicType);

View File

@@ -10,6 +10,7 @@ import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/main_page.dart'; import 'package:venera/pages/main_page.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/app_links.dart'; import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/io.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'components/components.dart'; import 'components/components.dart';
import 'components/window_frame.dart'; import 'components/window_frame.dart';
@@ -81,7 +82,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if(!App.isMobile) { if (!App.isMobile) {
return; return;
} }
if (state == AppLifecycleState.inactive && hideContentOverlay == null) { if (state == AppLifecycleState.inactive && hideContentOverlay == null) {
@@ -104,7 +105,8 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
} }
if (state == AppLifecycleState.hidden && if (state == AppLifecycleState.hidden &&
appdata.settings['authorizationRequired'] && appdata.settings['authorizationRequired'] &&
!isAuthPageActive) { !isAuthPageActive &&
!IO.isSelectingFiles) {
isAuthPageActive = true; isAuthPageActive = true;
App.rootContext.to( App.rootContext.to(
() => AuthPage( () => AuthPage(

View File

@@ -4,26 +4,21 @@ import 'package:sliver_tools/sliver_tools.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/accounts_page.dart'; import 'package:venera/pages/accounts_page.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/history_page.dart'; import 'package:venera/pages/history_page.dart';
import 'package:venera/pages/search_page.dart'; import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/cbz.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/import_comic.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'package:sqlite3/sqlite3.dart' as sql;
import 'dart:math';
import 'local_comics_page.dart'; import 'local_comics_page.dart';
@@ -624,323 +619,26 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
} }
void selectAndImport() async { 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);
var comic = await CBZ.import(File(cache));
if (selectedFolder != null) {
LocalFavoritesManager().addComic(
selectedFolder!,
FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subtitle,
type: comic.comicType,
tags: comic.tags,
));
}
await File(cache).deleteIgnoreError();
} catch (e, s) {
Log.error("Import Comic", e.toString(), s);
context.showMessage(message: e.toString());
}
controller.close();
return;
} else if (type == 3) {
var dbFile = await selectFile(ext: ['db']);
final picker = DirectoryPicker();
final comicSrc = await picker.pickDirectory();
if (dbFile == null || comicSrc == null) {
return;
}
bool cancelled = false;
var controller = showLoadingDialog(context, onCancel: () { cancelled = true; });
try {
var cache = FilePath.join(App.cachePath, dbFile.name);
await dbFile.saveTo(cache);
var db = sql.sqlite3.open(cache);
Future<void> addTagComics(String destFolder, List<sql.Row> comics) async {
for(var comic in comics) {
if(cancelled) {
return;
}
var comicDir = Directory(FilePath.join(comicSrc.path, comic['DIRNAME'] as String));
if(!(await comicDir.exists())) {
continue;
}
String titleJP = comic['TITLE_JPN'] == null ? "" : comic['TITLE_JPN'] as String;
String title = titleJP == "" ? comic['TITLE'] as String : titleJP;
if (LocalManager().findByName(title) != null) {
Log.info("Import Comic", "Comic already exists: $title");
continue;
}
String coverURL = await comicDir.joinFile(".thumb").exists() ?
comicDir.joinFile(".thumb").path :
(comic['THUMB'] as String).replaceAll('s.exhentai.org', 'ehgt.org');
int downloadedTimeStamp = comic['TIME'] as int;
DateTime downloadedTime =
downloadedTimeStamp != 0 ?
DateTime.fromMillisecondsSinceEpoch(downloadedTimeStamp) : DateTime.now();
var comicObj = LocalComic(
id: LocalManager().findValidId(ComicType.local),
title: title,
subtitle: '',
tags: [
//1 >> x
[
"MISC",
"DOUJINSHI",
"MANGA",
"ARTISTCG",
"GAMECG",
"IMAGE SET",
"COSPLAY",
"ASIAN PORN",
"NON-H",
"WESTERN",
][(log(comic['CATEGORY'] as int) / ln2).floor()]
],
directory: comicDir.path,
chapters: null,
cover: coverURL,
comicType: ComicType.local,
downloadedChapters: [],
createdAt: downloadedTime,
);
LocalManager().add(comicObj, comicObj.id);
LocalFavoritesManager().addComic(
destFolder,
FavoriteItem(
id: comicObj.id,
name: comicObj.title,
coverPath: comicObj.cover,
author: comicObj.subtitle,
type: comicObj.comicType,
tags: comicObj.tags,
favoriteTime: downloadedTime
),
);
}
}
//default folder
{
var defaultFolderName = '(EhViewer)Default'.tl;
if(!LocalFavoritesManager().existsFolder(defaultFolderName)) {
LocalFavoritesManager().createFolder(defaultFolderName);
}
var comicList = db.select("""
SELECT *
FROM DOWNLOAD_DIRNAME DN
LEFT JOIN DOWNLOADS DL
ON DL.GID = DN.GID
WHERE DL.LABEL IS NULL AND DL.STATE = 3
ORDER BY DL.TIME DESC
""").toList();
await addTagComics(defaultFolderName, comicList);
}
var folders = db.select("""
SELECT * FROM DOWNLOAD_LABELS;
""");
for (var folder in folders) {
if(cancelled) {
break;
}
var label = folder["LABEL"] as String;
var folderName = '(EhViewer)$label';
if(!LocalFavoritesManager().existsFolder(folderName)) {
LocalFavoritesManager().createFolder(folderName);
}
var comicList = db.select("""
SELECT *
FROM DOWNLOAD_DIRNAME DN
LEFT JOIN DOWNLOADS DL
ON DL.GID = DN.GID
WHERE DL.LABEL = ? AND DL.STATE = 3
ORDER BY DL.TIME DESC
""", [label]).toList();
await addTagComics(folderName, comicList);
}
db.dispose();
await File(cache).deleteIgnoreError();
} catch (e, s) {
Log.error("Import Comic", e.toString(), s);
context.showMessage(message: e.toString());
}
controller.close();
return;
}
height = key.currentContext!.size!.height; height = key.currentContext!.size!.height;
setState(() { setState(() {
loading = true; loading = true;
}); });
final picker = DirectoryPicker(); var importer = ImportComic(selectedFolder: selectedFolder);
final path = await picker.pickDirectory(); var result = false;
if (!loading) { if (type == 2) {
picker.dispose(); result = await importer.cbz();
return; } else if (type == 3) {
result = await importer.ehViewer();
} else {
result = await importer.directory(type == 0);
} }
if (path == null) { if(result) {
context.pop();
} else {
setState(() { setState(() {
loading = false; loading = false;
}); });
return;
} }
Map<Directory, LocalComic> comics = {};
if (type == 0) {
var result = await checkSingleComic(path);
if (result != null) {
comics[path] = result;
} else {
context.showMessage(message: "Invalid Comic".tl);
setState(() {
loading = false;
});
return;
}
} else {
await for (var entry in path.list()) {
if (entry is Directory) {
var result = await checkSingleComic(entry);
if (result != null) {
comics[entry] = result;
}
}
}
}
bool shouldCopy = true;
for (var comic in comics.keys) {
if (comic.parent.path == LocalManager().path) {
shouldCopy = false;
break;
}
}
if (shouldCopy && comics.isNotEmpty) {
try {
// copy the comics to the local directory
await compute<Map<String, dynamic>, void>(_copyDirectories, {
'toBeCopied': comics.keys.map((e) => e.path).toList(),
'destination': LocalManager().path,
});
} catch (e) {
context.showMessage(message: "Failed to import comics".tl);
Log.error("Import Comic", e.toString());
setState(() {
loading = false;
});
return;
}
}
for (var comic in comics.values) {
LocalManager().add(comic, LocalManager().findValidId(ComicType.local));
if (selectedFolder != null) {
LocalFavoritesManager().addComic(
selectedFolder!,
FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subtitle,
type: comic.comicType,
tags: comic.tags,
));
}
}
context.pop();
context.showMessage(
message: "Imported @a comics".tlParams({
'a': comics.length,
}));
}
static _copyDirectories(Map<String, dynamic> data) {
var toBeCopied = data['toBeCopied'] as List<String>;
var destination = data['destination'] as String;
for (var dir in toBeCopied) {
var source = Directory(dir);
var dest = Directory("$destination/${source.name}");
if (dest.existsSync()) {
// The destination directory already exists, and it is not managed by the app.
// Rename the old directory to avoid conflicts.
Log.info("Import Comic",
"Directory already exists: ${source.name}\nRenaming the old directory.");
dest.rename(
findValidDirectoryName(dest.parent.path, "${dest.path}_old"));
}
dest.createSync();
copyDirectory(source, dest);
}
}
Future<LocalComic?> 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 = <String>[];
var coverPath = ''; // relative path to the cover image
await for (var entry in directory.list()) {
if (entry is Directory) {
hasChapters = true;
chapters.add(entry.name);
await for (var file in entry.list()) {
if (file is Directory) {
Log.info("Import Comic",
"Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory.");
return null;
}
}
} else if (entry is File) {
if (entry.name.startsWith('cover')) {
coverPath = entry.name;
}
const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'];
if (!coverPath.startsWith('cover') &&
imageExtensions.contains(entry.extension)) {
coverPath = entry.name;
}
}
}
chapters.sort();
if (hasChapters && coverPath == '') {
// use the first image in the first chapter as the cover
var firstChapter = Directory('${directory.path}/${chapters.first}');
await for (var entry in firstChapter.list()) {
if (entry is File) {
coverPath = entry.name;
break;
}
}
}
if (coverPath == '') {
Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found.");
return null;
}
return LocalComic(
id: '0',
title: name,
subtitle: '',
tags: [],
directory: directory.name,
chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null,
cover: coverPath,
comicType: ComicType.local,
downloadedChapters: chapters,
createdAt: DateTime.now(),
);
} }
} }

View File

@@ -298,24 +298,16 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
return StatefulBuilder(builder: (context, state) { return StatefulBuilder(builder: (context, state) {
return ContentDialog( return ContentDialog(
title: "Delete".tl, title: "Delete".tl,
content: Column( content: CheckboxListTile(
children: [ title:
Text("Delete selected comics?".tl) Text("Also remove files on disk".tl),
.paddingVertical(8), value: removeComicFile,
Transform.scale( onChanged: (v) {
scale: 0.9, state(() {
child: CheckboxListTile( removeComicFile = !removeComicFile;
title: Text( });
"Also remove files on disk".tl), },
value: removeComicFile, ),
onChanged: (v) {
state(() {
removeComicFile =
!removeComicFile;
});
})),
],
).paddingHorizontal(16).paddingVertical(8),
actions: [ actions: [
FilledButton( FilledButton(
onPressed: () { onPressed: () {
@@ -379,12 +371,12 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
return PopScope( return PopScope(
canPop: !multiSelectMode && !searchMode, canPop: !multiSelectMode && !searchMode,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if(multiSelectMode) { if (multiSelectMode) {
setState(() { setState(() {
multiSelectMode = false; multiSelectMode = false;
selectedComics.clear(); selectedComics.clear();
}); });
} else if(searchMode) { } else if (searchMode) {
setState(() { setState(() {
searchMode = false; searchMode = false;
keyword = ""; keyword = "";

View File

@@ -86,6 +86,9 @@ abstract class CBZ {
var ext = e.path.split('.').last; var ext = e.path.split('.').last;
return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext); return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext);
}); });
if(files.isEmpty) {
throw Exception('No images found in the archive');
}
files.sort((a, b) => a.path.compareTo(b.path)); files.sort((a, b) => a.path.compareTo(b.path));
var coverFile = files.firstWhereOrNull( var coverFile = files.firstWhereOrNull(
(element) => (element) =>

View File

@@ -10,6 +10,9 @@ class FileType {
if(ext.startsWith('.')) { if(ext.startsWith('.')) {
ext = ext.substring(1); ext = ext.substring(1);
} }
if(ext == 'cbz') {
return const FileType('.cbz', 'application/octet-stream');
}
var mime = lookupMimeType('no-file.$ext'); var mime = lookupMimeType('no-file.$ext');
return FileType(".$ext", mime ?? 'application/octet-stream'); return FileType(".$ext", mime ?? 'application/octet-stream');
} }

338
lib/utils/import_comic.dart Normal file
View File

@@ -0,0 +1,338 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:sqlite3/sqlite3.dart' as sql;
import 'package:venera/utils/translations.dart';
import 'cbz.dart';
import 'io.dart';
class ImportComic {
final String? selectedFolder;
const ImportComic({this.selectedFolder});
Future<bool> cbz() async {
var xFile = await selectFile(ext: ['cbz']);
if(xFile == null) {
return false;
}
var controller = showLoadingDialog(App.rootContext, allowCancel: false);
var isSuccessful = false;
try {
var cache = FilePath.join(App.cachePath, xFile.name);
await xFile.saveTo(cache);
var comic = await CBZ.import(File(cache));
if (selectedFolder != null) {
LocalFavoritesManager().addComic(
selectedFolder!,
FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subtitle,
type: comic.comicType,
tags: comic.tags,
),
);
}
await File(cache).deleteIgnoreError();
isSuccessful = true;
} catch (e, s) {
Log.error("Import Comic", e.toString(), s);
App.rootContext.showMessage(message: e.toString());
}
controller.close();
return isSuccessful;
}
Future<bool> ehViewer() async {
var dbFile = await selectFile(ext: ['db']);
final picker = DirectoryPicker();
final comicSrc = await picker.pickDirectory();
if (dbFile == null || comicSrc == null) {
return false;
}
bool cancelled = false;
var controller = showLoadingDialog(App.rootContext, onCancel: () {
cancelled = true;
});
bool isSuccessful = false;
try {
var cache = FilePath.join(App.cachePath, dbFile.name);
await dbFile.saveTo(cache);
var db = sql.sqlite3.open(cache);
Future<void> addTagComics(String destFolder, List<sql.Row> comics) async {
for (var comic in comics) {
if (cancelled) {
return;
}
var comicDir = Directory(
FilePath.join(comicSrc.path, comic['DIRNAME'] as String));
if (!(await comicDir.exists())) {
continue;
}
String titleJP =
comic['TITLE_JPN'] == null ? "" : comic['TITLE_JPN'] as String;
String title = titleJP == "" ? comic['TITLE'] as String : titleJP;
if (LocalManager().findByName(title) != null) {
Log.info("Import Comic", "Comic already exists: $title");
continue;
}
String coverURL = await comicDir.joinFile(".thumb").exists()
? comicDir.joinFile(".thumb").path
: (comic['THUMB'] as String)
.replaceAll('s.exhentai.org', 'ehgt.org');
int downloadedTimeStamp = comic['TIME'] as int;
DateTime downloadedTime = downloadedTimeStamp != 0
? DateTime.fromMillisecondsSinceEpoch(downloadedTimeStamp)
: DateTime.now();
var comicObj = LocalComic(
id: LocalManager().findValidId(ComicType.local),
title: title,
subtitle: '',
tags: [
//1 >> x
[
"MISC",
"DOUJINSHI",
"MANGA",
"ARTISTCG",
"GAMECG",
"IMAGE SET",
"COSPLAY",
"ASIAN PORN",
"NON-H",
"WESTERN",
][(log(comic['CATEGORY'] as int) / ln2).floor()]
],
directory: comicDir.path,
chapters: null,
cover: coverURL,
comicType: ComicType.local,
downloadedChapters: [],
createdAt: downloadedTime,
);
LocalManager().add(comicObj, comicObj.id);
LocalFavoritesManager().addComic(
destFolder,
FavoriteItem(
id: comicObj.id,
name: comicObj.title,
coverPath: comicObj.cover,
author: comicObj.subtitle,
type: comicObj.comicType,
tags: comicObj.tags,
favoriteTime: downloadedTime,
),
);
}
}
{
var defaultFolderName = '(EhViewer)Default';
if (!LocalFavoritesManager().existsFolder(defaultFolderName)) {
LocalFavoritesManager().createFolder(defaultFolderName);
}
var comicList = db.select("""
SELECT *
FROM DOWNLOAD_DIRNAME DN
LEFT JOIN DOWNLOADS DL
ON DL.GID = DN.GID
WHERE DL.LABEL IS NULL AND DL.STATE = 3
ORDER BY DL.TIME DESC
""").toList();
await addTagComics(defaultFolderName, comicList);
}
var folders = db.select("""
SELECT * FROM DOWNLOAD_LABELS;
""");
for (var folder in folders) {
if (cancelled) {
break;
}
var label = folder["LABEL"] as String;
var folderName = '(EhViewer)$label';
if (!LocalFavoritesManager().existsFolder(folderName)) {
LocalFavoritesManager().createFolder(folderName);
}
var comicList = db.select("""
SELECT *
FROM DOWNLOAD_DIRNAME DN
LEFT JOIN DOWNLOADS DL
ON DL.GID = DN.GID
WHERE DL.LABEL = ? AND DL.STATE = 3
ORDER BY DL.TIME DESC
""", [label]).toList();
await addTagComics(folderName, comicList);
}
db.dispose();
await File(cache).deleteIgnoreError();
isSuccessful = true;
} catch (e, s) {
Log.error("Import Comic", e.toString(), s);
App.rootContext.showMessage(message: e.toString());
}
controller.close();
return isSuccessful;
}
Future<bool> directory(bool single) async {
final picker = DirectoryPicker();
final path = await picker.pickDirectory();
if (path == null) {
return false;
}
Map<Directory, LocalComic> comics = {};
if (single) {
var result = await _checkSingleComic(path);
if (result != null) {
comics[path] = result;
} else {
App.rootContext.showMessage(message: "Invalid Comic".tl);
return false;
}
} else {
await for (var entry in path.list()) {
if (entry is Directory) {
var result = await _checkSingleComic(entry);
if (result != null) {
comics[entry] = result;
}
}
}
}
bool shouldCopy = true;
for (var comic in comics.keys) {
if (comic.parent.path == LocalManager().path) {
shouldCopy = false;
break;
}
}
if (shouldCopy && comics.isNotEmpty) {
try {
// copy the comics to the local directory
await compute<Map<String, dynamic>, void>(_copyDirectories, {
'toBeCopied': comics.keys.map((e) => e.path).toList(),
'destination': LocalManager().path,
});
} catch (e) {
App.rootContext.showMessage(message: "Failed to import comics".tl);
Log.error("Import Comic", e.toString());
return false;
}
}
for (var comic in comics.values) {
LocalManager().add(comic, LocalManager().findValidId(ComicType.local));
if (selectedFolder != null) {
LocalFavoritesManager().addComic(
selectedFolder!,
FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subtitle,
type: comic.comicType,
tags: comic.tags,
),
);
}
}
App.rootContext.showMessage(
message: "Imported @a comics".tlParams({
'a': comics.length,
}));
return true;
}
Future<LocalComic?> _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 = <String>[];
var coverPath = ''; // relative path to the cover image
for (var entry in directory.listSync()) {
if (entry is Directory) {
hasChapters = true;
chapters.add(entry.name);
await for (var file in entry.list()) {
if (file is Directory) {
Log.info("Import Comic",
"Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory.");
return null;
}
}
} else if (entry is File) {
if (entry.name.startsWith('cover')) {
coverPath = entry.name;
}
const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'];
if (!coverPath.startsWith('cover') &&
imageExtensions.contains(entry.extension)) {
coverPath = entry.name;
}
}
}
chapters.sort();
if (hasChapters && coverPath == '') {
// use the first image in the first chapter as the cover
var firstChapter = Directory('${directory.path}/${chapters.first}');
await for (var entry in firstChapter.list()) {
if (entry is File) {
coverPath = entry.name;
break;
}
}
}
if (coverPath == '') {
Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found.");
return null;
}
return LocalComic(
id: '0',
title: name,
subtitle: '',
tags: [],
directory: directory.name,
chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null,
cover: coverPath,
comicType: ComicType.local,
downloadedChapters: chapters,
createdAt: DateTime.now(),
);
}
static _copyDirectories(Map<String, dynamic> data) {
var toBeCopied = data['toBeCopied'] as List<String>;
var destination = data['destination'] as String;
for (var dir in toBeCopied) {
var source = Directory(dir);
var dest = Directory("$destination/${source.name}");
if (dest.existsSync()) {
// The destination directory already exists, and it is not managed by the app.
// Rename the old directory to avoid conflicts.
Log.info("Import Comic",
"Directory already exists: ${source.name}\nRenaming the old directory.");
dest.rename(
findValidDirectoryName(dest.parent.path, "${dest.path}_old"));
}
dest.createSync();
copyDirectory(source, dest);
}
}
}

View File

@@ -14,6 +14,21 @@ import 'package:venera/utils/file_type.dart';
export 'dart:io'; export 'dart:io';
export 'dart:typed_data'; export 'dart:typed_data';
class IO {
/// A global flag used to indicate whether the app is selecting files.
///
/// Select file and other similar file operations will launch external programs,
/// causing the app to lose focus. AppLifecycleState will be set to paused.
static bool get isSelectingFiles => _isSelectingFiles;
static bool _isSelectingFiles = false;
}
/// A finalizer that can be used to dispose resources related to file operations.
final _finalizer = Finalizer<Function>((e) {
e();
});
class FilePath { class FilePath {
const FilePath._(); const FilePath._();
@@ -147,24 +162,35 @@ String findValidDirectoryName(String path, String directory) {
} }
class DirectoryPicker { class DirectoryPicker {
DirectoryPicker() {
_finalizer.attach(this, dispose);
}
String? _directory; String? _directory;
final _methodChannel = const MethodChannel("venera/method_channel"); final _methodChannel = const MethodChannel("venera/method_channel");
Future<Directory?> pickDirectory() async { Future<Directory?> pickDirectory() async {
if (App.isWindows || App.isLinux) { IO._isSelectingFiles = true;
var d = await file_selector.getDirectoryPath(); try {
_directory = d; if (App.isWindows || App.isLinux) {
return d == null ? null : Directory(d); var d = await file_selector.getDirectoryPath();
} else if (App.isAndroid) { _directory = d;
var d = await _methodChannel.invokeMethod<String?>("getDirectoryPath"); return d == null ? null : Directory(d);
_directory = d; } else if (App.isAndroid) {
return d == null ? null : Directory(d); var d = await _methodChannel.invokeMethod<String?>("getDirectoryPath");
} else { _directory = d;
// ios, macos return d == null ? null : Directory(d);
var d = await _methodChannel.invokeMethod<String?>("getDirectoryPath"); } else {
_directory = d; // ios, macos
return d == null ? null : Directory(d); var d = await _methodChannel.invokeMethod<String?>("getDirectoryPath");
_directory = d;
return d == null ? null : Directory(d);
}
} finally {
Future.delayed(const Duration(milliseconds: 100), () {
IO._isSelectingFiles = false;
});
} }
} }
@@ -172,11 +198,15 @@ class DirectoryPicker {
if (_directory == null) { if (_directory == null) {
return; return;
} }
if (App.isAndroid && _directory != null) { if (App.isAndroid &&
return Directory(_directory!).deleteIgnoreError(recursive: true); _directory != null &&
_directory!.startsWith(App.cachePath)) {
await Directory(_directory!).deleteIgnoreError(recursive: true);
_directory = null;
} }
if (App.isIOS || App.isMacOS) { if (App.isIOS || App.isMacOS) {
await _methodChannel.invokeMethod("stopAccessingSecurityScopedResource"); await _methodChannel.invokeMethod("stopAccessingSecurityScopedResource");
_directory = null;
} }
} }
} }
@@ -186,53 +216,73 @@ class IOSDirectoryPicker {
// 调用 iOS 目录选择方法 // 调用 iOS 目录选择方法
static Future<String?> selectDirectory() async { static Future<String?> selectDirectory() async {
IO._isSelectingFiles = true;
try { try {
final String? path = await _channel.invokeMethod('selectDirectory'); final String? path = await _channel.invokeMethod('selectDirectory');
return path; return path;
} catch (e) { } catch (e) {
// 返回报错信息 // 返回报错信息
return e.toString(); return e.toString();
} finally {
Future.delayed(const Duration(milliseconds: 100), () {
IO._isSelectingFiles = false;
});
} }
} }
} }
Future<file_selector.XFile?> selectFile({required List<String> ext}) async { Future<file_selector.XFile?> selectFile({required List<String> ext}) async {
var extensions = App.isMacOS || App.isIOS ? null : ext; IO._isSelectingFiles = true;
if (App.isAndroid) { try {
for (var e in ext) { var extensions = App.isMacOS || App.isIOS ? null : ext;
var fileType = FileType.fromExtension(e); file_selector.XTypeGroup typeGroup = file_selector.XTypeGroup(
if (fileType.mime == "application/octet-stream") { label: 'files',
extensions = null; extensions: extensions,
break;
}
}
}
file_selector.XTypeGroup typeGroup = file_selector.XTypeGroup(
label: 'files',
extensions: extensions,
);
file_selector.XFile? file;
if (extensions == null && App.isAndroid) {
const selectFileChannel = MethodChannel("venera/select_file");
var filePath = await selectFileChannel.invokeMethod("selectFile");
if (filePath == null) return null;
file = file_selector.XFile(filePath);
} else {
file = await file_selector.openFile(
acceptedTypeGroups: <file_selector.XTypeGroup>[typeGroup],
); );
if (file == null) return null; file_selector.XFile? file;
if (App.isAndroid) {
const selectFileChannel = MethodChannel("venera/select_file");
String mimeType = "*/*";
if(ext.length == 1) {
mimeType = FileType.fromExtension(ext[0]).mime;
if(mimeType == "application/octet-stream") {
mimeType = "*/*";
}
}
var filePath = await selectFileChannel.invokeMethod(
"selectFile",
mimeType,
);
if (filePath == null) return null;
file = _AndroidFileSelectResult(filePath);
} else {
file = await file_selector.openFile(
acceptedTypeGroups: <file_selector.XTypeGroup>[typeGroup],
);
if (file == null) return null;
}
if (!ext.contains(file.path.split(".").last)) {
App.rootContext.showMessage(message: "Invalid file type");
return null;
}
return file;
} finally {
Future.delayed(const Duration(milliseconds: 100), () {
IO._isSelectingFiles = false;
});
} }
if (!ext.contains(file.path.split(".").last)) {
App.rootContext.showMessage(message: "Invalid file type");
return null;
}
return file;
} }
Future<String?> selectDirectory() async { Future<String?> selectDirectory() async {
var path = await file_selector.getDirectoryPath(); IO._isSelectingFiles = true;
return path; try {
var path = await file_selector.getDirectoryPath();
return path;
} finally {
Future.delayed(const Duration(milliseconds: 100), () {
IO._isSelectingFiles = false;
});
}
} }
// selectDirectoryIOS // selectDirectoryIOS
@@ -245,25 +295,32 @@ Future<void> saveFile(
if (data == null && file == null) { if (data == null && file == null) {
throw Exception("data and file cannot be null at the same time"); throw Exception("data and file cannot be null at the same time");
} }
if (data != null) { IO._isSelectingFiles = true;
var cache = FilePath.join(App.cachePath, filename); try {
if (File(cache).existsSync()) { if (data != null) {
File(cache).deleteSync(); var cache = FilePath.join(App.cachePath, filename);
if (File(cache).existsSync()) {
File(cache).deleteSync();
}
await File(cache).writeAsBytes(data);
file = File(cache);
} }
await File(cache).writeAsBytes(data); if (App.isMobile) {
file = File(cache); final params = SaveFileDialogParams(sourceFilePath: file!.path);
} await FlutterFileDialog.saveFile(params: params);
if (App.isMobile) { } else {
final params = SaveFileDialogParams(sourceFilePath: file!.path); final result = await file_selector.getSaveLocation(
await FlutterFileDialog.saveFile(params: params); suggestedName: filename,
} else { );
final result = await file_selector.getSaveLocation( if (result != null) {
suggestedName: filename, var xFile = file_selector.XFile(file!.path);
); await xFile.saveTo(result.path);
if (result != null) { }
var xFile = file_selector.XFile(file!.path);
await xFile.saveTo(result.path);
} }
} finally {
Future.delayed(const Duration(milliseconds: 100), () {
IO._isSelectingFiles = false;
});
} }
} }
@@ -302,3 +359,16 @@ String bytesToReadableString(int bytes) {
return "${(bytes / 1024 / 1024 / 1024).toStringAsFixed(2)} GB"; return "${(bytes / 1024 / 1024 / 1024).toStringAsFixed(2)} GB";
} }
} }
class _AndroidFileSelectResult extends s.XFile {
_AndroidFileSelectResult(super.path) {
_finalizer.attach(this, dispose);
}
void dispose() {
print("dispose $path");
if (path.startsWith(App.cachePath)) {
File(path).deleteIgnoreError();
}
}
}