mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
454 lines
11 KiB
Dart
454 lines
11 KiB
Dart
library;
|
|
|
|
import 'dart:async';
|
|
import 'dart:collection';
|
|
import 'dart:convert';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_qjs/flutter_qjs.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/init.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';
|
|
|
|
part 'types.dart';
|
|
|
|
class ComicSourceManager with ChangeNotifier, Init {
|
|
final List<ComicSource> _sources = [];
|
|
|
|
static ComicSourceManager? _instance;
|
|
|
|
ComicSourceManager._create();
|
|
|
|
factory ComicSourceManager() => _instance ??= ComicSourceManager._create();
|
|
|
|
List<ComicSource> all() => List.from(_sources);
|
|
|
|
ComicSource? find(String key) =>
|
|
_sources.firstWhereOrNull((element) => element.key == key);
|
|
|
|
ComicSource? fromIntKey(int key) =>
|
|
_sources.firstWhereOrNull((element) => element.key.hashCode == key);
|
|
|
|
@override
|
|
@protected
|
|
Future<void> doInit() async {
|
|
await JsEngine().ensureInit();
|
|
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");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Future reload() async {
|
|
_sources.clear();
|
|
JsEngine().runCode("ComicSource.sources = {};");
|
|
await doInit();
|
|
notifyListeners();
|
|
}
|
|
|
|
void add(ComicSource source) {
|
|
_sources.add(source);
|
|
notifyListeners();
|
|
}
|
|
|
|
void remove(String key) {
|
|
_sources.removeWhere((element) => element.key == key);
|
|
notifyListeners();
|
|
}
|
|
|
|
bool get isEmpty => _sources.isEmpty;
|
|
|
|
/// Key is the source key, value is the version.
|
|
final _availableUpdates = <String, String>{};
|
|
|
|
void updateAvailableUpdates(Map<String, String> updates) {
|
|
_availableUpdates.addAll(updates);
|
|
notifyListeners();
|
|
}
|
|
|
|
Map<String, String> get availableUpdates => Map.from(_availableUpdates);
|
|
|
|
void notifyStateChange() {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
class ComicSource {
|
|
static List<ComicSource> all() => ComicSourceManager().all();
|
|
|
|
static ComicSource? find(String key) => ComicSourceManager().find(key);
|
|
|
|
static ComicSource? fromIntKey(int key) =>
|
|
ComicSourceManager().fromIntKey(key);
|
|
|
|
static bool get isEmpty => ComicSourceManager().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<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, 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;
|
|
|
|
final ArchiveDownloader? archiveDownloader;
|
|
|
|
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,
|
|
this.archiveDownloader,
|
|
);
|
|
}
|
|
|
|
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.firstOrNull ?? "";
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
class ArchiveDownloader {
|
|
final Future<Res<List<ArchiveInfo>>> Function(String cid) getArchives;
|
|
|
|
final Future<Res<String>> Function(String cid, String aid) getDownloadUrl;
|
|
|
|
const ArchiveDownloader(this.getArchives, this.getDownloadUrl);
|
|
}
|