Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce78a1f47f |
@@ -1,4 +1,14 @@
|
|||||||
const lunarisChangelog = [
|
const lunarisChangelog = [
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.3.3',
|
||||||
|
title: 'Tag Suggestions Overlay Fix & Rule34 API Alignment',
|
||||||
|
bullets: [
|
||||||
|
'Tag Suggestions Hanging Fix: eliminated the bug where the tag suggestion card remained stuck on screen after clearing text or tapping outside. Added immediate dismissal on empty input, outside-tap backdrop dismissal, controller listener, and unfocus on submission.',
|
||||||
|
'Rule34 Dedicated Autocomplete: connected Rule34 directly to its official fast /autocomplete.php?q= endpoint, displaying real-time tag suggestions with accurate post counts (e.g. braid (216,984)).',
|
||||||
|
'Rule34 Tag Types (fields=tag_info): added tag_info field to post query for accurate tag categories per post.',
|
||||||
|
'Rule34 Authentication & Outage Handling: added detection for "missing authentication", rate limits, and server overload/search down ("success": false) with informative guidance to enter API Key & User ID.',
|
||||||
|
],
|
||||||
|
),
|
||||||
LunarisChange(
|
LunarisChange(
|
||||||
version: '2.3.2',
|
version: '2.3.2',
|
||||||
title: 'Gelbooru DAPI Compliance & Tag Query Optimization',
|
title: 'Gelbooru DAPI Compliance & Tag Query Optimization',
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../mappers/rule34_mapper.dart';
|
import '../mappers/rule34_mapper.dart';
|
||||||
import '../models/post.dart';
|
import '../models/post.dart';
|
||||||
|
import '../models/tag_suggestion.dart';
|
||||||
import '../models/top_period_filter.dart';
|
import '../models/top_period_filter.dart';
|
||||||
import 'gelbooru_provider.dart';
|
import 'gelbooru_provider.dart';
|
||||||
|
|
||||||
@@ -36,27 +39,142 @@ class Rule34Provider extends GelbooruProvider {
|
|||||||
TopPeriodFilter.month || TopPeriodFilter.year => const <String>[],
|
TopPeriodFilter.month || TopPeriodFilter.year => const <String>[],
|
||||||
TopPeriodFilter.allTime => const ['sort:score:desc'],
|
TopPeriodFilter.allTime => const ['sort:score:desc'],
|
||||||
};
|
};
|
||||||
final response = await dio.get<dynamic>(
|
try {
|
||||||
'/index.php',
|
final response = await dio.get<dynamic>(
|
||||||
queryParameters: {
|
'/index.php',
|
||||||
'page': 'dapi',
|
queryParameters: {
|
||||||
's': 'post',
|
'page': 'dapi',
|
||||||
'q': 'index',
|
's': 'post',
|
||||||
'json': '1',
|
'q': 'index',
|
||||||
'pid': page,
|
'json': '1',
|
||||||
'limit': limit,
|
'pid': page,
|
||||||
'tags': [
|
'limit': limit,
|
||||||
...tags,
|
'fields': 'tag_info',
|
||||||
if (rating != null) 'rating:$rating',
|
'tags': [
|
||||||
...topTags,
|
...tags,
|
||||||
].join(' '),
|
if (rating != null) 'rating:$rating',
|
||||||
...queryParameters,
|
...topTags,
|
||||||
},
|
].join(' '),
|
||||||
);
|
...queryParameters,
|
||||||
return Rule34Mapper.postsFromResponse(
|
},
|
||||||
response.data,
|
);
|
||||||
providerId: id,
|
_checkRule34Response(response);
|
||||||
providerName: name,
|
return Rule34Mapper.postsFromResponse(
|
||||||
);
|
response.data,
|
||||||
|
providerId: id,
|
||||||
|
providerName: name,
|
||||||
|
);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
_handleRule34DioError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post?> getPost(String id) async {
|
||||||
|
try {
|
||||||
|
final response = await dio.get<dynamic>(
|
||||||
|
'/index.php',
|
||||||
|
queryParameters: {
|
||||||
|
'page': 'dapi',
|
||||||
|
's': 'post',
|
||||||
|
'q': 'index',
|
||||||
|
'json': '1',
|
||||||
|
'id': id,
|
||||||
|
'fields': 'tag_info',
|
||||||
|
...queryParameters,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_checkRule34Response(response);
|
||||||
|
final posts = Rule34Mapper.postsFromResponse(
|
||||||
|
response.data,
|
||||||
|
providerId: this.id,
|
||||||
|
providerName: name,
|
||||||
|
);
|
||||||
|
return posts.isEmpty ? null : posts.first;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
_handleRule34DioError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<TagSuggestion>> suggestTags(String query,
|
||||||
|
{int limit = 20}) async {
|
||||||
|
final trimmed = query.trim();
|
||||||
|
if (trimmed.isEmpty) return const [];
|
||||||
|
try {
|
||||||
|
// Official Rule34 autocomplete endpoint (fast, public, with exact post count)
|
||||||
|
final response = await dio.get<dynamic>(
|
||||||
|
'/autocomplete.php',
|
||||||
|
queryParameters: {
|
||||||
|
'q': trimmed,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.data is List) {
|
||||||
|
final list = response.data as List;
|
||||||
|
final suggestions = list
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((item) {
|
||||||
|
final json = Map<String, dynamic>.from(item);
|
||||||
|
final rawLabel = (json['label'] ?? '').toString();
|
||||||
|
final rawValue = (json['value'] ?? '').toString();
|
||||||
|
// Parse label "braid (216984)" -> name: "braid", postCount: 216984
|
||||||
|
final match =
|
||||||
|
RegExp(r'^(.*?)\s*\((\d+)\)$').firstMatch(rawLabel);
|
||||||
|
final name = (match?.group(1) ?? rawValue).trim();
|
||||||
|
final postCount = int.tryParse(match?.group(2) ?? '') ?? 0;
|
||||||
|
return TagSuggestion(
|
||||||
|
name: name.isNotEmpty ? name : rawValue,
|
||||||
|
category: TagCategory.general,
|
||||||
|
postCount: postCount,
|
||||||
|
providerId: id,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.where((tag) => tag.name.isNotEmpty)
|
||||||
|
.take(limit)
|
||||||
|
.toList(growable: false);
|
||||||
|
if (suggestions.isNotEmpty) return suggestions;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Gracefully fall back to standard DAPI tag search
|
||||||
|
}
|
||||||
|
return super.suggestTags(query, limit: limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkRule34Response(Response<dynamic> response) {
|
||||||
|
if (response.data is String) {
|
||||||
|
final text = (response.data as String).toLowerCase();
|
||||||
|
if (text.contains('missing authentication') ||
|
||||||
|
text.contains('throttled') ||
|
||||||
|
text.contains('authentication required')) {
|
||||||
|
unavailable(
|
||||||
|
'$name requires authentication. '
|
||||||
|
'Please configure your API Key and User ID in Settings -> Providers '
|
||||||
|
'(obtain from https://rule34.xxx/index.php?page=account&s=options).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (text.contains('search down') ||
|
||||||
|
text.contains('server is overloaded')) {
|
||||||
|
unavailable(
|
||||||
|
'$name searcher is temporarily down or overloaded. Please try again in a few minutes.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (response.data is Map) {
|
||||||
|
final map = response.data as Map;
|
||||||
|
if (map['success'] == false || map['success'] == 'false') {
|
||||||
|
final msg = map['message'] ?? 'search down';
|
||||||
|
unavailable('$name error: $msg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Never _handleRule34DioError(DioException error) {
|
||||||
|
final status = error.response?.statusCode;
|
||||||
|
if (status == 401 || status == 403 || status == 429) {
|
||||||
|
unavailable(
|
||||||
|
'$name requires authentication or rate limit reached ($status). '
|
||||||
|
'Please enter your API Key and User ID in Settings -> Providers.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
|
|||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'API key',
|
labelText: 'API key',
|
||||||
helperText:
|
helperText:
|
||||||
'Gelbooru/Danbooru API key (Gelbooru: Account -> Options)',
|
'Gelbooru/Rule34/Danbooru API key (Account -> Options)',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -139,7 +139,7 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
|
|||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'User ID',
|
labelText: 'User ID',
|
||||||
helperText:
|
helperText:
|
||||||
'Gelbooru User ID (required when throttled, see profile)',
|
'Gelbooru/Rule34 User ID (required for API access, see Options)',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -64,10 +64,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
_focusNode = FocusNode();
|
_focusNode = FocusNode();
|
||||||
_tagScrollController = ScrollController();
|
_tagScrollController = ScrollController();
|
||||||
_focusNode.addListener(_handleFocusChanged);
|
_focusNode.addListener(_handleFocusChanged);
|
||||||
|
_controller.addListener(_handleControllerChanged);
|
||||||
_lastExternalValue = widget.initialValue ?? '';
|
_lastExternalValue = widget.initialValue ?? '';
|
||||||
_setFromQuery(_lastExternalValue);
|
_setFromQuery(_lastExternalValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleControllerChanged() {
|
||||||
|
if (_controller.text.trim().isEmpty && _suggestionsOverlay != null) {
|
||||||
|
_removeSuggestionsOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant TagInputSearchBar oldWidget) {
|
void didUpdateWidget(covariant TagInputSearchBar oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@@ -89,6 +96,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_removeSuggestionsOverlay();
|
_removeSuggestionsOverlay();
|
||||||
_tagScrollController.dispose();
|
_tagScrollController.dispose();
|
||||||
|
_controller.removeListener(_handleControllerChanged);
|
||||||
_focusNode.removeListener(_handleFocusChanged);
|
_focusNode.removeListener(_handleFocusChanged);
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
@@ -97,15 +105,19 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
|
|
||||||
void _handleFocusChanged() {
|
void _handleFocusChanged() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (!_focusNode.hasFocus && !_localDirty) {
|
if (!_focusNode.hasFocus) {
|
||||||
final next = widget.initialValue ?? '';
|
_removeSuggestionsOverlay();
|
||||||
if (next != _lastExternalValue) {
|
if (!_localDirty) {
|
||||||
_setFromQuery(next);
|
final next = widget.initialValue ?? '';
|
||||||
_lastExternalValue = next;
|
if (next != _lastExternalValue) {
|
||||||
|
_setFromQuery(next);
|
||||||
|
_lastExternalValue = next;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
_syncSuggestionsOverlay();
|
||||||
}
|
}
|
||||||
setState(() {});
|
setState(() {});
|
||||||
_syncSuggestionsOverlay();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -209,17 +221,24 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
|
|
||||||
void _syncSuggestionsOverlay() {
|
void _syncSuggestionsOverlay() {
|
||||||
final token = _activeToken;
|
final token = _activeToken;
|
||||||
|
if (!_focusNode.hasFocus || token.isEmpty) {
|
||||||
|
_removeSuggestionsOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
final suggestions = widget.suggestions
|
final suggestions = widget.suggestions
|
||||||
.where((item) => item.name.toLowerCase().startsWith(token))
|
.where((item) => item.name.toLowerCase().startsWith(token))
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
if (!_focusNode.hasFocus || token.isEmpty || suggestions.isEmpty) {
|
if (suggestions.isEmpty) {
|
||||||
_removeSuggestionsOverlay();
|
_removeSuggestionsOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final overlay = Overlay.maybeOf(context);
|
final overlay = Overlay.maybeOf(context);
|
||||||
final renderBox =
|
final renderBox =
|
||||||
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
|
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
if (overlay == null || renderBox == null || !renderBox.hasSize) return;
|
if (overlay == null || renderBox == null || !renderBox.hasSize) {
|
||||||
|
_removeSuggestionsOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final size = renderBox.size;
|
final size = renderBox.size;
|
||||||
_removeSuggestionsOverlay();
|
_removeSuggestionsOverlay();
|
||||||
@@ -231,14 +250,22 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
suggestions: suggestions,
|
suggestions: suggestions,
|
||||||
query: token,
|
query: token,
|
||||||
onSelected: _applySuggestion,
|
onSelected: _applySuggestion,
|
||||||
|
onDismiss: () {
|
||||||
|
_removeSuggestionsOverlay();
|
||||||
|
_focusNode.unfocus();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
overlay.insert(_suggestionsOverlay!);
|
overlay.insert(_suggestionsOverlay!);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _removeSuggestionsOverlay() {
|
void _removeSuggestionsOverlay() {
|
||||||
_suggestionsOverlay?.remove();
|
if (_suggestionsOverlay != null) {
|
||||||
_suggestionsOverlay = null;
|
if (_suggestionsOverlay!.mounted) {
|
||||||
|
_suggestionsOverlay!.remove();
|
||||||
|
}
|
||||||
|
_suggestionsOverlay = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTagChip(BuildContext context, String tag) {
|
Widget _buildTagChip(BuildContext context, String tag) {
|
||||||
@@ -302,10 +329,15 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
|
|
||||||
void _handleDraftChanged(String value) {
|
void _handleDraftChanged(String value) {
|
||||||
_localDirty = true;
|
_localDirty = true;
|
||||||
|
if (value.trim().isEmpty) {
|
||||||
|
_debounce?.cancel();
|
||||||
|
_removeSuggestionsOverlay();
|
||||||
|
setState(() {});
|
||||||
|
_notifyChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
final composing = _controller.value.composing;
|
final composing = _controller.value.composing;
|
||||||
if (!composing.isValid &&
|
if (!composing.isValid && RegExp(r'\s$').hasMatch(value)) {
|
||||||
value.isNotEmpty &&
|
|
||||||
RegExp(r'\s$').hasMatch(value)) {
|
|
||||||
_commitDraft(value);
|
_commitDraft(value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -334,22 +366,24 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
|
|||||||
|
|
||||||
void _submit() {
|
void _submit() {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_commitDraft(_controller.text);
|
|
||||||
_removeSuggestionsOverlay();
|
_removeSuggestionsOverlay();
|
||||||
|
_commitDraft(_controller.text);
|
||||||
_lastExternalValue = _query;
|
_lastExternalValue = _query;
|
||||||
_localDirty = false;
|
_localDirty = false;
|
||||||
|
_focusNode.unfocus();
|
||||||
widget.onSubmitted(_query);
|
widget.onSubmitted(_query);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clear() {
|
void _clear() {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
|
_removeSuggestionsOverlay();
|
||||||
_localDirty = true;
|
_localDirty = true;
|
||||||
setState(() {
|
setState(() {
|
||||||
_tags = [];
|
_tags = [];
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
});
|
});
|
||||||
_notifyChanged();
|
_notifyChanged();
|
||||||
_focusNode.requestFocus();
|
_focusNode.unfocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _editTag(String tag) {
|
void _editTag(String tag) {
|
||||||
@@ -446,6 +480,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
|
|||||||
required this.suggestions,
|
required this.suggestions,
|
||||||
required this.query,
|
required this.query,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
|
required this.onDismiss,
|
||||||
});
|
});
|
||||||
|
|
||||||
final LayerLink link;
|
final LayerLink link;
|
||||||
@@ -454,26 +489,37 @@ class _TagSuggestionOverlay extends StatelessWidget {
|
|||||||
final List<TagSuggestion> suggestions;
|
final List<TagSuggestion> suggestions;
|
||||||
final String query;
|
final String query;
|
||||||
final ValueChanged<String> onSelected;
|
final ValueChanged<String> onSelected;
|
||||||
|
final VoidCallback onDismiss;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Positioned.fill(
|
return Stack(
|
||||||
child: CompositedTransformFollower(
|
children: [
|
||||||
link: link,
|
Positioned.fill(
|
||||||
showWhenUnlinked: false,
|
child: GestureDetector(
|
||||||
offset: Offset(0, yOffset),
|
behavior: HitTestBehavior.translucent,
|
||||||
child: Align(
|
onTap: onDismiss,
|
||||||
alignment: Alignment.topLeft,
|
),
|
||||||
child: SizedBox(
|
),
|
||||||
width: width.clamp(280, 620).toDouble(),
|
Positioned.fill(
|
||||||
child: _TagSuggestionDropdown(
|
child: CompositedTransformFollower(
|
||||||
suggestions: suggestions,
|
link: link,
|
||||||
query: query,
|
showWhenUnlinked: false,
|
||||||
onSelected: onSelected,
|
offset: Offset(0, yOffset),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: SizedBox(
|
||||||
|
width: width.clamp(280, 620).toDouble(),
|
||||||
|
child: _TagSuggestionDropdown(
|
||||||
|
suggestions: suggestions,
|
||||||
|
query: query,
|
||||||
|
onSelected: onSelected,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: gel_rule_app
|
name: gel_rule_app
|
||||||
description: Local backend/core layer for a Flutter booru browser app.
|
description: Local backend/core layer for a Flutter booru browser app.
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.3.2+42
|
version: 2.3.3+43
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
|
||||||
|
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
|
||||||
|
import 'package:gel_rule_app/backend/providers/rule34_provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('ProviderFactory creates Rule34Provider with credentials', () {
|
||||||
|
final factory = ProviderFactory();
|
||||||
|
final config = ContentProviderConfig(
|
||||||
|
id: 'rule34',
|
||||||
|
name: 'Rule34',
|
||||||
|
baseUrl: 'https://api.rule34.xxx',
|
||||||
|
apiType: 'rule34',
|
||||||
|
enabled: true,
|
||||||
|
priority: 1,
|
||||||
|
timeoutSeconds: 20,
|
||||||
|
customHeaders: const {
|
||||||
|
'query.api_key': 'test_key',
|
||||||
|
'query.user_id': '99999',
|
||||||
|
},
|
||||||
|
createdAt: DateTime(2026),
|
||||||
|
updatedAt: DateTime(2026),
|
||||||
|
);
|
||||||
|
|
||||||
|
final provider = factory.create(config);
|
||||||
|
expect(provider, isA<Rule34Provider>());
|
||||||
|
final r34 = provider as Rule34Provider;
|
||||||
|
expect(r34.id, 'rule34');
|
||||||
|
expect(r34.baseUrl, 'https://api.rule34.xxx');
|
||||||
|
expect(r34.queryParameters['api_key'], 'test_key');
|
||||||
|
expect(r34.queryParameters['user_id'], '99999');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user