import 'dart:io'; import 'package:anx_reader/dao/book.dart'; import 'package:anx_reader/dao/theme.dart'; import 'package:anx_reader/enums/sync_direction.dart'; import 'package:anx_reader/enums/sync_trigger.dart'; import 'package:anx_reader/l10n/generated/L10n.dart'; import 'package:anx_reader/main.dart'; import 'package:anx_reader/models/book.dart'; import 'package:anx_reader/models/current_reading_state.dart'; import 'package:anx_reader/page/home_page.dart'; import 'package:anx_reader/page/iap_page.dart'; import 'package:anx_reader/providers/ai_chat.dart'; import 'package:anx_reader/providers/chapter_content_bridge.dart'; import 'package:anx_reader/providers/current_reading.dart'; import 'package:anx_reader/providers/sync.dart'; import 'package:anx_reader/providers/iap.dart'; import 'package:anx_reader/providers/book_list.dart'; import 'package:anx_reader/providers/toc_search.dart'; import 'package:anx_reader/service/convert_to_epub/txt/convert_from_txt.dart'; import 'package:anx_reader/service/md5_service.dart'; import 'package:anx_reader/utils/webView/anx_headless_webview.dart'; import 'package:anx_reader/utils/env_var.dart'; import 'package:anx_reader/utils/get_path/get_base_path.dart'; import 'package:anx_reader/page/reading_page.dart'; import 'package:anx_reader/utils/import_book.dart'; import 'package:anx_reader/utils/log/common.dart'; import 'package:anx_reader/utils/toast/common.dart'; import 'package:anx_reader/utils/webView/gererate_url.dart'; import 'package:anx_reader/utils/webView/webview_console_message.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as path; import 'book_player/book_player_server.dart'; AnxHeadlessWebView? headlessInAppWebView; final allowBookExtensions = ["epub", "mobi", "azw3", "fb2", "txt", "pdf"]; /// import book list and **delete file** void importBookList(List fileList, BuildContext context, WidgetRef ref) { AnxLog.info('importBook fileList: ${fileList.toString()}'); List supportedFiles = fileList.where((file) { return allowBookExtensions .contains(file.path.split('.').last.toLowerCase()); }).toList(); List unsupportedFiles = fileList.where((file) { return !allowBookExtensions .contains(file.path.split('.').last.toLowerCase()); }).toList(); _checkDuplicatesAndShowDialog( supportedFiles, unsupportedFiles, fileList, context, ref, ); } void _checkDuplicatesAndShowDialog( List supportedFiles, List unsupportedFiles, List fileList, BuildContext context, WidgetRef ref) async { showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: Text(L10n.of(context).md5Calculating), content: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 16), Text(L10n.of(context).md5Calculating), ], ), ), ); try { final filePaths = supportedFiles.map((f) => f.path).toList(); final checkResults = await MD5Service.checkImportFiles(filePaths); Navigator.of(context).pop(); List duplicateFiles = []; List uniqueFiles = []; Map duplicateInfo = {}; for (int i = 0; i < supportedFiles.length; i++) { final file = supportedFiles[i]; final result = checkResults[i]; if (result.isDuplicate && result.duplicateBook != null) { duplicateFiles.add(file); duplicateInfo[file.path] = result.duplicateBook!; } else { uniqueFiles.add(file); } } _showImportDialog( uniqueFiles, duplicateFiles, duplicateInfo, unsupportedFiles, fileList, ref, ); } catch (e) { Navigator.of(navigatorKey.currentContext!).pop(); AnxLog.severe('MD5 check failed: $e'); _showImportDialog( supportedFiles, [], {}, unsupportedFiles, fileList, ref, ); } } void _showImportDialog( List uniqueFiles, List duplicateFiles, Map duplicateInfo, List unsupportedFiles, List fileList, WidgetRef ref, ) { // delete unsupported files for (var file in unsupportedFiles) { file.deleteSync(); } BuildContext context = navigatorKey.currentContext!; Widget bookItem( String filePath, Widget icon, { bool isDuplicate = false, String? duplicateTitle, String? errorMessage, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ SizedBox( width: 24, height: 24, child: icon, ), Expanded( child: Text( path.basename(filePath), style: TextStyle( fontWeight: FontWeight.w300, overflow: TextOverflow.ellipsis, ), ), ), if (errorMessage != null) IconButton( icon: const Icon(Icons.info_outline, size: 16), padding: EdgeInsets.zero, constraints: const BoxConstraints(), onPressed: () { showDialog( context: context, builder: (context) => AlertDialog( title: Text(L10n.of(context).commonError), content: SelectableText(errorMessage), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(L10n.of(context).commonOk), ), ], ), ); }, ), ], ), if (isDuplicate && duplicateTitle != null) Padding( padding: const EdgeInsets.only(left: 28, top: 2), child: Text( L10n.of(context).duplicateOf(duplicateTitle), style: const TextStyle( fontSize: 12, color: Colors.grey, ), ), ), if (errorMessage != null) Padding( padding: const EdgeInsets.only(left: 28, top: 2), child: Text( 'Error: ${errorMessage.length > 50 ? "${errorMessage.substring(0, 50)}..." : errorMessage}', style: const TextStyle( fontSize: 12, color: Colors.red, ), ), ), ], ); } final supportedFiles = [...uniqueFiles, ...duplicateFiles]; bool skipDuplicates = true; showDialog( context: context, builder: (BuildContext context) { String currentHandlingFile = ''; List errorFiles = []; bool finished = false; Map errorMessages = {}; return StatefulBuilder(builder: (context, setState) { return AlertDialog( title: Text(L10n.of(context).importNBooksSelected(fileList.length)), contentPadding: const EdgeInsets.all(16), content: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(L10n.of(context) .importSupportTypes(allowBookExtensions.join(' / '))), const SizedBox(height: 10), // show unique files for (var file in uniqueFiles) file.path == currentHandlingFile ? bookItem( file.path, Container( padding: const EdgeInsets.all(3), width: 20, height: 20, child: const CircularProgressIndicator(), )) : bookItem( file.path, errorFiles.contains(file.path) ? const Icon(Icons.error) : const Icon(Icons.done), errorMessage: errorFiles.contains(file.path) ? errorMessages[file.path] : null, ), // show unsupported files if (unsupportedFiles.isNotEmpty) ...[ Divider(), SizedBox(height: 10), Text(L10n.of(context) .importNBooksNotSupport(unsupportedFiles.length)) ], for (var file in unsupportedFiles) bookItem(file.path, const Icon(Icons.error)), // show duplicate files if (duplicateFiles.isNotEmpty) ...[ Divider(), const SizedBox(height: 10), Text(L10n.of(context).duplicateFile), ], for (var file in duplicateFiles) if (skipDuplicates) bookItem( file.path, const Icon(Icons.double_arrow_rounded), isDuplicate: true, duplicateTitle: duplicateInfo[file.path]?.title, ) else file.path == currentHandlingFile ? bookItem( file.path, Container( padding: const EdgeInsets.all(3), width: 20, height: 20, child: const CircularProgressIndicator(), ), isDuplicate: true, duplicateTitle: duplicateInfo[file.path]?.title, ) : bookItem( file.path, errorFiles.contains(file.path) ? const Icon(Icons.error) : const Icon(Icons.done), isDuplicate: true, duplicateTitle: duplicateInfo[file.path]?.title, errorMessage: errorFiles.contains(file.path) ? errorMessages[file.path] : null, ), // select skip duplicates if (duplicateFiles.isNotEmpty) ...[ const SizedBox(height: 10), Row( children: [ Checkbox( value: skipDuplicates, onChanged: (value) { setState(() { skipDuplicates = value ?? true; }); }, ), Expanded( child: Text(L10n.of(context).skipDuplicateFiles), ), ], ), ], ], ), ), actions: [ TextButton( onPressed: () { Navigator.pop(context); for (var file in supportedFiles) { file.deleteSync(); } }, child: Text(L10n.of(context).commonCancel), ), if (uniqueFiles.isNotEmpty || (duplicateFiles.isNotEmpty && !skipDuplicates)) TextButton( onPressed: () async { if (finished) { Navigator.of(context).pop('dialog'); return; } List filesToImport = [...uniqueFiles]; if (!skipDuplicates) { filesToImport.addAll(duplicateFiles); } for (var file in filesToImport) { AnxToast.show(path.basename(file.path)); setState(() { currentHandlingFile = file.path; }); try { await importBook(file, ref); setState(() { currentHandlingFile = ''; }); } catch (e, stackTrace) { AnxLog.severe('Failed to import ${file.path}: $e'); AnxLog.severe('Stack trace: $stackTrace'); setState(() { errorFiles.add(file.path); errorMessages[file.path] = e.toString(); }); } } // dumplicateFiles will be deleted if skipDuplicates is true // if skipDuplicates is false, they will be imported // and then deleted in the importBook function if (skipDuplicates) { for (var file in duplicateFiles) { file.deleteSync(); } } setState(() { finished = true; }); ref.read(syncProvider.notifier).syncData( SyncDirection.upload, ref, trigger: SyncTrigger.auto); }, child: Text(finished ? L10n.of(context).commonOk : L10n.of(context).importImportNBooks( uniqueFiles.length + (skipDuplicates ? 0 : duplicateFiles.length) - errorFiles.length))), ], ); }); }); } Future importBook(File file, WidgetRef ref) async { String? md5 = await MD5Service.calculateFileMd5(file.path); if (file.path.split('.').last == 'txt') { final tempFile = await convertFromTxt(file); file.deleteSync(); file = tempFile; } await getBookMetadata(file, md5: md5, ref: ref); ref.read(bookListProvider.notifier).refresh(); } Future pushToReadingPage( WidgetRef ref, BuildContext context, Book book, { String? cfi, String? heroTag, }) async { if (book.isDeleted) { AnxToast.show(L10n.of(context).bookDeleted); return; } if (!File(book.fileFullPath).existsSync()) { ref.read(syncProvider.notifier).downloadBook(book); return; } if (EnvVar.enableInAppPurchase) { final iapAsync = ref.read(iapProvider); final isFeatureAvailable = iapAsync.maybeWhen( data: (state) => state.isFeatureAvailable, orElse: () => ref.read(iapProvider.notifier).cachedFeatureAvailable(), ); if (!isFeatureAvailable) { Navigator.of(context).push( CupertinoPageRoute( builder: (context) => const IAPPage(), ), ); return; } } ref.read(aiChatProvider.notifier).clear(); final initialThemes = await themeDao.selectThemes(); ref.read(currentReadingProvider.notifier).start( CurrentReadingState( book: book, cfi: cfi, ), ); final currentReading = ref.read(currentReadingProvider.notifier); final chapterContentBridge = ref.read(chapterContentBridgeProvider.notifier); final tocSearch = ref.read(tocSearchProvider.notifier); await Navigator.push( navigatorKey.currentContext!, CupertinoPageRoute( builder: (c) => ReadingPage( key: readingPageKey, book: book, cfi: cfi, initialThemes: initialThemes, heroTag: heroTag, ), ), ).then((_) { AnxLog.info('ReadingPage: poped: ${book.title}'); currentReading.finish(); chapterContentBridge.state = null; tocSearch.clear(); AnxLog.info('Pop successfully ReadingPage: ${book.title}'); }); } void updateBookRating(Book book, double rating) { book.rating = rating; bookDao.updateBook(book); } Future resetBookCover(Book book) async { File file = File(book.fileFullPath); getBookMetadata(file); } Future saveBook( File file, String title, String author, String description, String? md5, String cover, { Book? provideBook, }) async { // Extract original filename (without extension) final fileNameWithoutExt = path.basenameWithoutExtension(file.path); // Use original filename if title is invalid final effectiveTitle = (title == 'Unknown' || title.trim().isEmpty) ? fileNameWithoutExt : title; final newBookName = '${effectiveTitle.length > 20 ? effectiveTitle.substring(0, 20) : effectiveTitle}-${DateTime.now().millisecondsSinceEpoch}' .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') .replaceAll('\n', '') .replaceAll('\r', '') .trim(); final extension = file.path.split('.').last; final dbFilePath = 'file/$newBookName.$extension'; final filePath = getBasePath(dbFilePath); String? dbCoverPath = 'cover/$newBookName'; // final coverPath = getBasePath(dbCoverPath); await file.copy(filePath); // remove cached file file.delete(); dbCoverPath = await saveImageToLocal(cover, dbCoverPath); if (md5 != null) { provideBook ??= await bookDao.getBookByMd5(md5); } Book book = Book( id: provideBook != null ? provideBook.id : -1, title: provideBook?.title ?? effectiveTitle, coverPath: dbCoverPath, filePath: dbFilePath, lastReadPosition: provideBook?.lastReadPosition ?? '', readingPercentage: provideBook?.readingPercentage ?? 0, author: provideBook?.author ?? author, isDeleted: false, rating: provideBook?.rating ?? 0.0, md5: md5, createTime: provideBook?.createTime ?? DateTime.now(), updateTime: DateTime.now()); book.id = await bookDao.insertBook(book); AnxToast.show(L10n.of(navigatorKey.currentContext!).serviceImportSuccess); await headlessInAppWebView?.dispose(); headlessInAppWebView = null; return; } Future getBookMetadata( File file, { Book? book, String? md5, WidgetRef? ref, }) async { String serverFileName = Server().setTempFile(file); String cfi = ''; String bookUrl = "http://127.0.0.1:${Server().port}/$serverFileName"; AnxLog.info("import start: book url: $bookUrl"); AnxHeadlessWebView webview = AnxHeadlessWebView( webViewEnvironment: webViewEnvironment, initialUrlRequest: URLRequest( url: WebUri(generateUrl( bookUrl, cfi, importing: true, ))), onLoadStop: (controller, url) async { controller.addJavaScriptHandler( handlerName: 'onMetadata', callback: (args) async { Map metadata = args[0]; String title = metadata['title'] ?? 'Unknown'; dynamic authorData = metadata['author']; String author = authorData is String ? authorData : authorData ?.map((author) => author is String ? author : author['name']) ?.join(', ') ?? 'Unknown'; // base64 cover String cover = metadata['cover'] ?? ''; String description = metadata['description'] ?? ''; saveBook( file, title, author, description, md5, cover, provideBook: book, ); ref?.read(bookListProvider.notifier).refresh(); // return; }); }, onConsoleMessage: (controller, consoleMessage) { if (consoleMessage.messageLevel == ConsoleMessageLevel.ERROR) { headlessInAppWebView?.dispose(); headlessInAppWebView = null; throw Exception('Webview: ${consoleMessage.message}'); } webviewConsoleMessage(controller, consoleMessage); }, ); await webview.run(); headlessInAppWebView = webview; // max 30s int count = 0; while (count < 300) { if (headlessInAppWebView == null) { return; } await Future.delayed(const Duration(milliseconds: 100)); count++; } await headlessInAppWebView?.dispose(); headlessInAppWebView = null; throw Exception('Import: Get book metadata timeout'); }