mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
464 lines
12 KiB
Dart
464 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/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;
|
|
}
|
|
|
|
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);
|
|
}
|