diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart index 7761778..f0211ab 100644 --- a/lib/backend/backend.dart +++ b/lib/backend/backend.dart @@ -6,6 +6,7 @@ export 'models/post.dart'; export 'models/post_comment.dart'; export 'models/provider_health.dart'; export 'models/search_history.dart'; +export 'models/tag_suggestion.dart'; export 'models/top_period_filter.dart'; export 'providers/content_provider.dart'; export 'providers/provider_manager.dart'; diff --git a/lib/backend/di/backend_providers.dart b/lib/backend/di/backend_providers.dart index a092f5b..48b091e 100644 --- a/lib/backend/di/backend_providers.dart +++ b/lib/backend/di/backend_providers.dart @@ -92,7 +92,10 @@ final feedServiceProvider = Provider((ref) { }); final searchServiceProvider = Provider((ref) { - return SearchService(ref.watch(searchRepositoryProvider)); + return SearchService( + ref.watch(searchRepositoryProvider), + ref.watch(providerManagerProvider), + ); }); final providerCheckServiceProvider = Provider((ref) { diff --git a/lib/backend/mappers/danbooru_mapper.dart b/lib/backend/mappers/danbooru_mapper.dart index 9858436..282b5ea 100644 --- a/lib/backend/mappers/danbooru_mapper.dart +++ b/lib/backend/mappers/danbooru_mapper.dart @@ -26,6 +26,10 @@ class DanbooruMapper { final sampleUrl = _string(json['large_file_url'] ?? json['sample_file_url'] ?? fileUrl); final previewUrl = _string(json['preview_file_url'] ?? sampleUrl); + final tagGroups = _tagGroups(json); + final tags = tagGroups.isEmpty + ? _tags(json['tag_string'] ?? json['tags']) + : tagGroups.values.expand((items) => items).toSet().toList(); return Post( id: _string(json['id']), providerId: providerId, @@ -33,15 +37,16 @@ class DanbooruMapper { previewUrl: previewUrl, sampleUrl: sampleUrl, fileUrl: fileUrl, - tags: _tags(json['tag_string'] ?? json['tags']), + tags: tags, rating: _string(json['rating'], fallback: 'unknown'), width: _int(json['image_width'] ?? json['width']), height: _int(json['image_height'] ?? json['height']), source: _nullableString(json['source']), createdAt: DateTime.tryParse(_string(json['created_at'])) ?? DateTime.now(), - fileType: _fileType(fileUrl), + fileType: _fileType(fileUrl, json['file_ext']), score: _int(json['score']), + tagGroups: tagGroups.isEmpty ? {'general': tags} : tagGroups, ); } @@ -70,11 +75,32 @@ class DanbooruMapper { return text == null || text.isEmpty ? null : text; } - static String _fileType(String url) { - final lower = url.toLowerCase(); - if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video'; - if (lower.endsWith('.gif')) return 'gif'; + static String _fileType(String url, dynamic fileExt) { + final lower = url.toLowerCase().split('?').first; + final ext = _string(fileExt).toLowerCase(); + if (lower.endsWith('.webm') || + lower.endsWith('.mp4') || + ext == 'webm' || + ext == 'mp4') { + return 'video'; + } + if (lower.endsWith('.gif') || ext == 'gif') return 'gif'; if (lower.isEmpty) return 'unknown'; return 'image'; } + + static Map> _tagGroups(Map json) { + final groups = >{}; + void add(String key, dynamic value) { + final tags = _tags(value); + if (tags.isNotEmpty) groups[key] = tags; + } + + add('general', json['tag_string_general']); + add('artist', json['tag_string_artist']); + add('copyright', json['tag_string_copyright']); + add('character', json['tag_string_character']); + add('meta', json['tag_string_meta']); + return groups; + } } diff --git a/lib/backend/mappers/e621_mapper.dart b/lib/backend/mappers/e621_mapper.dart new file mode 100644 index 0000000..fe26c4b --- /dev/null +++ b/lib/backend/mappers/e621_mapper.dart @@ -0,0 +1,100 @@ +import '../models/post.dart'; + +class E621Mapper { + static List postsFromResponse( + dynamic data, { + required String providerId, + required String providerName, + }) { + final posts = data is Map ? data['posts'] : data; + final items = posts is List ? posts : const []; + return items + .whereType() + .map((item) => postFromJson( + Map.from(item), + providerId: providerId, + providerName: providerName, + )) + .toList(); + } + + static Post postFromJson( + Map json, { + required String providerId, + required String providerName, + }) { + final file = Map.from((json['file'] as Map?) ?? const {}); + final preview = + Map.from((json['preview'] as Map?) ?? const {}); + final sample = + Map.from((json['sample'] as Map?) ?? const {}); + final score = + Map.from((json['score'] as Map?) ?? const {}); + final tagsMap = + Map.from((json['tags'] as Map?) ?? const {}); + final tagGroups = { + for (final entry in tagsMap.entries) + entry.key: List.from((entry.value as List?) ?? const []), + }..removeWhere((_, value) => value.isEmpty); + final tags = tagGroups.values.expand((items) => items).toSet().toList(); + final fileUrl = _string(file['url']); + return Post( + id: _string(json['id']), + providerId: providerId, + providerName: providerName, + previewUrl: _string(preview['url'] ?? sample['url'] ?? fileUrl), + sampleUrl: _string(sample['url'] ?? fileUrl), + fileUrl: fileUrl, + tags: tags, + rating: _rating(_string(json['rating'], fallback: 'unknown')), + width: _int(file['width']), + height: _int(file['height']), + source: _sources(json['sources']), + createdAt: + DateTime.tryParse(_string(json['created_at'])) ?? DateTime.now(), + fileType: _fileType(fileUrl, file['ext']), + score: _int(score['total'] ?? json['score']), + tagGroups: tagGroups, + ); + } + + static String _rating(String value) { + return switch (value.toLowerCase()) { + 's' => 'safe', + 'q' => 'questionable', + 'e' => 'explicit', + _ => value, + }; + } + + static String? _sources(dynamic value) { + if (value is List && value.isNotEmpty) return value.first.toString(); + final text = value?.toString(); + return text == null || text.isEmpty ? null : text; + } + + static int _int(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? '') ?? 0; + } + + static String _string(dynamic value, {String fallback = ''}) { + final text = value?.toString(); + return text == null || text.isEmpty ? fallback : text; + } + + static String _fileType(String url, dynamic fileExt) { + final lower = url.toLowerCase().split('?').first; + final ext = _string(fileExt).toLowerCase(); + if (lower.endsWith('.webm') || + lower.endsWith('.mp4') || + ext == 'webm' || + ext == 'mp4') { + return 'video'; + } + if (lower.endsWith('.gif') || ext == 'gif') return 'gif'; + if (lower.isEmpty) return 'unknown'; + return 'image'; + } +} diff --git a/lib/backend/mappers/gelbooru_mapper.dart b/lib/backend/mappers/gelbooru_mapper.dart index 4432d57..5a9c39f 100644 --- a/lib/backend/mappers/gelbooru_mapper.dart +++ b/lib/backend/mappers/gelbooru_mapper.dart @@ -30,6 +30,7 @@ class GelbooruMapper { fileUrl); final previewUrl = _string(json['preview_url'] ?? json['previewUrl'] ?? sampleUrl); + final tags = _tags(json['tags'] ?? json['tag_string']); return Post( id: id, providerId: providerId, @@ -37,14 +38,15 @@ class GelbooruMapper { previewUrl: previewUrl, sampleUrl: sampleUrl, fileUrl: fileUrl, - tags: _tags(json['tags'] ?? json['tag_string']), + tags: tags, rating: _string(json['rating'], fallback: 'unknown'), width: _int(json['width']), height: _int(json['height']), source: _nullableString(json['source']), createdAt: _date(json['created_at'] ?? json['createdAt']), - fileType: _fileType(fileUrl), + fileType: _fileType(fileUrl, json['file_ext']), score: _int(json['score']), + tagGroups: _tagGroups(tags), ); } @@ -89,11 +91,21 @@ class GelbooruMapper { return DateTime.tryParse(value?.toString() ?? '') ?? DateTime.now(); } - static String _fileType(String url) { - final lower = url.toLowerCase(); - if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video'; - if (lower.endsWith('.gif')) return 'gif'; + static String _fileType(String url, dynamic fileExt) { + final lower = url.toLowerCase().split('?').first; + final ext = _string(fileExt).toLowerCase(); + if (lower.endsWith('.webm') || + lower.endsWith('.mp4') || + ext == 'webm' || + ext == 'mp4') { + return 'video'; + } + if (lower.endsWith('.gif') || ext == 'gif') return 'gif'; if (lower.isEmpty) return 'unknown'; return 'image'; } + + static Map> _tagGroups(List tags) { + return tags.isEmpty ? const {} : {'general': tags}; + } } diff --git a/lib/backend/mappers/moebooru_mapper.dart b/lib/backend/mappers/moebooru_mapper.dart new file mode 100644 index 0000000..76dd429 --- /dev/null +++ b/lib/backend/mappers/moebooru_mapper.dart @@ -0,0 +1,20 @@ +import 'danbooru_mapper.dart'; +import '../models/post.dart'; + +class MoebooruMapper { + static List postsFromResponse( + dynamic data, { + required String providerId, + required String providerName, + }) { + final items = data is List ? data : const []; + return items + .whereType() + .map((item) => DanbooruMapper.postFromJson( + Map.from(item), + providerId: providerId, + providerName: providerName, + )) + .toList(); + } +} diff --git a/lib/backend/models/post.dart b/lib/backend/models/post.dart index 3021c83..d61c7d0 100644 --- a/lib/backend/models/post.dart +++ b/lib/backend/models/post.dart @@ -14,6 +14,7 @@ class Post { required this.createdAt, required this.fileType, required this.score, + this.tagGroups = const {}, }); final String id; @@ -30,6 +31,7 @@ class Post { final DateTime createdAt; final String fileType; final int score; + final Map> tagGroups; String get cacheKey => '$providerId:$id'; @@ -48,6 +50,7 @@ class Post { 'createdAt': createdAt.toIso8601String(), 'fileType': fileType, 'score': score, + 'tagGroups': tagGroups, }; factory Post.fromJson(Map json) => Post( @@ -66,5 +69,12 @@ class Post { DateTime.fromMillisecondsSinceEpoch(0), fileType: (json['fileType'] as String?) ?? 'unknown', score: (json['score'] as num?)?.toInt() ?? 0, + tagGroups: (json['tagGroups'] as Map?)?.map( + (key, value) => MapEntry( + key.toString(), + List.from((value as List?) ?? const []), + ), + ) ?? + const {}, ); } diff --git a/lib/backend/models/tag_suggestion.dart b/lib/backend/models/tag_suggestion.dart new file mode 100644 index 0000000..a95c795 --- /dev/null +++ b/lib/backend/models/tag_suggestion.dart @@ -0,0 +1,38 @@ +enum TagCategory { + general, + artist, + copyright, + character, + meta, + species, + unknown, +} + +class TagSuggestion { + const TagSuggestion({ + required this.name, + required this.category, + required this.postCount, + required this.providerId, + }); + + final String name; + final TagCategory category; + final int postCount; + final String providerId; + + String get categoryLabel => category.name; +} + +TagCategory tagCategoryFromString(String? value) { + final normalized = (value ?? '').toLowerCase(); + return switch (normalized) { + '0' || 'general' => TagCategory.general, + '1' || 'artist' => TagCategory.artist, + '3' || 'copyright' || 'circle' => TagCategory.copyright, + '4' || 'character' => TagCategory.character, + '5' || 'meta' || 'metadata' => TagCategory.meta, + 'species' => TagCategory.species, + _ => TagCategory.unknown, + }; +} diff --git a/lib/backend/providers/content_provider.dart b/lib/backend/providers/content_provider.dart index 77582cc..fd56c0d 100644 --- a/lib/backend/providers/content_provider.dart +++ b/lib/backend/providers/content_provider.dart @@ -1,6 +1,7 @@ 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'; abstract class ContentProvider { @@ -24,3 +25,7 @@ abstract class ContentProvider { abstract class CommentProvider { Future> getComments(String postId); } + +abstract class TagSuggestionProvider { + Future> suggestTags(String query, {int limit = 20}); +} diff --git a/lib/backend/providers/e621_provider.dart b/lib/backend/providers/e621_provider.dart new file mode 100644 index 0000000..c854050 --- /dev/null +++ b/lib/backend/providers/e621_provider.dart @@ -0,0 +1,150 @@ +import 'package:dio/dio.dart'; + +import '../../core/http/dio_client.dart'; +import '../mappers/e621_mapper.dart'; +import '../models/post.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 { + E621Provider({ + required this.id, + required this.name, + required this.baseUrl, + required DioClient dioClient, + Map queryParameters = const {}, + }) : _dio = dioClient.dio, + _queryParameters = queryParameters; + + @override + final String id; + @override + final String name; + @override + final String baseUrl; + final Dio _dio; + final Map _queryParameters; + + @override + Future> searchPosts({ + required List tags, + required int page, + int limit = 50, + String? rating, + TopPeriodFilter topPeriod = TopPeriodFilter.none, + }) async { + final response = await _dio.get( + '/posts.json', + queryParameters: { + 'page': page <= 0 ? 1 : page, + 'limit': limit.clamp(1, 75), + 'tags': [ + ...tags, + if (rating != null && rating.isNotEmpty) 'rating:${_rating(rating)}', + ..._topTags(topPeriod), + ].join(' '), + ..._queryParameters, + }, + ); + return E621Mapper.postsFromResponse( + response.data, + providerId: id, + providerName: name, + ); + } + + @override + Future getPost(String id) async { + final response = await _dio.get( + '/posts/$id.json', + queryParameters: _queryParameters, + ); + final posts = E621Mapper.postsFromResponse( + response.data, + providerId: this.id, + providerName: name, + ); + return posts.isEmpty ? null : posts.first; + } + + @override + Future checkHealth() async { + final startedAt = DateTime.now(); + try { + await _dio.get( + '/posts.json', + queryParameters: {'limit': 1, ..._queryParameters}, + ); + return ProviderHealth( + providerId: id, + status: ProviderStatus.online, + pingMs: DateTime.now().difference(startedAt).inMilliseconds, + lastCheckedAt: DateTime.now(), + apiVersion: 'e621', + ); + } catch (error) { + return ProviderHealth( + providerId: id, + status: ProviderStatus.offline, + pingMs: DateTime.now().difference(startedAt).inMilliseconds, + lastCheckedAt: DateTime.now(), + errorMessage: error.toString(), + apiVersion: 'e621', + ); + } + } + + @override + Future> suggestTags(String query, + {int limit = 20}) async { + if (query.trim().isEmpty) return const []; + final response = await _dio.get( + '/tags.json', + queryParameters: { + 'search[name_matches]': '${query.trim()}*', + 'search[order]': 'count', + 'limit': limit.clamp(1, 50), + ..._queryParameters, + }, + ); + final items = response.data is List ? response.data as List : const []; + return items + .whereType() + .map((item) { + final json = Map.from(item); + return TagSuggestion( + name: (json['name'] ?? '').toString(), + category: tagCategoryFromString(json['category']?.toString()), + postCount: _int(json['post_count']), + providerId: id, + ); + }) + .where((tag) => tag.name.isNotEmpty) + .toList(); + } + + String _rating(String value) { + return switch (value.toLowerCase()) { + 'safe' => 's', + 'questionable' => 'q', + 'explicit' => 'e', + _ => value, + }; + } + + List _topTags(TopPeriodFilter period) { + return switch (period) { + TopPeriodFilter.none => const [], + TopPeriodFilter.month => const ['order:rank'], + TopPeriodFilter.year || TopPeriodFilter.allTime => const ['order:score'], + }; + } + + int _int(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? '') ?? 0; + } +} diff --git a/lib/backend/providers/moebooru_provider.dart b/lib/backend/providers/moebooru_provider.dart new file mode 100644 index 0000000..25de230 --- /dev/null +++ b/lib/backend/providers/moebooru_provider.dart @@ -0,0 +1,145 @@ +import 'package:dio/dio.dart'; + +import '../../core/http/dio_client.dart'; +import '../mappers/moebooru_mapper.dart'; +import '../models/post.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 { + MoebooruProvider({ + required this.id, + required this.name, + required this.baseUrl, + required DioClient dioClient, + Map queryParameters = const {}, + }) : _dio = dioClient.dio, + _queryParameters = queryParameters; + + @override + final String id; + @override + final String name; + @override + final String baseUrl; + final Dio _dio; + final Map _queryParameters; + + @override + Future> searchPosts({ + required List tags, + required int page, + int limit = 50, + String? rating, + TopPeriodFilter topPeriod = TopPeriodFilter.none, + }) async { + final response = await _dio.get( + '/post.json', + queryParameters: { + 'page': page <= 0 ? 1 : page, + 'limit': limit.clamp(1, 100), + 'tags': [ + ...tags, + if (rating != null && rating.isNotEmpty) 'rating:$rating', + ..._topTags(topPeriod), + ].join(' '), + ..._queryParameters, + }, + ); + return MoebooruMapper.postsFromResponse( + response.data, + providerId: id, + providerName: name, + ); + } + + @override + Future getPost(String id) async { + final response = await _dio.get( + '/post.json', + queryParameters: {'id': id, 'limit': 1, ..._queryParameters}, + ); + final posts = MoebooruMapper.postsFromResponse( + response.data, + providerId: this.id, + providerName: name, + ); + return posts.isEmpty ? null : posts.first; + } + + @override + Future checkHealth() async { + final startedAt = DateTime.now(); + try { + await _dio.get( + '/post.json', + queryParameters: {'limit': 1, ..._queryParameters}, + ); + return ProviderHealth( + providerId: id, + status: ProviderStatus.online, + pingMs: DateTime.now().difference(startedAt).inMilliseconds, + lastCheckedAt: DateTime.now(), + apiVersion: 'moebooru', + ); + } catch (error) { + return ProviderHealth( + providerId: id, + status: ProviderStatus.offline, + pingMs: DateTime.now().difference(startedAt).inMilliseconds, + lastCheckedAt: DateTime.now(), + errorMessage: error.toString(), + apiVersion: 'moebooru', + ); + } + } + + @override + Future> suggestTags(String query, + {int limit = 20}) async { + if (query.trim().isEmpty) return const []; + final response = await _dio.get( + '/tag.json', + queryParameters: { + 'name_pattern': '${query.trim()}*', + 'limit': limit.clamp(1, 50), + 'order': 'count', + ..._queryParameters, + }, + ); + final items = response.data is List ? response.data as List : const []; + return items + .whereType() + .map((item) { + final json = Map.from(item); + return TagSuggestion( + name: (json['name'] ?? '').toString(), + category: tagCategoryFromString( + (json['type'] ?? json['tag_type']).toString(), + ), + postCount: _int(json['count'] ?? json['post_count']), + providerId: id, + ); + }) + .where((tag) => tag.name.isNotEmpty) + .toList(); + } + + List _topTags(TopPeriodFilter period) { + return switch (period) { + TopPeriodFilter.none => const [], + TopPeriodFilter.month || + TopPeriodFilter.year || + TopPeriodFilter.allTime => + const ['order:score'], + }; + } + + int _int(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? '') ?? 0; + } +} diff --git a/lib/backend/providers/provider_factory.dart b/lib/backend/providers/provider_factory.dart index 816f62b..eb812db 100644 --- a/lib/backend/providers/provider_factory.dart +++ b/lib/backend/providers/provider_factory.dart @@ -3,7 +3,9 @@ import '../models/content_provider_config.dart'; import 'content_provider.dart'; import 'custom_provider.dart'; import 'danbooru_provider.dart'; +import 'e621_provider.dart'; import 'gelbooru_provider.dart'; +import 'moebooru_provider.dart'; import 'rule34_provider.dart'; class ProviderFactory { @@ -45,6 +47,22 @@ class ProviderFactory { dioClient: client, queryParameters: queryParameters, ); + case 'moebooru': + return MoebooruProvider( + id: config.id, + name: config.name, + baseUrl: config.baseUrl, + dioClient: client, + queryParameters: queryParameters, + ); + case 'e621': + return E621Provider( + id: config.id, + name: config.name, + baseUrl: config.baseUrl, + dioClient: client, + queryParameters: queryParameters, + ); default: return UnsupportedCustomProvider( id: config.id, diff --git a/lib/backend/providers/provider_manager.dart b/lib/backend/providers/provider_manager.dart index 9c51a02..3c1becd 100644 --- a/lib/backend/providers/provider_manager.dart +++ b/lib/backend/providers/provider_manager.dart @@ -6,6 +6,7 @@ import '../models/content_provider_config.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 '../repositories/provider_repository.dart'; import 'content_provider.dart'; @@ -175,6 +176,36 @@ class ProviderManager { } } + Future>> suggestTags( + String query, { + int limit = 20, + }) async { + final providersResult = await activeProviders(); + if (providersResult is Error>) { + return Error(providersResult.failure); + } + final providers = (providersResult as Success>) + .data + .whereType() + .toList(); + final suggestions = {}; + for (final provider in providers) { + try { + final items = await provider.suggestTags(query, limit: limit); + for (final item in items) { + suggestions.putIfAbsent( + '${item.providerId}:${item.name}', () => item); + } + } catch (_) { + // Suggestions are non-critical; a failed provider should not affect UI. + } + if (suggestions.length >= limit) break; + } + final values = suggestions.values.toList() + ..sort((a, b) => b.postCount.compareTo(a.postCount)); + return Success(values.take(limit).toList()); + } + Future> _runLimited( List items, int concurrency, diff --git a/lib/backend/repositories/provider_repository.dart b/lib/backend/repositories/provider_repository.dart index 3e6f4b0..41f3b15 100644 --- a/lib/backend/repositories/provider_repository.dart +++ b/lib/backend/repositories/provider_repository.dart @@ -50,16 +50,78 @@ class ProviderRepository { createdAt: now, updatedAt: now, ), + ContentProviderConfig( + id: 'konachan', + name: 'Konachan', + baseUrl: 'https://konachan.com', + apiType: 'moebooru', + enabled: true, + priority: 3, + timeoutSeconds: 20, + customHeaders: const {}, + createdAt: now, + updatedAt: now, + ), + ContentProviderConfig( + id: 'yandere', + name: 'Yande.re', + baseUrl: 'https://yande.re', + apiType: 'moebooru', + enabled: true, + priority: 4, + timeoutSeconds: 20, + customHeaders: const { + 'query.api_version': '2', + 'query.include_tags': '1', + 'query.filter': '1', + }, + createdAt: now, + updatedAt: now, + ), + ContentProviderConfig( + id: 'e621', + name: 'e621', + baseUrl: 'https://e621.net', + apiType: 'e621', + enabled: true, + priority: 5, + timeoutSeconds: 20, + customHeaders: const { + 'User-Agent': 'RuleGel/0.2 Flutter local booru browser', + }, + createdAt: now, + updatedAt: now, + ), + ContentProviderConfig( + id: 'e926', + name: 'e926', + baseUrl: 'https://e926.net', + apiType: 'e621', + enabled: true, + priority: 6, + timeoutSeconds: 20, + customHeaders: const { + 'User-Agent': 'RuleGel/0.2 Flutter local booru browser', + }, + createdAt: now, + updatedAt: now, + ), ]; } Future> ensureSeedProviders() { return _databaseService.safeWrite((isar) async { - final count = await isar.providerConfigEntitys.count(); - if (count > 0) return; - await isar.providerConfigEntitys.putAll( - seedProviders().map(ProviderConfigEntity.fromModel).toList(), - ); + final seeds = seedProviders(); + for (final seed in seeds) { + final exists = await isar.providerConfigEntitys + .filter() + .providerIdEqualTo(seed.id) + .findFirst(); + if (exists == null) { + await isar.providerConfigEntitys + .put(ProviderConfigEntity.fromModel(seed)); + } + } }); } diff --git a/lib/backend/services/search_service.dart b/lib/backend/services/search_service.dart index d32a146..cb5ba6d 100644 --- a/lib/backend/services/search_service.dart +++ b/lib/backend/services/search_service.dart @@ -1,11 +1,14 @@ import '../../core/utils/result.dart'; import '../models/search_history.dart'; +import '../models/tag_suggestion.dart'; +import '../providers/provider_manager.dart'; import '../repositories/search_repository.dart'; class SearchService { - SearchService(this._repository); + SearchService(this._repository, [this._providerManager]); final SearchRepository _repository; + final ProviderManager? _providerManager; List parseTags(String query) { return query @@ -35,21 +38,21 @@ class SearchService { Future>> autocomplete(String prefix, {int limit = 10}) async { - final recent = await _repository.recent(limit: 100); - return recent.fold( - onSuccess: (items) { - final needle = prefix.toLowerCase(); - final values = {}; - for (final item in items) { - for (final tag in item.tags) { - if (tag.toLowerCase().startsWith(needle)) values.add(tag); - } - } - return Success(values.take(limit).toList()); - }, + final result = await autocompleteDetailed(prefix, limit: limit); + return result.fold( + onSuccess: (items) => Success(items.map((item) => item.name).toList()), onError: Error>.new, ); } + Future>> autocompleteDetailed( + String prefix, { + int limit = 10, + }) async { + final manager = _providerManager; + if (manager == null || prefix.trim().isEmpty) return const Success([]); + return manager.suggestTags(prefix.trim(), limit: limit); + } + Future> clearHistory() => _repository.clear(); } diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index d473417..bca0344 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -132,6 +132,7 @@ class CachedPostEntity { late String sampleUrl; late String fileUrl; late List tags; + late String tagGroupsJson; late String rating; late int width; late int height; @@ -149,6 +150,7 @@ class CachedPostEntity { sampleUrl: sampleUrl, fileUrl: fileUrl, tags: tags, + tagGroups: _decodeTagGroups(tagGroupsJson), rating: rating, width: width, height: height, @@ -168,6 +170,7 @@ class CachedPostEntity { ..sampleUrl = model.sampleUrl ..fileUrl = model.fileUrl ..tags = model.tags + ..tagGroupsJson = jsonEncode(model.tagGroups) ..rating = model.rating ..width = model.width ..height = model.height @@ -177,6 +180,17 @@ class CachedPostEntity { ..score = model.score ..cachedAt = cachedAt ?? DateTime.now(); } + + static Map> _decodeTagGroups(String value) { + if (value.isEmpty) return const {}; + final decoded = jsonDecode(value) as Map; + return decoded.map( + (key, value) => MapEntry( + key.toString(), + List.from((value as List?) ?? const []), + ), + ); + } } @collection diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index e47c950..af5bf3c 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -3226,13 +3226,18 @@ const CachedPostEntitySchema = CollectionSchema( name: r'source', type: IsarType.string, ), - r'tags': PropertySchema( + r'tagGroupsJson': PropertySchema( id: 14, + name: r'tagGroupsJson', + type: IsarType.string, + ), + r'tags': PropertySchema( + id: 15, name: r'tags', type: IsarType.stringList, ), r'width': PropertySchema( - id: 15, + id: 16, name: r'width', type: IsarType.long, ) @@ -3304,6 +3309,7 @@ int _cachedPostEntityEstimateSize( bytesCount += 3 + value.length * 3; } } + bytesCount += 3 + object.tagGroupsJson.length * 3; bytesCount += 3 + object.tags.length * 3; { for (var i = 0; i < object.tags.length; i++) { @@ -3334,8 +3340,9 @@ void _cachedPostEntitySerialize( writer.writeString(offsets[11], object.sampleUrl); writer.writeLong(offsets[12], object.score); writer.writeString(offsets[13], object.source); - writer.writeStringList(offsets[14], object.tags); - writer.writeLong(offsets[15], object.width); + writer.writeString(offsets[14], object.tagGroupsJson); + writer.writeStringList(offsets[15], object.tags); + writer.writeLong(offsets[16], object.width); } CachedPostEntity _cachedPostEntityDeserialize( @@ -3360,8 +3367,9 @@ CachedPostEntity _cachedPostEntityDeserialize( object.sampleUrl = reader.readString(offsets[11]); object.score = reader.readLong(offsets[12]); object.source = reader.readStringOrNull(offsets[13]); - object.tags = reader.readStringList(offsets[14]) ?? []; - object.width = reader.readLong(offsets[15]); + object.tagGroupsJson = reader.readString(offsets[14]); + object.tags = reader.readStringList(offsets[15]) ?? []; + object.width = reader.readLong(offsets[16]); return object; } @@ -3401,8 +3409,10 @@ P _cachedPostEntityDeserializeProp

( case 13: return (reader.readStringOrNull(offset)) as P; case 14: - return (reader.readStringList(offset) ?? []) as P; + return (reader.readString(offset)) as P; case 15: + return (reader.readStringList(offset) ?? []) as P; + case 16: return (reader.readLong(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -5353,6 +5363,142 @@ extension CachedPostEntityQueryFilter }); } + QueryBuilder + tagGroupsJsonEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'tagGroupsJson', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + tagGroupsJsonGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'tagGroupsJson', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + tagGroupsJsonLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'tagGroupsJson', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + tagGroupsJsonBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'tagGroupsJson', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + tagGroupsJsonStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'tagGroupsJson', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + tagGroupsJsonEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'tagGroupsJson', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + tagGroupsJsonContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'tagGroupsJson', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + tagGroupsJsonMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'tagGroupsJson', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + tagGroupsJsonIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'tagGroupsJson', + value: '', + )); + }); + } + + QueryBuilder + tagGroupsJsonIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'tagGroupsJson', + value: '', + )); + }); + } + QueryBuilder tagsElementEqualTo( String value, { @@ -5838,6 +5984,20 @@ extension CachedPostEntityQuerySortBy }); } + QueryBuilder + sortByTagGroupsJson() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tagGroupsJson', Sort.asc); + }); + } + + QueryBuilder + sortByTagGroupsJsonDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tagGroupsJson', Sort.desc); + }); + } + QueryBuilder sortByWidth() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'width', Sort.asc); @@ -6063,6 +6223,20 @@ extension CachedPostEntityQuerySortThenBy }); } + QueryBuilder + thenByTagGroupsJson() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tagGroupsJson', Sort.asc); + }); + } + + QueryBuilder + thenByTagGroupsJsonDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tagGroupsJson', Sort.desc); + }); + } + QueryBuilder thenByWidth() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'width', Sort.asc); @@ -6177,6 +6351,14 @@ extension CachedPostEntityQueryWhereDistinct }); } + QueryBuilder + distinctByTagGroupsJson({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'tagGroupsJson', + caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByTags() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'tags'); @@ -6288,6 +6470,13 @@ extension CachedPostEntityQueryProperty }); } + QueryBuilder + tagGroupsJsonProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'tagGroupsJson'); + }); + } + QueryBuilder, QQueryOperations> tagsProperty() { return QueryBuilder.apply(this, (query) { diff --git a/lib/features/feed/presentation/feed_controller.dart b/lib/features/feed/presentation/feed_controller.dart index bd83789..91d58ce 100644 --- a/lib/features/feed/presentation/feed_controller.dart +++ b/lib/features/feed/presentation/feed_controller.dart @@ -73,10 +73,12 @@ class FeedController extends AsyncNotifier { return; } final lastToken = query.trim().split(RegExp(r'\s+')).last; - final result = - await ref.read(searchServiceProvider).autocomplete(lastToken); - final suggestions = - result is Success> ? result.data : const []; + final result = await ref + .read(searchServiceProvider) + .autocompleteDetailed(lastToken, limit: 16); + final suggestions = result is Success> + ? result.data + : const []; state = AsyncData(current.copyWith(tagSuggestions: suggestions)); } diff --git a/lib/features/feed/presentation/feed_state.dart b/lib/features/feed/presentation/feed_state.dart index 13436f3..57a8d8b 100644 --- a/lib/features/feed/presentation/feed_state.dart +++ b/lib/features/feed/presentation/feed_state.dart @@ -19,7 +19,7 @@ class FeedState { final List selectedProviderIds; final String? ratingFilter; final TopPeriodFilter topPeriodFilter; - final List tagSuggestions; + final List tagSuggestions; final List providers; final bool isLoadingMore; final bool hasMore; @@ -31,7 +31,7 @@ class FeedState { List? selectedProviderIds, String? ratingFilter, TopPeriodFilter? topPeriodFilter, - List? tagSuggestions, + List? tagSuggestions, bool clearRating = false, List? providers, bool? isLoadingMore, diff --git a/lib/features/feed/presentation/widgets/feed_toolbar.dart b/lib/features/feed/presentation/widgets/feed_toolbar.dart index 98a8bfc..f522cda 100644 --- a/lib/features/feed/presentation/widgets/feed_toolbar.dart +++ b/lib/features/feed/presentation/widgets/feed_toolbar.dart @@ -33,7 +33,7 @@ class FeedToolbar extends StatefulWidget { final List selectedTags; final List selectedProviderIds; final TopPeriodFilter topPeriodFilter; - final List tagSuggestions; + final List tagSuggestions; final String? rating; final ValueChanged onQuickProviderToggle; final ValueChanged onSearchChanged; @@ -154,9 +154,9 @@ class _FeedToolbarState extends State { separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (context, index) => ActionChip( avatar: const Icon(Icons.tag_rounded, size: 15), - label: Text(widget.tagSuggestions[index]), + label: _SuggestionLabel(widget.tagSuggestions[index]), onPressed: () => - _applySuggestion(widget.tagSuggestions[index]), + _applySuggestion(widget.tagSuggestions[index].name), ), ), ), @@ -255,9 +255,9 @@ class _FeedToolbarState extends State { itemCount: widget.tagSuggestions.length, separatorBuilder: (_, __) => const SizedBox(width: 6), itemBuilder: (context, index) => ActionChip( - label: Text(widget.tagSuggestions[index]), + label: _SuggestionLabel(widget.tagSuggestions[index]), onPressed: () => - _applySuggestion(widget.tagSuggestions[index]), + _applySuggestion(widget.tagSuggestions[index].name), ), ), ), @@ -282,6 +282,27 @@ class _FeedToolbarState extends State { } } +class _SuggestionLabel extends StatelessWidget { + const _SuggestionLabel(this.suggestion); + + final TagSuggestion suggestion; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(suggestion.name), + const SizedBox(width: 6), + Text( + suggestion.categoryLabel, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ); + } +} + Future?> showProviderFilterSheet( BuildContext context, { required List providers, diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index c82815f..98a00b4 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -325,6 +325,9 @@ class _MobilePostPagerState extends State<_MobilePostPager> { super.initState(); _currentIndex = widget.initialIndex; _controller = PageController(initialPage: widget.initialIndex); + WidgetsBinding.instance.addPostFrameCallback((_) { + _prefetchAround(widget.initialIndex); + }); } @override @@ -337,6 +340,9 @@ class _MobilePostPagerState extends State<_MobilePostPager> { if (_controller.hasClients) { _controller.jumpToPage(widget.initialIndex); } + WidgetsBinding.instance.addPostFrameCallback((_) { + _prefetchAround(widget.initialIndex); + }); } } @@ -353,6 +359,7 @@ class _MobilePostPagerState extends State<_MobilePostPager> { itemCount: widget.posts.length, onPageChanged: (index) { _currentIndex = index; + _prefetchAround(index); final post = widget.posts[index]; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -363,13 +370,52 @@ class _MobilePostPagerState extends State<_MobilePostPager> { }); }, itemBuilder: (context, index) { - return KeyedSubtree( - key: ValueKey(widget.posts[index].cacheKey), - child: widget.buildDetails(context, widget.posts[index]), + return _KeepAlivePostPage( + child: KeyedSubtree( + key: ValueKey(widget.posts[index].cacheKey), + child: widget.buildDetails(context, widget.posts[index]), + ), ); }, ); } + + void _prefetchAround(int index) { + for (final offset in [-1, 0, 1]) { + final target = index + offset; + if (target < 0 || target >= widget.posts.length) continue; + final post = widget.posts[target]; + final urls = [ + post.previewUrl, + post.sampleUrl, + if (post.fileType.toLowerCase().contains('gif')) post.fileUrl, + ].where((url) => url.trim().isNotEmpty).toSet(); + for (final url in urls) { + precacheImage(NetworkImage(url), context); + } + } + } +} + +class _KeepAlivePostPage extends StatefulWidget { + const _KeepAlivePostPage({required this.child}); + + final Widget child; + + @override + State<_KeepAlivePostPage> createState() => _KeepAlivePostPageState(); +} + +class _KeepAlivePostPageState extends State<_KeepAlivePostPage> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } } class _CommentsSection extends ConsumerStatefulWidget { diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index a4ab393..ad37395 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -214,11 +214,10 @@ class _PostMediaViewerState extends State { } List _buildImageUrls(Post post) { - return { - post.sampleUrl, - post.fileUrl, - post.previewUrl, - }.where((url) => url.trim().isNotEmpty).toList(); + final urls = post.fileType.toLowerCase().contains('gif') + ? [post.fileUrl, post.sampleUrl, post.previewUrl] + : [post.sampleUrl, post.fileUrl, post.previewUrl]; + return urls.where((url) => url.trim().isNotEmpty).toSet().toList(); } List _buildVideoUrls(Post post) { diff --git a/lib/features/post/presentation/widgets/post_tags_panel.dart b/lib/features/post/presentation/widgets/post_tags_panel.dart index 7d25b50..8d68298 100644 --- a/lib/features/post/presentation/widgets/post_tags_panel.dart +++ b/lib/features/post/presentation/widgets/post_tags_panel.dart @@ -11,16 +11,66 @@ class PostTagsPanel extends StatelessWidget { @override Widget build(BuildContext context) { - return Wrap( - spacing: 8, - runSpacing: 8, + final groups = _groups(post); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final tag in post.tags.take(80)) - TagChip( - tag: tag, - onTap: () => context.go('/?q=${Uri.encodeQueryComponent(tag)}'), + for (final entry in groups.entries) ...[ + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 6), + child: Text( + _label(entry.key), + style: Theme.of(context).textTheme.labelLarge, + ), ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final tag in entry.value.take(80)) + TagChip( + tag: tag, + onTap: () => + context.go('/?q=${Uri.encodeQueryComponent(tag)}'), + ), + ], + ), + ], ], ); } + + Map> _groups(Post post) { + if (post.tagGroups.isNotEmpty) { + final ordered = >{}; + for (final key in [ + 'artist', + 'character', + 'copyright', + 'species', + 'meta', + 'general', + ]) { + final tags = post.tagGroups[key]; + if (tags != null && tags.isNotEmpty) ordered[key] = tags; + } + for (final entry in post.tagGroups.entries) { + ordered.putIfAbsent(entry.key, () => entry.value); + } + return ordered; + } + return {'general': post.tags}; + } + + String _label(String key) { + return switch (key) { + 'artist' => 'Artist', + 'character' => 'Character', + 'copyright' => 'Copyright / Title', + 'species' => 'Species', + 'meta' => 'Meta', + 'general' => 'General', + _ => 'Other', + }; + } } diff --git a/lib/features/search/presentation/search_controller.dart b/lib/features/search/presentation/search_controller.dart index 313a913..8715ef0 100644 --- a/lib/features/search/presentation/search_controller.dart +++ b/lib/features/search/presentation/search_controller.dart @@ -17,11 +17,12 @@ class SearchController extends AsyncNotifier { Future updateQuery(String query) async { final lastToken = query.trim().isEmpty ? '' : query.trim().split(RegExp(r'\s+')).last; - final suggestionsResult = - await ref.read(searchServiceProvider).autocomplete(lastToken); - final suggestions = suggestionsResult is Success> + final suggestionsResult = await ref + .read(searchServiceProvider) + .autocompleteDetailed(lastToken, limit: 16); + final suggestions = suggestionsResult is Success> ? suggestionsResult.data - : []; + : []; state = AsyncData( (state.value ?? const SearchState()).copyWith( query: query, diff --git a/lib/features/search/presentation/search_state.dart b/lib/features/search/presentation/search_state.dart index 31e57ea..20b3569 100644 --- a/lib/features/search/presentation/search_state.dart +++ b/lib/features/search/presentation/search_state.dart @@ -8,12 +8,12 @@ class SearchState { }); final List recent; - final List suggestions; + final List suggestions; final String query; SearchState copyWith({ List? recent, - List? suggestions, + List? suggestions, String? query, }) { return SearchState( diff --git a/lib/features/search/presentation/widgets/search_suggestions.dart b/lib/features/search/presentation/widgets/search_suggestions.dart index 9afc833..f61d4d4 100644 --- a/lib/features/search/presentation/widgets/search_suggestions.dart +++ b/lib/features/search/presentation/widgets/search_suggestions.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; +import '../../../../backend/backend.dart'; + class SearchSuggestions extends StatelessWidget { const SearchSuggestions( {required this.suggestions, required this.onTap, super.key}); - final List suggestions; + final List suggestions; final ValueChanged onTap; @override @@ -16,8 +18,8 @@ class SearchSuggestions extends StatelessWidget { children: [ for (final suggestion in suggestions) ActionChip( - label: Text(suggestion), - onPressed: () => onTap(suggestion), + label: Text('${suggestion.name} ยท ${suggestion.categoryLabel}'), + onPressed: () => onTap(suggestion.name), ), ], ); diff --git a/test/backend/isar_integration_test.dart b/test/backend/isar_integration_test.dart index 3b69314..077026a 100644 --- a/test/backend/isar_integration_test.dart +++ b/test/backend/isar_integration_test.dart @@ -65,6 +65,10 @@ void main() { 'gelbooru', 'rule34', 'safebooru', + 'konachan', + 'yandere', + 'e621', + 'e926', ]); }); diff --git a/test/backend/provider_manager_test.dart b/test/backend/provider_manager_test.dart index 2015bb3..855945d 100644 --- a/test/backend/provider_manager_test.dart +++ b/test/backend/provider_manager_test.dart @@ -155,8 +155,7 @@ void main() { expect(result.data.map((provider) => provider.id), ['a', 'b']); }); - test('saved offline health does not block enabled provider retry', - () async { + test('saved offline health does not block enabled provider retry', () async { final repository = FakeProviderRepository() ..configs['a'] = config('a', 0) ..configs['b'] = config('b', 1) diff --git a/test/backend/provider_parser_test.dart b/test/backend/provider_parser_test.dart index de8901b..d79ed1c 100644 --- a/test/backend/provider_parser_test.dart +++ b/test/backend/provider_parser_test.dart @@ -1,6 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:gel_rule_app/backend/mappers/danbooru_mapper.dart'; +import 'package:gel_rule_app/backend/mappers/e621_mapper.dart'; import 'package:gel_rule_app/backend/mappers/gelbooru_mapper.dart'; +import 'package:gel_rule_app/backend/mappers/moebooru_mapper.dart'; import 'package:gel_rule_app/backend/mappers/rule34_mapper.dart'; void main() { @@ -59,6 +61,24 @@ void main() { expect(posts.single.fileType, 'video'); }); + test('parses Rule34 gif with query and file_ext', () { + final posts = Rule34Mapper.postsFromResponse( + { + 'posts': [ + { + 'id': '33', + 'file_url': 'https://example.test/c.gif?download=1', + 'file_ext': 'gif', + } + ], + }, + providerId: 'rule34', + providerName: 'Rule34', + ); + + expect(posts.single.fileType, 'gif'); + }); + test('parses Danbooru response', () { final posts = DanbooruMapper.postsFromResponse( [ @@ -81,6 +101,76 @@ void main() { expect(posts.single.tags, ['blue', 'sky']); }); + test('parses Danbooru tag groups', () { + final posts = DanbooruMapper.postsFromResponse( + [ + { + 'id': 40, + 'file_url': 'https://example.test/d.jpg', + 'tag_string_general': 'blue sky', + 'tag_string_artist': 'artist_name', + 'tag_string_character': 'char_name', + } + ], + providerId: 'danbooru', + providerName: 'Danbooru', + ); + + expect(posts.single.tagGroups['artist'], ['artist_name']); + expect(posts.single.tagGroups['character'], ['char_name']); + expect(posts.single.tagGroups['general'], ['blue', 'sky']); + }); + + test('parses Moebooru response through Danbooru-compatible mapper', () { + final posts = MoebooruMapper.postsFromResponse( + [ + { + 'id': 50, + 'file_url': 'https://example.test/moe.png', + 'tag_string': 'konachan_test', + } + ], + providerId: 'konachan', + providerName: 'Konachan', + ); + + expect(posts.single.providerId, 'konachan'); + expect(posts.single.tags, ['konachan_test']); + }); + + test('parses e621 response and tag groups', () { + final posts = E621Mapper.postsFromResponse( + { + 'posts': [ + { + 'id': 60, + 'file': { + 'url': 'https://example.test/e.webm', + 'width': 1280, + 'height': 720, + 'ext': 'webm', + }, + 'preview': {'url': 'https://example.test/e-preview.jpg'}, + 'sample': {'url': 'https://example.test/e-sample.jpg'}, + 'rating': 'e', + 'score': {'total': 10}, + 'tags': { + 'artist': ['artist_e'], + 'species': ['wolf'], + 'general': ['running'], + }, + } + ], + }, + providerId: 'e621', + providerName: 'e621', + ); + + expect(posts.single.fileType, 'video'); + expect(posts.single.rating, 'explicit'); + expect(posts.single.tagGroups['species'], ['wolf']); + }); + test('missing optional fields do not crash parser', () { final posts = GelbooruMapper.postsFromResponse( [ diff --git a/test/backend/search_service_test.dart b/test/backend/search_service_test.dart index a7a967a..b413dde 100644 --- a/test/backend/search_service_test.dart +++ b/test/backend/search_service_test.dart @@ -52,12 +52,13 @@ void main() { expect((result as Success>).data.first.query, 'new'); }); - test('autocomplete uses history tags', () async { + test('autocomplete is provider-only and returns empty without manager', + () async { await service.saveSearch('cat cute', 2); await service.saveSearch('car', 1); final result = await service.autocomplete('ca'); - expect((result as Success>).data, containsAll(['cat', 'car'])); + expect((result as Success>).data, isEmpty); }); test('clear history removes all searches', () async {