Files
venera/lib/foundation/comic_source/comic_source.dart

466 lines
12 KiB
Dart

library comic_source;
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<Res<List<Comic>>> 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<Res<List<Comic>>> Function(
String? next);
typedef LoginFunction = Future<Res<bool>> Function(String, String);
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
String id, String? ep);
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
String id, String? subId, int page, String? replyTo);
typedef SendCommentFunc = Future<Res<bool>> Function(
String id, String? subId, String content, String? replyTo);
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
String imageKey, String comicId, String epId)?;
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
String imageKey)?;
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
String comicId, String? next);
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> 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<Res<int?>> 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<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
typedef HandleClickTagEvent = Map<String, String> Function(
String namespace, String tag);
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
class ComicSource {
static final List<ComicSource> _sources = [];
static final List<Function> _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<ComicSource> 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<void> 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<ExplorePageData> 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<String, dynamic> Function(String imageKey)?
getThumbnailLoadingConfig;
var data = <String, dynamic>{};
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<String, dynamic>? settings;
final Map<String, Map<String, String>>? translations;
final HandleClickTagEvent? handleClickTagEvent;
final LinkHandler? linkHandler;
final bool enableTagsSuggestions;
final bool enableTagsTranslate;
final StarRatingFunc? starRatingFunc;
Future<void> 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<void> 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<bool> 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,
);
}
class AccountConfig {
final LoginFunction? login;
final String? loginWebsite;
final String? registerWebsite;
final void Function() logout;
final List<AccountInfoItem> infoItems;
final bool Function(String url, String title)? checkLoginStatus;
final void Function()? onLoginWithWebviewSuccess;
final List<String>? cookieFields;
final Future<bool> Function(List<String>)? 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<String, String> headers;
LoadImageRequest(this.url, this.headers);
}
class ExplorePageData {
final String title;
final ExplorePageType type;
final ComicListBuilder? loadPage;
final ComicListBuilderWithNext? loadNext;
final Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart;
/// return a `List` contains `List<Comic>` or `ExplorePagePart`
final Future<Res<List<Object>>> Function(int index)? loadMixed;
ExplorePageData(
this.title,
this.type,
this.loadPage,
this.loadNext,
this.loadMultiPart,
this.loadMixed,
);
}
class ExplorePagePart {
final String title;
final List<Comic> 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<Res<List<Comic>>> Function(
String keyword, int page, List<String> searchOption);
typedef SearchNextFunction = Future<Res<List<Comic>>> Function(
String keyword, String? next, List<String> searchOption);
class SearchPageData {
/// If this is not null, the default value of search options will be first element.
final List<SearchOptions>? searchOptions;
final SearchFunction? loadPage;
final SearchNextFunction? loadNext;
const SearchPageData(this.searchOptions, this.loadPage, this.loadNext);
}
class SearchOptions {
final LinkedHashMap<String, String> 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<Res<List<Comic>>> Function(
String category, String? param, List<String> options, int page);
class CategoryComicsData {
/// options
final List<CategoryComicsOptions> 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<String, String> options;
final Future<Res<List<Comic>>> Function(String option, int page)? load;
final Future<Res<List<Comic>>> 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<String, String> options;
/// If [notShowWhen] contains category's name, the option will not be shown.
final List<String> notShowWhen;
final List<String>? showWhen;
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
}
class LinkHandler {
final List<String> domains;
final String? Function(String url) linkToId;
const LinkHandler(this.domains, this.linkToId);
}