library; import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/widgets.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/translations.dart'; import '../js_engine.dart'; import '../log.dart'; part 'category.dart'; part 'favorites.dart'; part 'parser.dart'; part 'models.dart'; /// build comic list, [Res.subData] should be maxPage or null if there is no limit. typedef ComicListBuilder = Future>> Function(int page); /// build comic list with next param, [Res.subData] should be next page param or null if there is no next page. typedef ComicListBuilderWithNext = Future>> Function( String? next); 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 = Future> Function( String imageKey, String comicId, String epId)?; typedef GetThumbnailLoadingConfigFunc = Map Function( String imageKey)?; typedef ComicThumbnailLoader = Future>> Function( String comicId, String? next); typedef LikeOrUnlikeComicFunc = Future> Function( String comicId, bool isLiking); /// [isLiking] is true if the user is liking the comment, false if unliking. /// return the new likes count or null. typedef LikeCommentFunc = Future> Function( String comicId, String? subId, String commentId, bool isLiking); /// [isUp] is true if the user is upvoting the comment, false if downvoting. /// return the new vote count or null. typedef VoteCommentFunc = Future> Function( String comicId, String? subId, String commentId, bool isUp, bool isCancel); typedef HandleClickTagEvent = Map Function( String namespace, String tag); /// [rating] is the rating value, 0-10. 1 represents 0.5 star. typedef StarRatingFunc = Future> Function(String comicId, int rating); 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(); } static final availableUpdates = {}; static bool get isEmpty => _sources.isEmpty; /// 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; /// Load comic info. final LoadComicFunc? loadComicInfo; final ComicThumbnailLoader? loadComicThumbnail; /// Load comic pages. final LoadComicPagesFunc? loadComicPages; final GetImageLoadingConfigFunc? getImageLoadingConfig; final Map Function(String imageKey)? getThumbnailLoadingConfig; 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; final LikeOrUnlikeComicFunc? likeOrUnlikeComic; final VoteCommentFunc? voteCommentFunc; final LikeCommentFunc? likeCommentFunc; final Map? settings; final Map>? translations; final HandleClickTagEvent? handleClickTagEvent; final LinkHandler? linkHandler; final bool enableTagsSuggestions; final bool enableTagsTranslate; final StarRatingFunc? starRatingFunc; final ArchiveDownloader? archiveDownloader; 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; DataSync().uploadData(); } 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.loadComicThumbnail, this.loadComicPages, this.getImageLoadingConfig, this.getThumbnailLoadingConfig, this.filePath, this.url, this.version, this.commentsLoader, this.sendCommentFunc, this.likeOrUnlikeComic, this.voteCommentFunc, this.likeCommentFunc, this.idMatcher, this.translations, this.handleClickTagEvent, this.linkHandler, this.enableTagsSuggestions, this.enableTagsTranslate, this.starRatingFunc, this.archiveDownloader, ); } class AccountConfig { final LoginFunction? login; final String? loginWebsite; final String? registerWebsite; final void Function() logout; final List infoItems; final bool Function(String url, String title)? checkLoginStatus; final void Function()? onLoginWithWebviewSuccess; final List? cookieFields; final Future Function(List)? validateCookies; const AccountConfig( this.login, this.loginWebsite, this.registerWebsite, this.logout, this.checkLoginStatus, this.onLoginWithWebviewSuccess, this.cookieFields, this.validateCookies, ) : 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 ComicListBuilderWithNext? loadNext; final Future>> Function()? loadMultiPart; /// return a `List` contains `List` or `ExplorePagePart` final Future>> Function(int index)? loadMixed; ExplorePageData( this.title, this.type, this.loadPage, this.loadNext, this.loadMultiPart, this.loadMixed, ); } 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); typedef SearchNextFunction = Future>> Function( String keyword, String? next, List searchOption); class SearchPageData { /// If this is not null, the default value of search options will be first element. final List? searchOptions; final SearchFunction? loadPage; final SearchNextFunction? loadNext; const SearchPageData(this.searchOptions, this.loadPage, this.loadNext); } class SearchOptions { final LinkedHashMap options; final String label; final String type; final String? defaultVal; const SearchOptions(this.options, this.label, this.type, this.defaultVal); String get defaultValue => defaultVal ?? options.keys.first; } 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; final Future>> Function(String option, String? next)? loadWithNext; const RankingData(this.options, this.load, this.loadWithNext); } 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 LinkHandler { final List domains; final String? Function(String url) linkToId; const LinkHandler(this.domains, this.linkToId); } class ArchiveDownloader { final Future>> Function(String cid) getArchives; final Future> Function(String cid, String aid) getDownloadUrl; const ArchiveDownloader(this.getArchives, this.getDownloadUrl); }