Add viewed history, diagnostics, tag chips and SWF viewer
This commit is contained in:
@@ -4,10 +4,12 @@ export 'models/content_provider_config.dart';
|
||||
export 'models/favorite.dart';
|
||||
export 'models/post.dart';
|
||||
export 'models/post_comment.dart';
|
||||
export 'models/provider_diagnostics.dart';
|
||||
export 'models/provider_health.dart';
|
||||
export 'models/search_history.dart';
|
||||
export 'models/tag_suggestion.dart';
|
||||
export 'models/top_period_filter.dart';
|
||||
export 'models/viewed_post.dart';
|
||||
export 'providers/content_provider.dart';
|
||||
export 'providers/provider_manager.dart';
|
||||
export 'services/collection_service.dart';
|
||||
@@ -17,3 +19,5 @@ export 'services/feed_service.dart';
|
||||
export 'services/provider_check_service.dart';
|
||||
export 'services/search_service.dart';
|
||||
export 'services/settings_service.dart';
|
||||
export 'services/viewed_history_service.dart';
|
||||
export 'utils/smart_blacklist.dart';
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../core/cache/image_cache_service.dart';
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/http/network_info.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../providers/provider_factory.dart';
|
||||
import '../providers/provider_manager.dart';
|
||||
import '../repositories/collection_repository.dart';
|
||||
@@ -13,6 +14,7 @@ import '../repositories/favorite_repository.dart';
|
||||
import '../repositories/post_repository.dart';
|
||||
import '../repositories/provider_repository.dart';
|
||||
import '../repositories/search_repository.dart';
|
||||
import '../repositories/viewed_post_repository.dart';
|
||||
import '../services/collection_service.dart';
|
||||
import '../services/download_service.dart';
|
||||
import '../services/favorite_service.dart';
|
||||
@@ -20,6 +22,7 @@ import '../services/feed_service.dart';
|
||||
import '../services/provider_check_service.dart';
|
||||
import '../services/search_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/viewed_history_service.dart';
|
||||
|
||||
final appDatabaseProvider = FutureProvider<AppDatabase>((ref) async {
|
||||
final database = await AppDatabase.open();
|
||||
@@ -54,6 +57,10 @@ final searchRepositoryProvider = Provider<SearchRepository>((ref) {
|
||||
return SearchRepository(ref.watch(databaseServiceProvider));
|
||||
});
|
||||
|
||||
final viewedPostRepositoryProvider = Provider<ViewedPostRepository>((ref) {
|
||||
return ViewedPostRepository(ref.watch(databaseServiceProvider));
|
||||
});
|
||||
|
||||
final favoriteRepositoryProvider = Provider<FavoriteRepository>((ref) {
|
||||
return FavoriteRepository(
|
||||
ref.watch(databaseServiceProvider),
|
||||
@@ -88,9 +95,22 @@ final feedServiceProvider = Provider<FeedService>((ref) {
|
||||
ref.watch(providerManagerProvider),
|
||||
ref.watch(cacheServiceProvider),
|
||||
ref.watch(settingsServiceProvider),
|
||||
ref.watch(viewedHistoryServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final viewedHistoryServiceProvider = Provider<ViewedHistoryService>((ref) {
|
||||
return ViewedHistoryService(
|
||||
ref.watch(viewedPostRepositoryProvider),
|
||||
ref.watch(postRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final viewedKeysProvider = FutureProvider<Set<String>>((ref) async {
|
||||
final result = await ref.watch(viewedHistoryServiceProvider).getViewedKeys();
|
||||
return result is Success<Set<String>> ? result.data : <String>{};
|
||||
});
|
||||
|
||||
final searchServiceProvider = Provider<SearchService>((ref) {
|
||||
return SearchService(
|
||||
ref.watch(searchRepositoryProvider),
|
||||
|
||||
@@ -30,7 +30,10 @@ class GelbooruMapper {
|
||||
fileUrl);
|
||||
final previewUrl =
|
||||
_string(json['preview_url'] ?? json['previewUrl'] ?? sampleUrl);
|
||||
final tags = _tags(json['tags'] ?? json['tag_string']);
|
||||
final explicitGroups = _tagGroupsFromJson(json);
|
||||
final tags = explicitGroups.isEmpty
|
||||
? _tags(json['tags'] ?? json['tag_string'])
|
||||
: explicitGroups.values.expand((items) => items).toSet().toList();
|
||||
return Post(
|
||||
id: id,
|
||||
providerId: providerId,
|
||||
@@ -46,7 +49,7 @@ class GelbooruMapper {
|
||||
createdAt: _date(json['created_at'] ?? json['createdAt']),
|
||||
fileType: _fileType(fileUrl, json['file_ext']),
|
||||
score: _int(json['score']),
|
||||
tagGroups: _tagGroups(tags),
|
||||
tagGroups: explicitGroups.isEmpty ? _tagGroups(tags) : explicitGroups,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,4 +111,21 @@ class GelbooruMapper {
|
||||
static Map<String, List<String>> _tagGroups(List<String> tags) {
|
||||
return tags.isEmpty ? const {} : {'general': tags};
|
||||
}
|
||||
|
||||
static Map<String, List<String>> _tagGroupsFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) {
|
||||
final groups = <String, List<String>>{};
|
||||
void add(String key, dynamic value) {
|
||||
final tags = _tags(value);
|
||||
if (tags.isNotEmpty) groups[key] = tags;
|
||||
}
|
||||
|
||||
add('general', json['tag_string_general'] ?? json['tags_general']);
|
||||
add('artist', json['tag_string_artist'] ?? json['tags_artist']);
|
||||
add('copyright', json['tag_string_copyright'] ?? json['tags_copyright']);
|
||||
add('character', json['tag_string_character'] ?? json['tags_character']);
|
||||
add('meta', json['tag_string_meta'] ?? json['tags_meta']);
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class ProviderDiagnostics {
|
||||
const ProviderDiagnostics({
|
||||
required this.providerId,
|
||||
required this.lastSearchAt,
|
||||
required this.lastResultCount,
|
||||
this.lastErrorMessage,
|
||||
});
|
||||
|
||||
final String providerId;
|
||||
final DateTime lastSearchAt;
|
||||
final int lastResultCount;
|
||||
final String? lastErrorMessage;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
class ViewedPost {
|
||||
const ViewedPost({
|
||||
required this.viewedKey,
|
||||
required this.providerId,
|
||||
required this.postId,
|
||||
required this.viewedAt,
|
||||
});
|
||||
|
||||
final String viewedKey;
|
||||
final String providerId;
|
||||
final String postId;
|
||||
final DateTime viewedAt;
|
||||
|
||||
static String keyFor(String providerId, String postId) =>
|
||||
'$providerId:$postId';
|
||||
}
|
||||
@@ -43,7 +43,7 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/posts.json',
|
||||
queryParameters: {
|
||||
'page': page <= 0 ? 1 : page,
|
||||
'page': page + 1,
|
||||
'limit': limit,
|
||||
'tags': queryTags.join(' '),
|
||||
..._queryParameters,
|
||||
@@ -107,8 +107,7 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
final data = response.data;
|
||||
final items = data is List ? data : const [];
|
||||
final items = _commentItems(response.data);
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
@@ -117,9 +116,13 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
|
||||
id: (json['id'] ?? '').toString(),
|
||||
postId: (json['post_id'] ?? postId).toString(),
|
||||
providerId: id,
|
||||
authorName: (json['creator_name'] ?? json['creator_id'] ?? 'user')
|
||||
authorName: (json['creator_name'] ??
|
||||
json['creator_id'] ??
|
||||
json['updater_name'] ??
|
||||
'user')
|
||||
.toString(),
|
||||
body: (json['body'] ?? json['text'] ?? json['comment'] ?? '')
|
||||
.toString(),
|
||||
body: (json['body'] ?? '').toString(),
|
||||
createdAt:
|
||||
DateTime.tryParse((json['created_at'] ?? '').toString()) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
@@ -129,6 +132,20 @@ class DanbooruProvider implements ContentProvider, CommentProvider {
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<dynamic> _commentItems(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is! Map) return const [];
|
||||
final json = Map<String, dynamic>.from(data);
|
||||
final comments = json['comments'];
|
||||
if (comments is List) return comments;
|
||||
final records = json['records'];
|
||||
if (records is List) return records;
|
||||
final comment = json['comment'];
|
||||
if (comment is List) return comment;
|
||||
if (comment is Map) return [comment];
|
||||
return const [];
|
||||
}
|
||||
|
||||
List<String> _topTags(TopPeriodFilter period) {
|
||||
return switch (period) {
|
||||
TopPeriodFilter.none => const [],
|
||||
|
||||
@@ -3,12 +3,14 @@ import 'package:dio/dio.dart';
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../mappers/e621_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/tag_suggestion.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class E621Provider implements ContentProvider, TagSuggestionProvider {
|
||||
class E621Provider
|
||||
implements ContentProvider, TagSuggestionProvider, CommentProvider {
|
||||
E621Provider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -38,7 +40,7 @@ class E621Provider implements ContentProvider, TagSuggestionProvider {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/posts.json',
|
||||
queryParameters: {
|
||||
'page': page <= 0 ? 1 : page,
|
||||
'page': page + 1,
|
||||
'limit': limit.clamp(1, 75),
|
||||
'tags': [
|
||||
...tags,
|
||||
@@ -125,6 +127,37 @@ class E621Provider implements ContentProvider, TagSuggestionProvider {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getComments(String postId) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/comments.json',
|
||||
queryParameters: {
|
||||
'search[post_id]': postId,
|
||||
'limit': 50,
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
final items = response.data is List ? response.data as List : const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
return PostComment(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
postId: (json['post_id'] ?? postId).toString(),
|
||||
providerId: id,
|
||||
authorName: (json['creator_name'] ?? json['creator_id'] ?? 'user')
|
||||
.toString(),
|
||||
body: (json['body'] ?? '').toString(),
|
||||
createdAt:
|
||||
DateTime.tryParse((json['created_at'] ?? '').toString()) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
})
|
||||
.where((comment) => comment.body.trim().isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
String _rating(String value) {
|
||||
return switch (value.toLowerCase()) {
|
||||
'safe' => 's',
|
||||
|
||||
@@ -148,13 +148,7 @@ class GelbooruProvider implements ContentProvider, CommentProvider {
|
||||
}
|
||||
|
||||
List<PostComment> _commentsFromResponse(dynamic data, String postId) {
|
||||
final raw = switch (data) {
|
||||
List() => data,
|
||||
{'comments': final List comments} => comments,
|
||||
{'@attributes': _, 'comment': final List comments} => comments,
|
||||
{'comment': final List comments} => comments,
|
||||
_ => const [],
|
||||
};
|
||||
final raw = _commentItems(data);
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
@@ -165,10 +159,18 @@ class GelbooruProvider implements ContentProvider, CommentProvider {
|
||||
providerId: id,
|
||||
authorName: (json['creator'] ??
|
||||
json['creator_name'] ??
|
||||
json['creator_id'] ??
|
||||
json['user'] ??
|
||||
json['username'] ??
|
||||
json['author'] ??
|
||||
'')
|
||||
.toString(),
|
||||
body: (json['body'] ?? json['comment'] ?? '').toString(),
|
||||
body: (json['body'] ??
|
||||
json['comment'] ??
|
||||
json['content'] ??
|
||||
json['text'] ??
|
||||
'')
|
||||
.toString(),
|
||||
createdAt: DateTime.tryParse(
|
||||
(json['created_at'] ?? json['created'] ?? '').toString(),
|
||||
) ??
|
||||
@@ -178,4 +180,66 @@ class GelbooruProvider implements ContentProvider, CommentProvider {
|
||||
.where((comment) => comment.body.trim().isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<dynamic> _commentItems(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is String) return _commentItemsFromText(data);
|
||||
if (data is! Map) return const [];
|
||||
final json = Map<String, dynamic>.from(data);
|
||||
final comments = json['comments'];
|
||||
if (comments is List) return comments;
|
||||
if (comments is Map) return _commentItems(comments);
|
||||
final comment = json['comment'];
|
||||
if (comment is List) return comment;
|
||||
if (comment is Map) return [comment];
|
||||
return const [];
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _commentItemsFromText(String text) {
|
||||
final items = <Map<String, dynamic>>[];
|
||||
for (final match in RegExp(r'<comment\b([^>]*)>(.*?)</comment>',
|
||||
caseSensitive: false, dotAll: true)
|
||||
.allMatches(text)) {
|
||||
final attrs = _attributes(match.group(1) ?? '');
|
||||
final body = _tagValue(match.group(2) ?? '', 'body') ??
|
||||
_tagValue(match.group(2) ?? '', 'comment') ??
|
||||
match.group(2) ??
|
||||
'';
|
||||
items.add({...attrs, 'body': body});
|
||||
}
|
||||
for (final match
|
||||
in RegExp(r'<comment\b([^>]*)/?>', caseSensitive: false, dotAll: true)
|
||||
.allMatches(text)) {
|
||||
final attrs = _attributes(match.group(1) ?? '');
|
||||
if (attrs.isNotEmpty) items.add(attrs);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _attributes(String source) {
|
||||
final result = <String, dynamic>{};
|
||||
for (final match in RegExp(r'''([A-Za-z0-9_:-]+)\s*=\s*["']([^"']*)["']''')
|
||||
.allMatches(source)) {
|
||||
result[match.group(1)!] = _decodeEntities(match.group(2) ?? '');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String? _tagValue(String source, String tag) {
|
||||
final match =
|
||||
RegExp('<$tag[^>]*>(.*?)</$tag>', caseSensitive: false, dotAll: true)
|
||||
.firstMatch(source);
|
||||
final value = match?.group(1);
|
||||
return value == null ? null : _decodeEntities(value.trim());
|
||||
}
|
||||
|
||||
String _decodeEntities(String value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ import 'package:dio/dio.dart';
|
||||
import '../../core/http/dio_client.dart';
|
||||
import '../mappers/moebooru_mapper.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/tag_suggestion.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import 'content_provider.dart';
|
||||
|
||||
class MoebooruProvider implements ContentProvider, TagSuggestionProvider {
|
||||
class MoebooruProvider
|
||||
implements ContentProvider, TagSuggestionProvider, CommentProvider {
|
||||
MoebooruProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -38,7 +40,7 @@ class MoebooruProvider implements ContentProvider, TagSuggestionProvider {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/post.json',
|
||||
queryParameters: {
|
||||
'page': page <= 0 ? 1 : page,
|
||||
'page': page + 1,
|
||||
'limit': limit.clamp(1, 100),
|
||||
'tags': [
|
||||
...tags,
|
||||
@@ -127,6 +129,40 @@ class MoebooruProvider implements ContentProvider, TagSuggestionProvider {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PostComment>> getComments(String postId) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/comment.json',
|
||||
queryParameters: {
|
||||
'post_id': postId,
|
||||
'limit': 50,
|
||||
..._queryParameters,
|
||||
},
|
||||
);
|
||||
final items = response.data is List ? response.data as List : const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) {
|
||||
final json = Map<String, dynamic>.from(item);
|
||||
return PostComment(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
postId: (json['post_id'] ?? postId).toString(),
|
||||
providerId: id,
|
||||
authorName:
|
||||
(json['creator'] ?? json['author'] ?? json['user'] ?? 'user')
|
||||
.toString(),
|
||||
body: (json['body'] ?? json['comment'] ?? json['text'] ?? '')
|
||||
.toString(),
|
||||
createdAt: DateTime.tryParse(
|
||||
(json['created_at'] ?? json['created_on'] ?? '').toString(),
|
||||
) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
})
|
||||
.where((comment) => comment.body.trim().isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<String> _topTags(TopPeriodFilter period) {
|
||||
return switch (period) {
|
||||
TopPeriodFilter.none => const [],
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../core/utils/result.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/post_comment.dart';
|
||||
import '../models/provider_diagnostics.dart';
|
||||
import '../models/provider_health.dart';
|
||||
import '../models/tag_suggestion.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
@@ -97,6 +98,7 @@ class ProviderManager {
|
||||
}
|
||||
|
||||
final posts = <Post>[];
|
||||
final seen = <String>{};
|
||||
for (final provider in providers) {
|
||||
try {
|
||||
final providerPosts = await provider.searchPosts(
|
||||
@@ -106,8 +108,25 @@ class ProviderManager {
|
||||
rating: rating,
|
||||
topPeriod: topPeriod,
|
||||
);
|
||||
posts.addAll(providerPosts);
|
||||
for (final post in providerPosts) {
|
||||
if (seen.add(post.cacheKey)) posts.add(post);
|
||||
}
|
||||
await _repository.saveDiagnostics(
|
||||
ProviderDiagnostics(
|
||||
providerId: provider.id,
|
||||
lastSearchAt: DateTime.now(),
|
||||
lastResultCount: providerPosts.length,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
await _repository.saveDiagnostics(
|
||||
ProviderDiagnostics(
|
||||
providerId: provider.id,
|
||||
lastSearchAt: DateTime.now(),
|
||||
lastResultCount: 0,
|
||||
lastErrorMessage: 'Search failed',
|
||||
),
|
||||
);
|
||||
await _repository.saveHealth(
|
||||
ProviderHealth(
|
||||
providerId: provider.id,
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/content_provider_config.dart';
|
||||
import '../models/provider_diagnostics.dart';
|
||||
import '../models/provider_health.dart';
|
||||
|
||||
class ProviderRepository {
|
||||
@@ -111,6 +112,14 @@ class ProviderRepository {
|
||||
|
||||
Future<Result<void>> ensureSeedProviders() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
final existingProviders =
|
||||
await isar.providerConfigEntitys.where().findAll();
|
||||
for (final provider in existingProviders) {
|
||||
if (provider.providerId == 'kemono' ||
|
||||
provider.providerId == 'coomer') {
|
||||
await isar.providerConfigEntitys.delete(provider.isarId);
|
||||
}
|
||||
}
|
||||
final seeds = seedProviders();
|
||||
for (final seed in seeds) {
|
||||
final exists = await isar.providerConfigEntitys
|
||||
@@ -186,4 +195,19 @@ class ProviderRepository {
|
||||
return entity?.toModel();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> saveDiagnostics(ProviderDiagnostics diagnostics) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.providerDiagnosticsEntitys.put(
|
||||
ProviderDiagnosticsEntity.fromModel(diagnostics),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<ProviderDiagnostics>>> getDiagnostics() {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final items = await isar.providerDiagnosticsEntitys.where().findAll();
|
||||
return items.map((item) => item.toModel()).toList();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../../core/database/app_database.dart';
|
||||
import '../../core/database/database_service.dart';
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/viewed_post.dart';
|
||||
|
||||
class ViewedPostRepository {
|
||||
ViewedPostRepository(this._databaseService);
|
||||
|
||||
final DatabaseService _databaseService;
|
||||
|
||||
Future<Result<void>> markViewed(String providerId, String postId) {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
final key = ViewedPost.keyFor(providerId, postId);
|
||||
await isar.viewedPostEntitys.put(
|
||||
ViewedPostEntity.fromModel(
|
||||
ViewedPost(
|
||||
viewedKey: key,
|
||||
providerId: providerId,
|
||||
postId: postId,
|
||||
viewedAt: DateTime.now(),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<bool>> exists(String providerId, String postId) {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final entity = await isar.viewedPostEntitys
|
||||
.filter()
|
||||
.viewedKeyEqualTo(ViewedPost.keyFor(providerId, postId))
|
||||
.findFirst();
|
||||
return entity != null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<Set<String>>> keys() {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final items = await isar.viewedPostEntitys.where().findAll();
|
||||
return items.map((item) => item.viewedKey).toSet();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<List<ViewedPost>>> recent() {
|
||||
return _databaseService.safeRead((isar) async {
|
||||
final items = await isar.viewedPostEntitys.where().findAll();
|
||||
items.sort((a, b) => b.viewedAt.compareTo(a.viewedAt));
|
||||
return items.map((item) => item.toModel()).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Result<void>> clear() {
|
||||
return _databaseService.safeWrite((isar) async {
|
||||
await isar.viewedPostEntitys.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,22 @@ import '../../core/utils/result.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/top_period_filter.dart';
|
||||
import '../providers/provider_manager.dart';
|
||||
import '../services/viewed_history_service.dart';
|
||||
import '../utils/smart_blacklist.dart';
|
||||
import 'settings_service.dart';
|
||||
|
||||
class FeedService {
|
||||
FeedService(this._providerManager, this._cacheService, this._settingsService);
|
||||
FeedService(
|
||||
this._providerManager,
|
||||
this._cacheService,
|
||||
this._settingsService,
|
||||
this._viewedHistoryService,
|
||||
);
|
||||
|
||||
final ProviderManager _providerManager;
|
||||
final CacheService _cacheService;
|
||||
final SettingsService _settingsService;
|
||||
final ViewedHistoryService _viewedHistoryService;
|
||||
final Map<String, int> _pages = {};
|
||||
|
||||
String _key(
|
||||
@@ -61,10 +69,65 @@ class FeedService {
|
||||
final posts = (result as Success<List<Post>>).data;
|
||||
_pages[key] = page + 1;
|
||||
final settingsResult = await _settingsService.getSettings();
|
||||
final maxItems = settingsResult is Success<AppSettings>
|
||||
? settingsResult.data.cacheMaxItems
|
||||
: null;
|
||||
await _cacheService.cachePosts(posts, maxItems: maxItems);
|
||||
return Success(posts);
|
||||
final settings = settingsResult is Success<AppSettings>
|
||||
? settingsResult.data
|
||||
: AppSettings.defaults;
|
||||
final viewedKeysResult = settings.hideViewedPosts
|
||||
? await _viewedHistoryService.getViewedKeys()
|
||||
: const Success(<String>{});
|
||||
final viewedKeys = viewedKeysResult is Success<Set<String>>
|
||||
? viewedKeysResult.data
|
||||
: <String>{};
|
||||
final filteredPosts = posts
|
||||
.where((post) => postMatchesRequestedTags(post, tags))
|
||||
.where((post) => postPassesTagFilters(post, settings))
|
||||
.where((post) =>
|
||||
!settings.hideViewedPosts || !viewedKeys.contains(post.cacheKey))
|
||||
.toList(growable: false);
|
||||
await _cacheService.cachePosts(
|
||||
filteredPosts,
|
||||
maxItems: settings.cacheMaxItems,
|
||||
);
|
||||
return Success(filteredPosts);
|
||||
}
|
||||
}
|
||||
|
||||
bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
|
||||
final requested = requestedTags
|
||||
.map((tag) => tag.trim().toLowerCase())
|
||||
.where((tag) => tag.isNotEmpty && !tag.startsWith('-'))
|
||||
.toList(growable: false);
|
||||
if (requested.isEmpty) return true;
|
||||
final postTags = _postTagSet(post);
|
||||
return requested.every(postTags.contains);
|
||||
}
|
||||
|
||||
bool postPassesTagFilters(Post post, AppSettings settings) {
|
||||
final tags = _postTagSet(post);
|
||||
if (tags.isEmpty) return settings.whitelistedTags.isEmpty;
|
||||
|
||||
final blacklist = [
|
||||
...settings.smartBlacklistRules,
|
||||
...settings.blacklistedTags,
|
||||
].map((tag) => tag.trim().toLowerCase()).where((tag) => tag.isNotEmpty);
|
||||
if (blacklist.any((rule) => SmartBlacklistMatcher.matches(post, rule))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final whitelist = settings.whitelistedTags
|
||||
.map((tag) => tag.trim().toLowerCase())
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (whitelist.isEmpty) return true;
|
||||
return whitelist.any(tags.contains);
|
||||
}
|
||||
|
||||
Set<String> _postTagSet(Post post) {
|
||||
return <String>{
|
||||
...post.tags,
|
||||
for (final group in post.tagGroups.values) ...group,
|
||||
}
|
||||
.map((tag) => tag.trim().toLowerCase())
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ class AppSettings {
|
||||
required this.selectedFeedProviderIds,
|
||||
required this.showPostBadges,
|
||||
required this.defaultTopPeriodFilter,
|
||||
required this.blacklistedTags,
|
||||
required this.whitelistedTags,
|
||||
required this.smartBlacklistRules,
|
||||
required this.hideViewedPosts,
|
||||
this.defaultRatingFilter,
|
||||
});
|
||||
|
||||
@@ -40,6 +44,10 @@ class AppSettings {
|
||||
final List<String> selectedFeedProviderIds;
|
||||
final bool showPostBadges;
|
||||
final String defaultTopPeriodFilter;
|
||||
final List<String> blacklistedTags;
|
||||
final List<String> whitelistedTags;
|
||||
final List<String> smartBlacklistRules;
|
||||
final bool hideViewedPosts;
|
||||
final String? defaultRatingFilter;
|
||||
|
||||
static const defaults = AppSettings(
|
||||
@@ -56,6 +64,10 @@ class AppSettings {
|
||||
selectedFeedProviderIds: [],
|
||||
showPostBadges: true,
|
||||
defaultTopPeriodFilter: 'none',
|
||||
blacklistedTags: [],
|
||||
whitelistedTags: [],
|
||||
smartBlacklistRules: [],
|
||||
hideViewedPosts: false,
|
||||
defaultRatingFilter: null,
|
||||
);
|
||||
|
||||
@@ -73,6 +85,10 @@ class AppSettings {
|
||||
List<String>? selectedFeedProviderIds,
|
||||
bool? showPostBadges,
|
||||
String? defaultTopPeriodFilter,
|
||||
List<String>? blacklistedTags,
|
||||
List<String>? whitelistedTags,
|
||||
List<String>? smartBlacklistRules,
|
||||
bool? hideViewedPosts,
|
||||
String? defaultRatingFilter,
|
||||
}) {
|
||||
return AppSettings(
|
||||
@@ -91,6 +107,10 @@ class AppSettings {
|
||||
showPostBadges: showPostBadges ?? this.showPostBadges,
|
||||
defaultTopPeriodFilter:
|
||||
defaultTopPeriodFilter ?? this.defaultTopPeriodFilter,
|
||||
blacklistedTags: blacklistedTags ?? this.blacklistedTags,
|
||||
whitelistedTags: whitelistedTags ?? this.whitelistedTags,
|
||||
smartBlacklistRules: smartBlacklistRules ?? this.smartBlacklistRules,
|
||||
hideViewedPosts: hideViewedPosts ?? this.hideViewedPosts,
|
||||
defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter,
|
||||
);
|
||||
}
|
||||
@@ -109,6 +129,10 @@ class AppSettings {
|
||||
'selectedFeedProviderIds': selectedFeedProviderIds,
|
||||
'showPostBadges': showPostBadges,
|
||||
'defaultTopPeriodFilter': defaultTopPeriodFilter,
|
||||
'blacklistedTags': blacklistedTags,
|
||||
'whitelistedTags': whitelistedTags,
|
||||
'smartBlacklistRules': smartBlacklistRules,
|
||||
'hideViewedPosts': hideViewedPosts,
|
||||
'defaultRatingFilter': defaultRatingFilter,
|
||||
};
|
||||
|
||||
@@ -141,6 +165,19 @@ class AppSettings {
|
||||
(json['showPostBadges'] as bool?) ?? defaults.showPostBadges,
|
||||
defaultTopPeriodFilter: (json['defaultTopPeriodFilter'] as String?) ??
|
||||
defaults.defaultTopPeriodFilter,
|
||||
blacklistedTags: List<String>.from(
|
||||
(json['blacklistedTags'] as List?) ?? defaults.blacklistedTags,
|
||||
),
|
||||
whitelistedTags: List<String>.from(
|
||||
(json['whitelistedTags'] as List?) ?? defaults.whitelistedTags,
|
||||
),
|
||||
smartBlacklistRules: List<String>.from(
|
||||
(json['smartBlacklistRules'] as List?) ??
|
||||
(json['blacklistedTags'] as List?) ??
|
||||
defaults.smartBlacklistRules,
|
||||
),
|
||||
hideViewedPosts:
|
||||
(json['hideViewedPosts'] as bool?) ?? defaults.hideViewedPosts,
|
||||
defaultRatingFilter: json['defaultRatingFilter'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/post.dart';
|
||||
import '../models/viewed_post.dart';
|
||||
import '../repositories/post_repository.dart';
|
||||
import '../repositories/viewed_post_repository.dart';
|
||||
|
||||
class ViewedHistoryService {
|
||||
ViewedHistoryService(this._repository, this._postRepository);
|
||||
|
||||
final ViewedPostRepository _repository;
|
||||
final PostRepository _postRepository;
|
||||
|
||||
Future<Result<void>> markViewed(Post post) async {
|
||||
final cacheResult = await _postRepository.cachePosts([post]);
|
||||
if (cacheResult is Error<void>) return cacheResult;
|
||||
return _repository.markViewed(post.providerId, post.id);
|
||||
}
|
||||
|
||||
Future<Result<bool>> isViewed(String postId, String providerId) {
|
||||
return _repository.exists(providerId, postId);
|
||||
}
|
||||
|
||||
Future<Result<Set<String>>> getViewedKeys() {
|
||||
return _repository.keys();
|
||||
}
|
||||
|
||||
Future<Result<List<Post>>> getViewedPosts() async {
|
||||
final viewedResult = await _repository.recent();
|
||||
if (viewedResult is Error<List<ViewedPost>>) {
|
||||
return Error(viewedResult.failure);
|
||||
}
|
||||
final viewed = (viewedResult as Success<List<ViewedPost>>).data;
|
||||
return _postRepository.getCachedPostsByKeys(
|
||||
viewed.map((item) => item.viewedKey),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> clearHistory() {
|
||||
return _repository.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import '../models/post.dart';
|
||||
|
||||
class SmartBlacklistMatcher {
|
||||
const SmartBlacklistMatcher._();
|
||||
|
||||
static bool matches(Post post, String rule) {
|
||||
final tokens = rule
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((token) => token.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (tokens.isEmpty) return false;
|
||||
return tokens.every((token) => _matchesToken(post, token));
|
||||
}
|
||||
|
||||
static bool _matchesToken(Post post, String token) {
|
||||
final parts = token.split(':');
|
||||
if (parts.length >= 2) {
|
||||
final key = parts.first;
|
||||
final value = parts.sublist(1).join(':');
|
||||
return switch (key) {
|
||||
'provider' => post.providerId.toLowerCase() == value,
|
||||
'rating' => post.rating.toLowerCase() == value ||
|
||||
post.rating.toLowerCase().startsWith(value),
|
||||
'type' || 'filetype' => post.fileType.toLowerCase().contains(value),
|
||||
'score' => _scoreMatches(post.score, value),
|
||||
'artist' ||
|
||||
'character' ||
|
||||
'copyright' ||
|
||||
'meta' ||
|
||||
'general' =>
|
||||
_groupContains(post, key, value),
|
||||
_ => _allTags(post).contains(token),
|
||||
};
|
||||
}
|
||||
return _allTags(post).contains(token);
|
||||
}
|
||||
|
||||
static bool _scoreMatches(int score, String value) {
|
||||
if (value.startsWith('>=')) {
|
||||
return score >= (int.tryParse(value.substring(2)) ?? score + 1);
|
||||
}
|
||||
if (value.startsWith('<=')) {
|
||||
return score <= (int.tryParse(value.substring(2)) ?? score - 1);
|
||||
}
|
||||
if (value.startsWith('>')) {
|
||||
return score > (int.tryParse(value.substring(1)) ?? score);
|
||||
}
|
||||
if (value.startsWith('<')) {
|
||||
return score < (int.tryParse(value.substring(1)) ?? score);
|
||||
}
|
||||
return score == int.tryParse(value);
|
||||
}
|
||||
|
||||
static bool _groupContains(Post post, String group, String tag) {
|
||||
return (post.tagGroups[group] ?? const [])
|
||||
.map((value) => value.toLowerCase())
|
||||
.contains(tag);
|
||||
}
|
||||
|
||||
static Set<String> _allTags(Post post) {
|
||||
return {
|
||||
...post.tags,
|
||||
for (final group in post.tagGroups.values) ...group,
|
||||
}.map((tag) => tag.toLowerCase()).toSet();
|
||||
}
|
||||
}
|
||||
|
||||
List<String> similarTagsFor(Post post) {
|
||||
final priority = [
|
||||
...post.tagGroups['artist'] ?? const <String>[],
|
||||
...post.tagGroups['character'] ?? const <String>[],
|
||||
...post.tagGroups['copyright'] ?? const <String>[],
|
||||
].where((tag) => tag.trim().isNotEmpty).toSet().take(5).toList();
|
||||
if (priority.isNotEmpty) return priority;
|
||||
return post.tags.where((tag) => tag.trim().isNotEmpty).take(5).toList();
|
||||
}
|
||||
Reference in New Issue
Block a user