mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
initial commit
This commit is contained in:
540
lib/foundation/comic_source/comic_source.dart
Normal file
540
lib/foundation/comic_source/comic_source.dart
Normal file
@@ -0,0 +1,540 @@
|
||||
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 '../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 List<ComicSource> 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();
|
||||
}
|
||||
|
||||
/// 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 isLogin => 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);
|
||||
}
|
Reference in New Issue
Block a user