library comic_source; import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'package:flutter/widgets.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/io.dart'; import '../js_engine.dart'; import '../log.dart'; part 'category.dart'; part 'favorites.dart'; part 'parser.dart'; /// build comic list, [Res.subData] should be maxPage or null if there is no limit. typedef ComicListBuilder = Future>> Function(int page); typedef LoginFunction = Future> Function(String, String); typedef LoadComicFunc = Future> Function(String id); typedef LoadComicPagesFunc = Future>> Function( String id, String? ep); typedef CommentsLoader = Future>> Function( String id, String? subId, int page, String? replyTo); typedef SendCommentFunc = Future> Function( String id, String? subId, String content, String? replyTo); typedef GetImageLoadingConfigFunc = Map Function( String imageKey, String comicId, String epId)?; typedef GetThumbnailLoadingConfigFunc = Map Function( String imageKey)?; class ComicSource { static final List _sources = []; static final List _listeners = []; static void addListener(Function listener) { _listeners.add(listener); } static void removeListener(Function listener) { _listeners.remove(listener); } static void notifyListeners() { for (var listener in _listeners) { listener(); } } static List all() => List.from(_sources); static ComicSource? find(String key) => _sources.firstWhereOrNull((element) => element.key == key); static ComicSource? fromIntKey(int key) => _sources.firstWhereOrNull((element) => element.key.hashCode == key); static Future init() async { final path = "${App.dataPath}/comic_source"; if (!(await Directory(path).exists())) { Directory(path).create(); return; } await for (var entity in Directory(path).list()) { if (entity is File && entity.path.endsWith(".js")) { try { var source = await ComicSourceParser() .parse(await entity.readAsString(), entity.absolute.path); _sources.add(source); } catch (e, s) { Log.error("ComicSource", "$e\n$s"); } } } } static Future reload() async { _sources.clear(); JsEngine().runCode("ComicSource.sources = {};"); await init(); notifyListeners(); } static void add(ComicSource source) { _sources.add(source); notifyListeners(); } static void remove(String key) { _sources.removeWhere((element) => element.key == key); notifyListeners(); } /// Name of this source. final String name; /// Identifier of this source. final String key; int get intKey { return key.hashCode; } /// Account config. final AccountConfig? account; /// Category data used to build a static category tags page. final CategoryData? categoryData; /// Category comics data used to build a comics page with a category tag. final CategoryComicsData? categoryComicsData; /// Favorite data used to build favorite page. final FavoriteData? favoriteData; /// Explore pages. final List explorePages; /// Search page. final SearchPageData? searchPageData; /// Settings. final List settings; /// Load comic info. final LoadComicFunc? loadComicInfo; /// Load comic pages. final LoadComicPagesFunc? loadComicPages; final Map Function( String imageKey, String comicId, String epId)? getImageLoadingConfig; final Map Function(String imageKey)? getThumbnailLoadingConfig; final String? matchBriefIdReg; var data = {}; bool get isLogged => data["account"] != null; final String filePath; final String url; final String version; final CommentsLoader? commentsLoader; final SendCommentFunc? sendCommentFunc; final RegExp? idMatcher; Future loadData() async { var file = File("${App.dataPath}/comic_source/$key.data"); if (await file.exists()) { data = Map.from(jsonDecode(await file.readAsString())); } } bool _isSaving = false; bool _haveWaitingTask = false; Future saveData() async { if (_haveWaitingTask) return; while (_isSaving) { _haveWaitingTask = true; await Future.delayed(const Duration(milliseconds: 20)); _haveWaitingTask = false; } _isSaving = true; var file = File("${App.dataPath}/comic_source/$key.data"); if (!await file.exists()) { await file.create(recursive: true); } await file.writeAsString(jsonEncode(data)); _isSaving = false; } Future reLogin() async { if (data["account"] == null) { return false; } final List accountData = data["account"]; var res = await account!.login!(accountData[0], accountData[1]); if (res.error) { Log.error("Failed to re-login", res.errorMessage ?? "Error"); } return !res.error; } ComicSource( this.name, this.key, this.account, this.categoryData, this.categoryComicsData, this.favoriteData, this.explorePages, this.searchPageData, this.settings, this.loadComicInfo, this.loadComicPages, this.getImageLoadingConfig, this.getThumbnailLoadingConfig, this.matchBriefIdReg, this.filePath, this.url, this.version, this.commentsLoader, this.sendCommentFunc) : idMatcher = null; ComicSource.unknown(this.key) : name = "Unknown", account = null, categoryData = null, categoryComicsData = null, favoriteData = null, explorePages = [], searchPageData = null, settings = [], loadComicInfo = null, loadComicPages = null, getImageLoadingConfig = null, getThumbnailLoadingConfig = null, matchBriefIdReg = null, filePath = "", url = "", version = "", commentsLoader = null, sendCommentFunc = null, idMatcher = null; } class AccountConfig { final LoginFunction? login; final FutureOr Function(BuildContext)? onLogin; final String? loginWebsite; final String? registerWebsite; final void Function() logout; final bool allowReLogin; final List infoItems; const AccountConfig( this.login, this.loginWebsite, this.registerWebsite, this.logout, {this.onLogin}) : allowReLogin = true, infoItems = const []; } class AccountInfoItem { final String title; final String Function()? data; final void Function()? onTap; final WidgetBuilder? builder; AccountInfoItem({required this.title, this.data, this.onTap, this.builder}); } class LoadImageRequest { String url; Map headers; LoadImageRequest(this.url, this.headers); } class ExplorePageData { final String title; final ExplorePageType type; final ComicListBuilder? loadPage; final Future>> Function()? loadMultiPart; /// return a `List` contains `List` or `ExplorePagePart` final Future>> Function(int index)? loadMixed; final WidgetBuilder? overridePageBuilder; ExplorePageData(this.title, this.type, this.loadPage, this.loadMultiPart) : loadMixed = null, overridePageBuilder = null; } class ExplorePagePart { final String title; final List comics; /// If this is not null, the [ExplorePagePart] will show a button to jump to new page. /// /// Value of this field should match the following format: /// - search:keyword /// - category:categoryName /// /// End with `@`+`param` if the category has a parameter. final String? viewMore; const ExplorePagePart(this.title, this.comics, this.viewMore); } enum ExplorePageType { multiPageComicList, singlePageWithMultiPart, mixed, override, } typedef SearchFunction = Future>> Function( String keyword, int page, List searchOption); class SearchPageData { /// If this is not null, the default value of search options will be first element. final List? searchOptions; final Widget Function(BuildContext, List initialValues, void Function(List))? customOptionsBuilder; final Widget Function(String keyword, List options)? overrideSearchResultBuilder; final SearchFunction? loadPage; final bool enableLanguageFilter; final bool enableTagsSuggestions; const SearchPageData(this.searchOptions, this.loadPage) : enableLanguageFilter = false, customOptionsBuilder = null, overrideSearchResultBuilder = null, enableTagsSuggestions = false; } class SearchOptions { final LinkedHashMap options; final String label; const SearchOptions(this.options, this.label); String get defaultValue => options.keys.first; } class SettingItem { final String name; final String iconName; final SettingType type; final List? options; const SettingItem(this.name, this.iconName, this.type, this.options); } enum SettingType { switcher, selector, input, } class Comic { final String title; final String cover; final String id; final String? subTitle; final List? tags; final String description; final String sourceKey; const Comic(this.title, this.cover, this.id, this.subTitle, this.tags, this.description, this.sourceKey); Map toJson() { return { "title": title, "cover": cover, "id": id, "subTitle": subTitle, "tags": tags, "description": description, "sourceKey": sourceKey, }; } Comic.fromJson(Map json, this.sourceKey) : title = json["title"], subTitle = json["subTitle"] ?? "", cover = json["cover"], id = json["id"], tags = List.from(json["tags"] ?? []), description = json["description"] ?? ""; } class ComicDetails with HistoryMixin { @override final String title; @override final String? subTitle; @override final String cover; final String? description; final Map> tags; /// id-name final Map? chapters; final List? thumbnails; final Future>> Function(String id, int page)? thumbnailLoader; final int thumbnailMaxPage; final List? suggestions; final String sourceKey; final String comicId; final bool? isFavorite; final String? subId; const ComicDetails( this.title, this.subTitle, this.cover, this.description, this.tags, this.chapters, this.thumbnails, this.thumbnailLoader, this.thumbnailMaxPage, this.suggestions, this.sourceKey, this.comicId, {this.isFavorite, this.subId}); Map toJson() { return { "title": title, "subTitle": subTitle, "cover": cover, "description": description, "tags": tags, "chapters": chapters, "sourceKey": sourceKey, "comicId": comicId, "isFavorite": isFavorite, "subId": subId, }; } static Map> _generateMap(Map map) { var res = >{}; map.forEach((key, value) { res[key] = List.from(value); }); return res; } ComicDetails.fromJson(Map json) : title = json["title"], subTitle = json["subTitle"], cover = json["cover"], description = json["description"], tags = _generateMap(json["tags"]), chapters = Map.from(json["chapters"]), sourceKey = json["sourceKey"], comicId = json["comicId"], thumbnails = null, thumbnailLoader = null, thumbnailMaxPage = 0, suggestions = null, isFavorite = json["isFavorite"], subId = json["subId"]; @override HistoryType get historyType => HistoryType(sourceKey.hashCode); @override String get id => comicId; } typedef CategoryComicsLoader = Future>> Function( String category, String? param, List options, int page); class CategoryComicsData { /// options final List options; /// [category] is the one clicked by the user on the category page. /// if [BaseCategoryPart.categoryParams] is not null, [param] will be not null. /// /// [Res.subData] should be maxPage or null if there is no limit. final CategoryComicsLoader load; final RankingData? rankingData; const CategoryComicsData(this.options, this.load, {this.rankingData}); } class RankingData { final Map options; final Future>> Function(String option, int page) load; const RankingData(this.options, this.load); } class CategoryComicsOptions { /// Use a [LinkedHashMap] to describe an option list. /// key is for loading comics, value is the name displayed on screen. /// Default value will be the first of the Map. final LinkedHashMap options; /// If [notShowWhen] contains category's name, the option will not be shown. final List notShowWhen; final List? showWhen; const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen); } class Comment { final String userName; final String? avatar; final String content; final String? time; final int? replyCount; final String? id; const Comment(this.userName, this.avatar, this.content, this.time, this.replyCount, this.id); }