Add provider tag suggestions and tag categories

This commit is contained in:
2026-05-23 15:41:23 +03:00
parent 1b763c4d62
commit b02c776628
30 changed files with 1119 additions and 77 deletions
+1
View File
@@ -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';
+4 -1
View File
@@ -92,7 +92,10 @@ final feedServiceProvider = Provider<FeedService>((ref) {
});
final searchServiceProvider = Provider<SearchService>((ref) {
return SearchService(ref.watch(searchRepositoryProvider));
return SearchService(
ref.watch(searchRepositoryProvider),
ref.watch(providerManagerProvider),
);
});
final providerCheckServiceProvider = Provider<ProviderCheckService>((ref) {
+32 -6
View File
@@ -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<String, List<String>> _tagGroups(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']);
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;
}
}
+100
View File
@@ -0,0 +1,100 @@
import '../models/post.dart';
class E621Mapper {
static List<Post> 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>()
.map((item) => postFromJson(
Map<String, dynamic>.from(item),
providerId: providerId,
providerName: providerName,
))
.toList();
}
static Post postFromJson(
Map<String, dynamic> json, {
required String providerId,
required String providerName,
}) {
final file = Map<String, dynamic>.from((json['file'] as Map?) ?? const {});
final preview =
Map<String, dynamic>.from((json['preview'] as Map?) ?? const {});
final sample =
Map<String, dynamic>.from((json['sample'] as Map?) ?? const {});
final score =
Map<String, dynamic>.from((json['score'] as Map?) ?? const {});
final tagsMap =
Map<String, dynamic>.from((json['tags'] as Map?) ?? const {});
final tagGroups = {
for (final entry in tagsMap.entries)
entry.key: List<String>.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';
}
}
+18 -6
View File
@@ -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<String, List<String>> _tagGroups(List<String> tags) {
return tags.isEmpty ? const {} : {'general': tags};
}
}
+20
View File
@@ -0,0 +1,20 @@
import 'danbooru_mapper.dart';
import '../models/post.dart';
class MoebooruMapper {
static List<Post> postsFromResponse(
dynamic data, {
required String providerId,
required String providerName,
}) {
final items = data is List ? data : const [];
return items
.whereType<Map>()
.map((item) => DanbooruMapper.postFromJson(
Map<String, dynamic>.from(item),
providerId: providerId,
providerName: providerName,
))
.toList();
}
}
+10
View File
@@ -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<String, List<String>> 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<String, dynamic> 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<String>.from((value as List?) ?? const []),
),
) ??
const {},
);
}
+38
View File
@@ -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,
};
}
@@ -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<List<PostComment>> getComments(String postId);
}
abstract class TagSuggestionProvider {
Future<List<TagSuggestion>> suggestTags(String query, {int limit = 20});
}
+150
View File
@@ -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<String, String> queryParameters = const {},
}) : _dio = dioClient.dio,
_queryParameters = queryParameters;
@override
final String id;
@override
final String name;
@override
final String baseUrl;
final Dio _dio;
final Map<String, String> _queryParameters;
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
final response = await _dio.get<dynamic>(
'/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<Post?> getPost(String id) async {
final response = await _dio.get<dynamic>(
'/posts/$id.json',
queryParameters: _queryParameters,
);
final posts = E621Mapper.postsFromResponse(
response.data,
providerId: this.id,
providerName: name,
);
return posts.isEmpty ? null : posts.first;
}
@override
Future<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
await _dio.get<dynamic>(
'/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<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
if (query.trim().isEmpty) return const [];
final response = await _dio.get<dynamic>(
'/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>()
.map((item) {
final json = Map<String, dynamic>.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<String> _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;
}
}
@@ -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<String, String> queryParameters = const {},
}) : _dio = dioClient.dio,
_queryParameters = queryParameters;
@override
final String id;
@override
final String name;
@override
final String baseUrl;
final Dio _dio;
final Map<String, String> _queryParameters;
@override
Future<List<Post>> searchPosts({
required List<String> tags,
required int page,
int limit = 50,
String? rating,
TopPeriodFilter topPeriod = TopPeriodFilter.none,
}) async {
final response = await _dio.get<dynamic>(
'/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<Post?> getPost(String id) async {
final response = await _dio.get<dynamic>(
'/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<ProviderHealth> checkHealth() async {
final startedAt = DateTime.now();
try {
await _dio.get<dynamic>(
'/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<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
if (query.trim().isEmpty) return const [];
final response = await _dio.get<dynamic>(
'/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>()
.map((item) {
final json = Map<String, dynamic>.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<String> _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;
}
}
@@ -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,
@@ -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<Result<List<TagSuggestion>>> suggestTags(
String query, {
int limit = 20,
}) async {
final providersResult = await activeProviders();
if (providersResult is Error<List<ContentProvider>>) {
return Error(providersResult.failure);
}
final providers = (providersResult as Success<List<ContentProvider>>)
.data
.whereType<TagSuggestionProvider>()
.toList();
final suggestions = <String, TagSuggestion>{};
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<List<R>> _runLimited<T, R>(
List<T> items,
int concurrency,
@@ -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<Result<void>> 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));
}
}
});
}
+16 -13
View File
@@ -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<String> parseTags(String query) {
return query
@@ -35,21 +38,21 @@ class SearchService {
Future<Result<List<String>>> 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 = <String>{};
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<List<String>>.new,
);
}
Future<Result<List<TagSuggestion>>> 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<Result<void>> clearHistory() => _repository.clear();
}
+14
View File
@@ -132,6 +132,7 @@ class CachedPostEntity {
late String sampleUrl;
late String fileUrl;
late List<String> 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<String, List<String>> _decodeTagGroups(String value) {
if (value.isEmpty) return const {};
final decoded = jsonDecode(value) as Map;
return decoded.map(
(key, value) => MapEntry(
key.toString(),
List<String>.from((value as List?) ?? const []),
),
);
}
}
@collection
+196 -7
View File
@@ -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<P>(
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<CachedPostEntity, CachedPostEntity, QAfterFilterCondition>
tagGroupsJsonEqualTo(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'tagGroupsJson',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterFilterCondition>
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<CachedPostEntity, CachedPostEntity, QAfterFilterCondition>
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<CachedPostEntity, CachedPostEntity, QAfterFilterCondition>
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<CachedPostEntity, CachedPostEntity, QAfterFilterCondition>
tagGroupsJsonStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'tagGroupsJson',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterFilterCondition>
tagGroupsJsonEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'tagGroupsJson',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterFilterCondition>
tagGroupsJsonContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'tagGroupsJson',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterFilterCondition>
tagGroupsJsonMatches(String pattern, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'tagGroupsJson',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterFilterCondition>
tagGroupsJsonIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'tagGroupsJson',
value: '',
));
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterFilterCondition>
tagGroupsJsonIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'tagGroupsJson',
value: '',
));
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterFilterCondition>
tagsElementEqualTo(
String value, {
@@ -5838,6 +5984,20 @@ extension CachedPostEntityQuerySortBy
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterSortBy>
sortByTagGroupsJson() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'tagGroupsJson', Sort.asc);
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterSortBy>
sortByTagGroupsJsonDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'tagGroupsJson', Sort.desc);
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterSortBy> sortByWidth() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'width', Sort.asc);
@@ -6063,6 +6223,20 @@ extension CachedPostEntityQuerySortThenBy
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterSortBy>
thenByTagGroupsJson() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'tagGroupsJson', Sort.asc);
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterSortBy>
thenByTagGroupsJsonDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'tagGroupsJson', Sort.desc);
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QAfterSortBy> thenByWidth() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'width', Sort.asc);
@@ -6177,6 +6351,14 @@ extension CachedPostEntityQueryWhereDistinct
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QDistinct>
distinctByTagGroupsJson({bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'tagGroupsJson',
caseSensitive: caseSensitive);
});
}
QueryBuilder<CachedPostEntity, CachedPostEntity, QDistinct> distinctByTags() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'tags');
@@ -6288,6 +6470,13 @@ extension CachedPostEntityQueryProperty
});
}
QueryBuilder<CachedPostEntity, String, QQueryOperations>
tagGroupsJsonProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'tagGroupsJson');
});
}
QueryBuilder<CachedPostEntity, List<String>, QQueryOperations>
tagsProperty() {
return QueryBuilder.apply(this, (query) {
@@ -73,10 +73,12 @@ class FeedController extends AsyncNotifier<FeedState> {
return;
}
final lastToken = query.trim().split(RegExp(r'\s+')).last;
final result =
await ref.read(searchServiceProvider).autocomplete(lastToken);
final suggestions =
result is Success<List<String>> ? result.data : const <String>[];
final result = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
final suggestions = result is Success<List<TagSuggestion>>
? result.data
: const <TagSuggestion>[];
state = AsyncData(current.copyWith(tagSuggestions: suggestions));
}
@@ -19,7 +19,7 @@ class FeedState {
final List<String> selectedProviderIds;
final String? ratingFilter;
final TopPeriodFilter topPeriodFilter;
final List<String> tagSuggestions;
final List<TagSuggestion> tagSuggestions;
final List<ContentProviderConfig> providers;
final bool isLoadingMore;
final bool hasMore;
@@ -31,7 +31,7 @@ class FeedState {
List<String>? selectedProviderIds,
String? ratingFilter,
TopPeriodFilter? topPeriodFilter,
List<String>? tagSuggestions,
List<TagSuggestion>? tagSuggestions,
bool clearRating = false,
List<ContentProviderConfig>? providers,
bool? isLoadingMore,
@@ -33,7 +33,7 @@ class FeedToolbar extends StatefulWidget {
final List<String> selectedTags;
final List<String> selectedProviderIds;
final TopPeriodFilter topPeriodFilter;
final List<String> tagSuggestions;
final List<TagSuggestion> tagSuggestions;
final String? rating;
final ValueChanged<String> onQuickProviderToggle;
final ValueChanged<String> onSearchChanged;
@@ -154,9 +154,9 @@ class _FeedToolbarState extends State<FeedToolbar> {
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<FeedToolbar> {
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<FeedToolbar> {
}
}
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<List<String>?> showProviderFilterSheet(
BuildContext context, {
required List<ContentProviderConfig> providers,
@@ -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 {
@@ -214,11 +214,10 @@ class _PostMediaViewerState extends State<PostMediaViewer> {
}
List<String> _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<String> _buildVideoUrls(Post post) {
@@ -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<String, List<String>> _groups(Post post) {
if (post.tagGroups.isNotEmpty) {
final ordered = <String, List<String>>{};
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',
};
}
}
@@ -17,11 +17,12 @@ class SearchController extends AsyncNotifier<SearchState> {
Future<void> 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<List<String>>
final suggestionsResult = await ref
.read(searchServiceProvider)
.autocompleteDetailed(lastToken, limit: 16);
final suggestions = suggestionsResult is Success<List<TagSuggestion>>
? suggestionsResult.data
: <String>[];
: <TagSuggestion>[];
state = AsyncData(
(state.value ?? const SearchState()).copyWith(
query: query,
@@ -8,12 +8,12 @@ class SearchState {
});
final List<SearchHistory> recent;
final List<String> suggestions;
final List<TagSuggestion> suggestions;
final String query;
SearchState copyWith({
List<SearchHistory>? recent,
List<String>? suggestions,
List<TagSuggestion>? suggestions,
String? query,
}) {
return SearchState(
@@ -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<String> suggestions;
final List<TagSuggestion> suggestions;
final ValueChanged<String> 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),
),
],
);
+4
View File
@@ -65,6 +65,10 @@ void main() {
'gelbooru',
'rule34',
'safebooru',
'konachan',
'yandere',
'e621',
'e926',
]);
});
+1 -2
View File
@@ -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)
+90
View File
@@ -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(
[
+3 -2
View File
@@ -52,12 +52,13 @@ void main() {
expect((result as Success<List<SearchHistory>>).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<List<String>>).data, containsAll(['cat', 'car']));
expect((result as Success<List<String>>).data, isEmpty);
});
test('clear history removes all searches', () async {