Add provider tag suggestions and tag categories
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -65,6 +65,10 @@ void main() {
|
||||
'gelbooru',
|
||||
'rule34',
|
||||
'safebooru',
|
||||
'konachan',
|
||||
'yandere',
|
||||
'e621',
|
||||
'e926',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user