import 'dart:convert'; import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:path_provider/path_provider.dart'; import 'package:sqlite3/sqlite3.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/network/download.dart'; import 'package:venera/pages/reader/reader.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/io.dart'; import 'app.dart'; import 'history.dart'; class LocalComic with HistoryMixin implements Comic { @override final String id; @override final String title; @override final String subtitle; @override final List tags; /// The name of the directory where the comic is stored final String directory; /// key: chapter id, value: chapter title /// /// chapter id is the name of the directory in `LocalManager.path/$directory` final Map? chapters; /// relative path to the cover image @override final String cover; final ComicType comicType; final List downloadedChapters; final DateTime createdAt; const LocalComic({ required this.id, required this.title, required this.subtitle, required this.tags, required this.directory, required this.chapters, required this.cover, required this.comicType, required this.downloadedChapters, required this.createdAt, }); LocalComic.fromRow(Row row) : id = row[0] as String, title = row[1] as String, subtitle = row[2] as String, tags = List.from(jsonDecode(row[3] as String)), directory = row[4] as String, chapters = MapOrNull.from(jsonDecode(row[5] as String)), cover = row[6] as String, comicType = ComicType(row[7] as int), downloadedChapters = List.from(jsonDecode(row[8] as String)), createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int); File get coverFile => File(FilePath.join( LocalManager().path, directory, cover, )); @override String get description => ""; @override String get sourceKey => comicType == ComicType.local ? "local" : comicType.sourceKey; @override Map toJson() { return { "title": title, "cover": cover, "id": id, "subTitle": subtitle, "tags": tags, "description": description, "sourceKey": sourceKey, }; } @override int? get maxPage => null; void read() async { var history = await HistoryManager().find(id, comicType); App.rootContext.to( () => Reader( type: comicType, cid: id, name: title, chapters: chapters, initialChapter: history?.ep, initialPage: history?.page, history: history ?? History.fromModel( model: this, ep: 0, page: 0, ), ), ); } @override HistoryType get historyType => comicType; @override String? get subTitle => subtitle; @override String? get language => null; @override String? get favoriteId => null; @override double? get stars => null; } class LocalManager with ChangeNotifier { static LocalManager? _instance; LocalManager._(); factory LocalManager() { return _instance ??= LocalManager._(); } late Database _db; /// path to the directory where all the comics are stored late String path; // return error message if failed Future setNewPath(String newPath) async { var newDir = Directory(newPath); if (!await newDir.exists()) { return "Directory does not exist"; } if (!await newDir.list().isEmpty) { return "Directory is not empty"; } try { await copyDirectoryIsolate( Directory(path), newDir, ); await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(path); } catch (e, s) { Log.error("IO", e, s); return e.toString(); } await Directory(path).deleteIgnoreError(recursive:true); path = newPath; return null; } Future init() async { _db = sqlite3.open( '${App.dataPath}/local.db', ); _db.execute(''' CREATE TABLE IF NOT EXISTS comics ( id TEXT NOT NULL, title TEXT NOT NULL, subtitle TEXT NOT NULL, tags TEXT NOT NULL, directory TEXT NOT NULL, chapters TEXT NOT NULL, cover TEXT NOT NULL, comic_type INTEGER NOT NULL, downloadedChapters TEXT NOT NULL, created_at INTEGER, PRIMARY KEY (id, comic_type) ); '''); if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) { path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync(); } else { if (App.isAndroid) { var external = await getExternalStorageDirectories(); if (external != null && external.isNotEmpty) { path = FilePath.join(external.first.path, 'local'); } else { path = FilePath.join(App.dataPath, 'local'); } } else { path = FilePath.join(App.dataPath, 'local'); } } if (!Directory(path).existsSync()) { await Directory(path).create(); } restoreDownloadingTasks(); } String findValidId(ComicType type) { final res = _db.select( ''' SELECT id FROM comics WHERE comic_type = ? ORDER BY CAST(id AS INTEGER) DESC LIMIT 1; ''', [type.value], ); if (res.isEmpty) { return '1'; } return (int.parse((res.first[0])) + 1).toString(); } Future add(LocalComic comic, [String? id]) async { var old = find(id ?? comic.id, comic.comicType); var downloaded = comic.downloadedChapters; if (old != null) { downloaded.addAll(old.downloadedChapters); } _db.execute( 'INSERT OR REPLACE INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);', [ id ?? comic.id, comic.title, comic.subtitle, jsonEncode(comic.tags), comic.directory, jsonEncode(comic.chapters), comic.cover, comic.comicType.value, jsonEncode(downloaded), comic.createdAt.millisecondsSinceEpoch, ], ); notifyListeners(); } void remove(String id, ComicType comicType) async { _db.execute( 'DELETE FROM comics WHERE id = ? AND comic_type = ?;', [id, comicType.value], ); notifyListeners(); } void removeComic(LocalComic comic) { remove(comic.id, comic.comicType); notifyListeners(); } List getComics(LocalSortType sortType) { var res = _db.select(''' SELECT * FROM comics ORDER BY ${sortType.value == 'name' ? 'title' : 'created_at'} ${sortType.value == 'time_asc' ? 'ASC' : 'DESC'} ; '''); return res.map((row) => LocalComic.fromRow(row)).toList(); } LocalComic? find(String id, ComicType comicType) { final res = _db.select( 'SELECT * FROM comics WHERE id = ? AND comic_type = ?;', [id, comicType.value], ); if (res.isEmpty) { return null; } return LocalComic.fromRow(res.first); } @override void dispose() { super.dispose(); _db.dispose(); } List getRecent() { final res = _db.select(''' SELECT * FROM comics ORDER BY created_at DESC LIMIT 20; '''); return res.map((row) => LocalComic.fromRow(row)).toList(); } int get count { final res = _db.select(''' SELECT COUNT(*) FROM comics; '''); return res.first[0] as int; } LocalComic? findByName(String name) { final res = _db.select(''' SELECT * FROM comics WHERE title = ? OR directory = ?; ''', [name, name]); if (res.isEmpty) { return null; } return LocalComic.fromRow(res.first); } List search(String keyword) { final res = _db.select(''' SELECT * FROM comics WHERE title LIKE ? OR tags LIKE ? OR subtitle LIKE ? ORDER BY created_at DESC; ''', ['%$keyword%', '%$keyword%', '%$keyword%']); return res.map((row) => LocalComic.fromRow(row)).toList(); } Future> getImages(String id, ComicType type, Object ep) async { if(ep is! String && ep is! int) { throw "Invalid ep"; } var comic = find(id, type) ?? (throw "Comic Not Found"); var directory = Directory(FilePath.join(path, comic.directory)); if (comic.chapters != null) { var cid = ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String); directory = Directory(FilePath.join(directory.path, cid)); } var files = []; await for (var entity in directory.list()) { if (entity is File) { if (entity.absolute.path.replaceFirst(path, '').substring(1) == comic.cover) { continue; } files.add(entity); } } files.sort((a, b) { var ai = int.tryParse(a.name.split('.').first); var bi = int.tryParse(b.name.split('.').first); if (ai != null && bi != null) { return ai.compareTo(bi); } return a.name.compareTo(b.name); }); return files.map((e) => "file://${e.path}").toList(); } Future isDownloaded(String id, ComicType type, int ep) async { var comic = find(id, type); if (comic == null) return false; if (comic.chapters == null) return true; return comic.downloadedChapters .contains(comic.chapters!.keys.elementAt(ep-1)); } List downloadingTasks = []; bool isDownloading(String id, ComicType type) { return downloadingTasks .any((element) => element.id == id && element.comicType == type); } Future findValidDirectory( String id, ComicType type, String name) async { var comic = find(id, type); if (comic != null) { return Directory(FilePath.join(path, comic.directory)); } var dir = findValidDirectoryName(path, name); return Directory(FilePath.join(path, dir)).create().then((value) => value); } void completeTask(DownloadTask task) { add(task.toLocalComic()); downloadingTasks.remove(task); notifyListeners(); saveCurrentDownloadingTasks(); downloadingTasks.firstOrNull?.resume(); } void removeTask(DownloadTask task) { downloadingTasks.remove(task); notifyListeners(); saveCurrentDownloadingTasks(); } void moveToFirst(DownloadTask task) { if (downloadingTasks.first != task) { var shouldResume = !downloadingTasks.first.isPaused; downloadingTasks.first.pause(); downloadingTasks.remove(task); downloadingTasks.insert(0, task); notifyListeners(); saveCurrentDownloadingTasks(); if (shouldResume) { downloadingTasks.first.resume(); } } } Future saveCurrentDownloadingTasks() async { var tasks = downloadingTasks.map((e) => e.toJson()).toList(); await File(FilePath.join(App.dataPath, 'downloading_tasks.json')) .writeAsString(jsonEncode(tasks)); } void restoreDownloadingTasks() { var file = File(FilePath.join(App.dataPath, 'downloading_tasks.json')); if (file.existsSync()) { var tasks = jsonDecode(file.readAsStringSync()); for (var e in tasks) { var task = DownloadTask.fromJson(e); if (task != null) { downloadingTasks.add(task); } } } } void addTask(DownloadTask task) { downloadingTasks.add(task); notifyListeners(); saveCurrentDownloadingTasks(); downloadingTasks.first.resume(); } void deleteComic(LocalComic c) { var dir = Directory(FilePath.join(path, c.directory)); dir.deleteIgnoreError(recursive: true); remove(c.id, c.comicType); notifyListeners(); } } enum LocalSortType { name("name"), timeAsc("time_asc"), timeDesc("time_desc"); final String value; const LocalSortType(this.value); static LocalSortType fromString(String value) { for (var type in values) { if (type.value == value) { return type; } } return name; } }