mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00

* feat: 增加图片收藏 * feat: 主体图片收藏页面实现 * feat: 点击打开大图浏览 * feat: 数据结构变更 * feat: 基本完成 * feat: 翻译与bug修复 * feat: 实机测试和问题修复 * feat: jm导入, pica历史记录nhentai有问题, 一键反转 * fix: 大小写不一致, 一个htManga, 一个htmanga * feat: 拉取收藏优化 * feat: 改成以ep为准 * feat: 兜底一些可能报错场景 * chore: 没有用到 * feat: 尽量保证和网络收藏顺序一致 * feat: 支持显示热点tag * feat: 支持双击收藏, 不过此时禁止放大图片 * fix: 自动塞封面逻辑完善, 切换快速收藏图片立刻生效 * Refactor * fix updateValue * feat: 双击功能提示 * fix: 被确定取消收藏的才删除 * Refactor ImageFavoritesPage * translate author * feat: 功能提示改到dialog中 * fix text editing * fix text editing * feat: 功能提示放到邮件或长按菜单中 * fix: 修复tag过滤不生效问题 * Improve image loading * The default value of quickCollectImage should be false. * Refactor DragListener * Refactor ImageFavoriteItem & ImageFavoritePhotoView * Refactor * Fix `ImageFavoriteManager.has` * Fix UI * Improve UI --------- Co-authored-by: nyne <me@nyne.dev>
536 lines
14 KiB
Dart
536 lines
14 KiB
Dart
part of "history.dart";
|
|
|
|
class ImageFavorite {
|
|
final String eid;
|
|
final String id; // 漫画id
|
|
final int ep;
|
|
final String epName;
|
|
final String sourceKey;
|
|
String imageKey;
|
|
int page;
|
|
bool? isAutoFavorite;
|
|
|
|
ImageFavorite(
|
|
this.page,
|
|
this.imageKey,
|
|
this.isAutoFavorite,
|
|
this.eid,
|
|
this.id,
|
|
this.ep,
|
|
this.sourceKey,
|
|
this.epName,
|
|
);
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'page': page,
|
|
'imageKey': imageKey,
|
|
'isAutoFavorite': isAutoFavorite,
|
|
'eid': eid,
|
|
'id': id,
|
|
'ep': ep,
|
|
'sourceKey': sourceKey,
|
|
'epName': epName,
|
|
};
|
|
}
|
|
|
|
ImageFavorite.fromJson(Map<String, dynamic> json)
|
|
: page = json['page'],
|
|
imageKey = json['imageKey'],
|
|
isAutoFavorite = json['isAutoFavorite'],
|
|
eid = json['eid'],
|
|
id = json['id'],
|
|
ep = json['ep'],
|
|
sourceKey = json['sourceKey'],
|
|
epName = json['epName'];
|
|
|
|
ImageFavorite copyWith({
|
|
int? page,
|
|
String? imageKey,
|
|
bool? isAutoFavorite,
|
|
String? eid,
|
|
String? id,
|
|
int? ep,
|
|
String? sourceKey,
|
|
String? epName,
|
|
}) {
|
|
return ImageFavorite(
|
|
page ?? this.page,
|
|
imageKey ?? this.imageKey,
|
|
isAutoFavorite ?? this.isAutoFavorite,
|
|
eid ?? this.eid,
|
|
id ?? this.id,
|
|
ep ?? this.ep,
|
|
sourceKey ?? this.sourceKey,
|
|
epName ?? this.epName,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
return other is ImageFavorite &&
|
|
other.id == id &&
|
|
other.sourceKey == sourceKey &&
|
|
other.page == page &&
|
|
other.eid == eid &&
|
|
other.ep == ep;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(id, sourceKey, page, eid, ep);
|
|
}
|
|
|
|
class ImageFavoritesEp {
|
|
// 小心拷贝等多章节的可能更新章节顺序
|
|
String eid;
|
|
final int ep;
|
|
int maxPage;
|
|
String epName;
|
|
List<ImageFavorite> imageFavorites;
|
|
|
|
ImageFavoritesEp(
|
|
this.eid, this.ep, this.imageFavorites, this.epName, this.maxPage);
|
|
|
|
// 是否有封面
|
|
bool get isHasFirstPage {
|
|
return imageFavorites[0].page == firstPage;
|
|
}
|
|
|
|
// 是否都有imageKey
|
|
bool get isHasImageKey {
|
|
return imageFavorites.every((e) => e.imageKey != "");
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'eid': eid,
|
|
'ep': ep,
|
|
'maxPage': maxPage,
|
|
'epName': epName,
|
|
'imageFavorites': imageFavorites.map((e) => e.toJson()).toList(),
|
|
};
|
|
}
|
|
}
|
|
|
|
class ImageFavoritesComic {
|
|
final String id;
|
|
final String title;
|
|
String subTitle;
|
|
String author;
|
|
final String sourceKey;
|
|
|
|
// 不一定是真的这本漫画的所有页数, 如果是多章节的时候
|
|
int maxPage;
|
|
List<String> tags;
|
|
List<String> translatedTags;
|
|
final DateTime time;
|
|
List<ImageFavoritesEp> imageFavoritesEp;
|
|
final Map<String, dynamic> other;
|
|
|
|
ImageFavoritesComic(
|
|
this.id,
|
|
this.imageFavoritesEp,
|
|
this.title,
|
|
this.sourceKey,
|
|
this.tags,
|
|
this.translatedTags,
|
|
this.time,
|
|
this.author,
|
|
this.other,
|
|
this.subTitle,
|
|
this.maxPage,
|
|
);
|
|
|
|
// 是否都有imageKey
|
|
bool get isAllHasImageKey {
|
|
return imageFavoritesEp
|
|
.every((e) => e.imageFavorites.every((j) => j.imageKey != ""));
|
|
}
|
|
|
|
int get maxPageFromEp {
|
|
int temp = 0;
|
|
for (var e in imageFavoritesEp) {
|
|
temp += e.maxPage;
|
|
}
|
|
return temp;
|
|
}
|
|
|
|
// 是否都有封面
|
|
bool get isAllHasFirstPage {
|
|
return imageFavoritesEp.every((e) => e.isHasFirstPage);
|
|
}
|
|
|
|
Iterable<ImageFavorite> get images sync*{
|
|
for (var e in imageFavoritesEp) {
|
|
yield* e.imageFavorites;
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
return other is ImageFavoritesComic &&
|
|
other.id == id &&
|
|
other.sourceKey == sourceKey;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(id, sourceKey);
|
|
|
|
factory ImageFavoritesComic.fromRow(Row r) {
|
|
var tempImageFavoritesEp = jsonDecode(r["image_favorites_ep"]);
|
|
List<ImageFavoritesEp> finalImageFavoritesEp = [];
|
|
tempImageFavoritesEp.forEach((i) {
|
|
List<ImageFavorite> temp = [];
|
|
i["imageFavorites"].forEach((j) {
|
|
temp.add(ImageFavorite(
|
|
j["page"],
|
|
j["imageKey"],
|
|
j["isAutoFavorite"],
|
|
i["eid"],
|
|
r["id"],
|
|
i["ep"],
|
|
r["source_key"],
|
|
i["epName"],
|
|
));
|
|
});
|
|
finalImageFavoritesEp.add(ImageFavoritesEp(
|
|
i["eid"], i["ep"], temp, i["epName"], i["maxPage"] ?? 1));
|
|
});
|
|
return ImageFavoritesComic(
|
|
r["id"],
|
|
finalImageFavoritesEp,
|
|
r["title"],
|
|
r["source_key"],
|
|
r["tags"].split(","),
|
|
r["translated_tags"].split(","),
|
|
DateTime.fromMillisecondsSinceEpoch(r["time"]),
|
|
r["author"],
|
|
jsonDecode(r["other"]),
|
|
r["sub_title"],
|
|
r["max_page"],
|
|
);
|
|
}
|
|
}
|
|
|
|
class ImageFavoriteManager with ChangeNotifier {
|
|
Database get _db => HistoryManager()._db;
|
|
|
|
List<ImageFavoritesComic> get comics => getAll();
|
|
|
|
static ImageFavoriteManager? _cache;
|
|
|
|
ImageFavoriteManager._();
|
|
|
|
factory ImageFavoriteManager() => (_cache ??= ImageFavoriteManager._());
|
|
|
|
/// 检查表image_favorites是否存在, 不存在则创建
|
|
void init() {
|
|
_db.execute("CREATE TABLE IF NOT EXISTS image_favorites ("
|
|
"id TEXT,"
|
|
"title TEXT NOT NULL,"
|
|
"sub_title TEXT,"
|
|
"author TEXT,"
|
|
"tags TEXT,"
|
|
"translated_tags TEXT,"
|
|
"time int,"
|
|
"max_page int,"
|
|
"source_key TEXT NOT NULL,"
|
|
"image_favorites_ep TEXT NOT NULL,"
|
|
"other TEXT NOT NULL,"
|
|
"PRIMARY KEY (id,source_key)"
|
|
");");
|
|
}
|
|
|
|
// 做排序和去重的操作
|
|
void addOrUpdateOrDelete(ImageFavoritesComic favorite, [bool notify = true]) {
|
|
// 没有章节了就删掉
|
|
if (favorite.imageFavoritesEp.isEmpty) {
|
|
_db.execute("""
|
|
delete from image_favorites
|
|
where id == ? and source_key == ?;
|
|
""", [favorite.id, favorite.sourceKey]);
|
|
} else {
|
|
// 去重章节
|
|
List<ImageFavoritesEp> tempImageFavoritesEp = [];
|
|
for (var e in favorite.imageFavoritesEp) {
|
|
int index = tempImageFavoritesEp.indexWhere((i) {
|
|
return i.ep == e.ep;
|
|
});
|
|
// 再做一层保险, 防止出现ep为0的脏数据
|
|
if (index == -1 && e.ep > 0) {
|
|
tempImageFavoritesEp.add(e);
|
|
}
|
|
}
|
|
tempImageFavoritesEp.sort((a, b) => a.ep.compareTo(b.ep));
|
|
List<dynamic> finalImageFavoritesEp =
|
|
jsonDecode(jsonEncode(tempImageFavoritesEp));
|
|
for (var e in tempImageFavoritesEp) {
|
|
List<Map> finalImageFavorites = [];
|
|
int epIndex = tempImageFavoritesEp.indexOf(e);
|
|
for (ImageFavorite j in e.imageFavorites) {
|
|
int index =
|
|
finalImageFavorites.indexWhere((i) => i["page"] == j.page);
|
|
if (index == -1 && j.page > 0) {
|
|
// isAutoFavorite 为 null 不写入数据库, 同时只保留需要的属性, 避免增加太多重复字段在数据库里
|
|
if (j.isAutoFavorite != null) {
|
|
finalImageFavorites.add({
|
|
"page": j.page,
|
|
"imageKey": j.imageKey,
|
|
"isAutoFavorite": j.isAutoFavorite
|
|
});
|
|
} else {
|
|
finalImageFavorites.add({"page": j.page, "imageKey": j.imageKey});
|
|
}
|
|
}
|
|
}
|
|
finalImageFavorites.sort((a, b) => a["page"].compareTo(b["page"]));
|
|
finalImageFavoritesEp[epIndex]["imageFavorites"] = finalImageFavorites;
|
|
}
|
|
if (tempImageFavoritesEp.isEmpty) {
|
|
throw "Error: No ImageFavoritesEp";
|
|
}
|
|
_db.execute("""
|
|
insert or replace into image_favorites(id, title, sub_title, author, tags, translated_tags, time, max_page, source_key, image_favorites_ep, other)
|
|
values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
|
""", [
|
|
favorite.id,
|
|
favorite.title,
|
|
favorite.subTitle,
|
|
favorite.author,
|
|
favorite.tags.join(","),
|
|
favorite.translatedTags.join(","),
|
|
favorite.time.millisecondsSinceEpoch,
|
|
favorite.maxPage,
|
|
favorite.sourceKey,
|
|
jsonEncode(finalImageFavoritesEp),
|
|
jsonEncode(favorite.other)
|
|
]);
|
|
}
|
|
if (notify) {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
bool has(String id, String sourceKey, String eid, int page, int ep) {
|
|
var comic = find(id, sourceKey);
|
|
if (comic == null) {
|
|
return false;
|
|
}
|
|
var epIndex = comic.imageFavoritesEp.where((e) => e.eid == eid).firstOrNull;
|
|
if (epIndex == null) {
|
|
return false;
|
|
}
|
|
return epIndex.imageFavorites.any((e) => e.page == page && e.ep == ep);
|
|
}
|
|
|
|
List<ImageFavoritesComic> getAll([String? keyword]) {
|
|
ResultSet res;
|
|
if (keyword == null || keyword == "") {
|
|
res = _db.select("select * from image_favorites;");
|
|
} else {
|
|
res = _db.select(
|
|
"""
|
|
select * from image_favorites
|
|
WHERE title LIKE ?
|
|
OR sub_title LIKE ?
|
|
OR LOWER(tags) LIKE LOWER(?)
|
|
OR LOWER(translated_tags) LIKE LOWER(?)
|
|
OR author LIKE ?;
|
|
""",
|
|
['%$keyword%', '%$keyword%', '%$keyword%', '%$keyword%', '%$keyword%'],
|
|
);
|
|
}
|
|
try {
|
|
return res.map((e) => ImageFavoritesComic.fromRow(e)).toList();
|
|
} catch (e, stackTrace) {
|
|
Log.error("Unhandled Exception", e.toString(), stackTrace);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
void deleteImageFavorite(Iterable<ImageFavorite> imageFavoriteList) {
|
|
if (imageFavoriteList.isEmpty) {
|
|
return;
|
|
}
|
|
for (var i in imageFavoriteList) {
|
|
ImageFavoritesProvider.deleteFromCache(i);
|
|
}
|
|
var comics = <ImageFavoritesComic>{};
|
|
for (var i in imageFavoriteList) {
|
|
var comic = comics
|
|
.where((c) => c.id == i.id && c.sourceKey == i.sourceKey)
|
|
.firstOrNull ??
|
|
find(i.id, i.sourceKey);
|
|
if (comic == null) {
|
|
continue;
|
|
}
|
|
var ep = comic.imageFavoritesEp.firstWhereOrNull((e) => e.ep == i.ep);
|
|
if (ep == null) {
|
|
continue;
|
|
}
|
|
ep.imageFavorites.remove(i);
|
|
if (ep.imageFavorites.isEmpty) {
|
|
comic.imageFavoritesEp.remove(ep);
|
|
}
|
|
comics.add(comic);
|
|
}
|
|
for (var i in comics) {
|
|
addOrUpdateOrDelete(i, false);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
int get length {
|
|
var res = _db.select("select count(*) from image_favorites;");
|
|
return res.first.values.first! as int;
|
|
}
|
|
|
|
List<ImageFavoritesComic> search(String keyword) {
|
|
if (keyword == "") {
|
|
return [];
|
|
}
|
|
return getAll(keyword);
|
|
}
|
|
|
|
static Future<ImageFavoritesComputed> computeImageFavorites() {
|
|
var token = ServicesBinding.rootIsolateToken!;
|
|
var count = ImageFavoriteManager().length;
|
|
if (count == 0) {
|
|
return Future.value(ImageFavoritesComputed([], [], []));
|
|
} else if (count > 100) {
|
|
return Isolate.run(() async {
|
|
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
|
await App.init();
|
|
await HistoryManager().init();
|
|
return _computeImageFavorites();
|
|
});
|
|
} else {
|
|
return Future.value(_computeImageFavorites());
|
|
}
|
|
}
|
|
|
|
static ImageFavoritesComputed _computeImageFavorites() {
|
|
const maxLength = 20;
|
|
|
|
var comics = ImageFavoriteManager().getAll();
|
|
// 去掉这些没有意义的标签
|
|
const List<String> exceptTags = [
|
|
'連載中',
|
|
'',
|
|
'translated',
|
|
'chinese',
|
|
'sole male',
|
|
'sole female',
|
|
'original',
|
|
'doujinshi',
|
|
'manga',
|
|
'multi-work series',
|
|
'mosaic censorship',
|
|
'dilf',
|
|
'bbm',
|
|
'uncensored',
|
|
'full censorship'
|
|
];
|
|
|
|
Map<String, int> tagCount = {};
|
|
Map<String, int> authorCount = {};
|
|
Map<ImageFavoritesComic, int> comicImageCount = {};
|
|
Map<ImageFavoritesComic, int> comicMaxPages = {};
|
|
|
|
for (var comic in comics) {
|
|
for (var tag in comic.tags) {
|
|
String finalTag = tag;
|
|
tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1;
|
|
}
|
|
|
|
if (comic.author != "") {
|
|
String finalAuthor = comic.author;
|
|
authorCount[finalAuthor] =
|
|
(authorCount[finalAuthor] ?? 0) + comic.images.length;
|
|
}
|
|
// 小于10页的漫画不统计
|
|
if (comic.maxPageFromEp < 10) {
|
|
continue;
|
|
}
|
|
comicImageCount[comic] =
|
|
(comicImageCount[comic] ?? 0) + comic.images.length;
|
|
comicMaxPages[comic] = (comicMaxPages[comic] ?? 0) + comic.maxPageFromEp;
|
|
}
|
|
|
|
// 按数量排序标签
|
|
List<String> sortedTags = tagCount.keys.toList()
|
|
..sort((a, b) => tagCount[b]!.compareTo(tagCount[a]!));
|
|
|
|
// 按数量排序作者
|
|
List<String> sortedAuthors = authorCount.keys.toList()
|
|
..sort((a, b) => authorCount[b]!.compareTo(authorCount[a]!));
|
|
|
|
// 按收藏数量排序漫画
|
|
List<MapEntry<ImageFavoritesComic, int>> sortedComicsByNum =
|
|
comicImageCount.entries.toList()
|
|
..sort((a, b) => b.value.compareTo(a.value));
|
|
|
|
validateTag(String tag) {
|
|
if (tag.startsWith("Category:")) {
|
|
return false;
|
|
}
|
|
return !exceptTags.contains(tag.split(":").last.toLowerCase()) &&
|
|
!tag.isNum;
|
|
}
|
|
|
|
return ImageFavoritesComputed(
|
|
sortedTags
|
|
.where(validateTag)
|
|
.map((tag) => TextWithCount(tag, tagCount[tag]!))
|
|
.take(maxLength)
|
|
.toList(),
|
|
sortedAuthors
|
|
.map((author) => TextWithCount(author, authorCount[author]!))
|
|
.take(maxLength)
|
|
.toList(),
|
|
sortedComicsByNum
|
|
.map((comic) => TextWithCount(comic.key.title, comic.value))
|
|
.take(maxLength)
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
ImageFavoritesComic? find(String id, String sourceKey) {
|
|
var row = _db.select("""
|
|
select * from image_favorites
|
|
where id == ? and source_key == ?;
|
|
""", [id, sourceKey]);
|
|
if (row.isEmpty) {
|
|
return null;
|
|
}
|
|
return ImageFavoritesComic.fromRow(row.first);
|
|
}
|
|
}
|
|
|
|
class TextWithCount {
|
|
final String text;
|
|
final int count;
|
|
|
|
const TextWithCount(this.text, this.count);
|
|
}
|
|
|
|
class ImageFavoritesComputed {
|
|
/// 基于收藏的标签数排序
|
|
final List<TextWithCount> tags;
|
|
|
|
/// 基于收藏的作者数排序
|
|
final List<TextWithCount> authors;
|
|
|
|
/// 基于喜欢的图片数排序
|
|
final List<TextWithCount> comics;
|
|
|
|
/// 计算后的图片收藏数据
|
|
const ImageFavoritesComputed(
|
|
this.tags,
|
|
this.authors,
|
|
this.comics,
|
|
);
|
|
|
|
bool get isEmpty => tags.isEmpty && authors.isEmpty && comics.isEmpty;
|
|
}
|