Files
venera/lib/foundation/comic_source/comic_source.dart
2024-09-30 17:06:58 +08:00

571 lines
14 KiB
Dart

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<Res<List<Comic>>> Function(int page);
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 = Map<String, dynamic> Function(
String imageKey, String comicId, String epId)?;
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
String imageKey)?;
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;
/// Settings.
final List<SettingItem> settings;
/// Load comic info.
final LoadComicFunc? loadComicInfo;
/// Load comic pages.
final LoadComicPagesFunc? loadComicPages;
final Map<String, dynamic> Function(
String imageKey, String comicId, String epId)? getImageLoadingConfig;
final Map<String, dynamic> Function(String imageKey)?
getThumbnailLoadingConfig;
final String? matchBriefIdReg;
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;
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.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<void> Function(BuildContext)? onLogin;
final String? loginWebsite;
final String? registerWebsite;
final void Function() logout;
final bool allowReLogin;
final List<AccountInfoItem> 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<String, String> headers;
LoadImageRequest(this.url, this.headers);
}
class ExplorePageData {
final String title;
final ExplorePageType type;
final ComicListBuilder? loadPage;
final Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart;
/// return a `List` contains `List<Comic>` or `ExplorePagePart`
final Future<Res<List<Object>>> 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<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);
class SearchPageData {
/// If this is not null, the default value of search options will be first element.
final List<SearchOptions>? searchOptions;
final Widget Function(BuildContext, List<String> initialValues, void Function(List<String>))?
customOptionsBuilder;
final Widget Function(String keyword, List<String> 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<String, String> 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<String>? 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<String>? tags;
final String description;
final String sourceKey;
const Comic(this.title, this.cover, this.id, this.subTitle, this.tags, this.description, this.sourceKey);
Map<String, dynamic> toJson() {
return {
"title": title,
"cover": cover,
"id": id,
"subTitle": subTitle,
"tags": tags,
"description": description,
"sourceKey": sourceKey,
};
}
Comic.fromJson(Map<String, dynamic> json, this.sourceKey)
: title = json["title"],
subTitle = json["subTitle"] ?? "",
cover = json["cover"],
id = json["id"],
tags = List<String>.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<String, List<String>> tags;
/// id-name
final Map<String, String>? chapters;
final List<String>? thumbnails;
final Future<Res<List<String>>> Function(String id, int page)?
thumbnailLoader;
final int thumbnailMaxPage;
final List<Comic>? 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<String, dynamic> toJson() {
return {
"title": title,
"subTitle": subTitle,
"cover": cover,
"description": description,
"tags": tags,
"chapters": chapters,
"sourceKey": sourceKey,
"comicId": comicId,
"isFavorite": isFavorite,
"subId": subId,
};
}
static Map<String, List<String>> _generateMap(Map<String, dynamic> map) {
var res = <String, List<String>>{};
map.forEach((key, value) {
res[key] = List<String>.from(value);
});
return res;
}
ComicDetails.fromJson(Map<String, dynamic> json)
: title = json["title"],
subTitle = json["subTitle"],
cover = json["cover"],
description = json["description"],
tags = _generateMap(json["tags"]),
chapters = Map<String, String>.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<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;
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<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 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);
}