Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
576bd53487 | ||
|
|
87e126ec4d |
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Version-2.3.0-blueviolet?style=for-the-badge" alt="Version 2.3.0" />
|
<img src="https://img.shields.io/badge/Version-2.3.1-blueviolet?style=for-the-badge" alt="Version 2.3.1" />
|
||||||
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
||||||
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-brightgreen?style=for-the-badge" alt="Platforms" />
|
||||||
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
<img src="https://img.shields.io/badge/Gitea-RarDog%2FLunaris-orange?style=for-the-badge&logo=gitea&logoColor=white" alt="Gitea" />
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
const appDisplayVersion = '2.3.0';
|
const appDisplayVersion = '2.3.1';
|
||||||
const appBuildNumber = 40;
|
const appBuildNumber = 41;
|
||||||
const appReleaseChannel = 'Stable';
|
const appReleaseChannel = 'Stable';
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
const lunarisChangelog = [
|
const lunarisChangelog = [
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.3.2',
|
||||||
|
title: 'Gelbooru DAPI Compliance & Tag Query Optimization',
|
||||||
|
bullets: [
|
||||||
|
'Official Gelbooru DAPI Alignment: fixed tag suggestions pattern to use SQL LIKE wildcard (%) instead of literal asterisk (*), restoring accurate tag autocompletion.',
|
||||||
|
'Popularity Tag Sorting: added orderby=count and order=DESC to Gelbooru tag search so the most relevant and widely used tags appear first.',
|
||||||
|
'Batch Tag Categorization: migrated post tag metadata queries to the official "names" parameter, resolving categories for up to 80 tags in a single batch request instead of dozens of sequential requests.',
|
||||||
|
'Throttling & Auth Guidance: enhanced API credentials helper text and informative error messages with direct instructions to configure API Key & User ID from Gelbooru account options.',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
LunarisChange(
|
||||||
|
version: '2.3.1',
|
||||||
|
title: 'Cloud Mirrors & Author Announcements Fixes',
|
||||||
|
bullets: [
|
||||||
|
'Collapsible Cloud Mirrors: the cloud drives section is now folded by default into a sleek header card matching the Tags and Comments cards, expandable on tap.',
|
||||||
|
'Tag Panel Sanitation: filtered out cloud drive links, JSON metadata, and URLs from appearing in the post tags panel and tag counters.',
|
||||||
|
'Author Announcements HTML Cleaning: stripped all raw <p style="">, <strong>, and formatting artifacts from artist announcements, rendering clean readable text and clickable service link chips.',
|
||||||
|
],
|
||||||
|
),
|
||||||
LunarisChange(
|
LunarisChange(
|
||||||
version: '2.3.0',
|
version: '2.3.0',
|
||||||
title: 'Artist Cloud Drives & External Video Mirrors',
|
title: 'Artist Cloud Drives & External Video Mirrors',
|
||||||
|
|||||||
@@ -34,5 +34,6 @@ export 'services/settings_service.dart';
|
|||||||
export 'services/update_service.dart';
|
export 'services/update_service.dart';
|
||||||
export 'services/viewed_history_service.dart';
|
export 'services/viewed_history_service.dart';
|
||||||
export 'services/backup_service.dart';
|
export 'services/backup_service.dart';
|
||||||
|
export 'services/cloud_link_extractor.dart';
|
||||||
export 'utils/smart_blacklist.dart';
|
export 'utils/smart_blacklist.dart';
|
||||||
export 'utils/media_quality.dart';
|
export 'utils/media_quality.dart';
|
||||||
|
|||||||
@@ -60,6 +60,26 @@ class Post {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> get cleanTags {
|
||||||
|
return tags.where((tag) {
|
||||||
|
final t = tag.trim();
|
||||||
|
if (t.isEmpty) return false;
|
||||||
|
if (t == 'cloud_mirror') return false;
|
||||||
|
if (t.startsWith('{') && t.endsWith('}')) return false;
|
||||||
|
if (t.contains('"url"') || t.contains('"service"')) return false;
|
||||||
|
if (t.startsWith('http://') || t.startsWith('https://')) return false;
|
||||||
|
if (t.contains('mega.nz') ||
|
||||||
|
t.contains('drive.google.com') ||
|
||||||
|
t.contains('dropbox.com') ||
|
||||||
|
t.contains('pixeldrain.com') ||
|
||||||
|
t.contains('catbox.moe') ||
|
||||||
|
t.contains('mediafire.com')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
Post copyWith({
|
Post copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? providerId,
|
String? providerId,
|
||||||
|
|||||||
@@ -65,45 +65,55 @@ class GelbooruProvider
|
|||||||
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
if (rating != null && rating.isNotEmpty) 'rating:$rating',
|
||||||
..._topTags(topPeriod),
|
..._topTags(topPeriod),
|
||||||
];
|
];
|
||||||
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': queryTags.join(' '),
|
'limit': limit,
|
||||||
..._queryParameters,
|
'tags': queryTags.join(' '),
|
||||||
},
|
..._queryParameters,
|
||||||
);
|
},
|
||||||
return GelbooruMapper.postsFromResponse(
|
);
|
||||||
response.data,
|
_checkResponse(response);
|
||||||
providerId: id,
|
return GelbooruMapper.postsFromResponse(
|
||||||
providerName: name,
|
response.data,
|
||||||
);
|
providerId: id,
|
||||||
|
providerName: name,
|
||||||
|
);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
_handleDioError(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Post?> getPost(String id) async {
|
Future<Post?> getPost(String id) async {
|
||||||
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',
|
||||||
'id': id,
|
'json': '1',
|
||||||
..._queryParameters,
|
'id': id,
|
||||||
},
|
..._queryParameters,
|
||||||
);
|
},
|
||||||
final posts = GelbooruMapper.postsFromResponse(
|
);
|
||||||
response.data,
|
_checkResponse(response);
|
||||||
providerId: this.id,
|
final posts = GelbooruMapper.postsFromResponse(
|
||||||
providerName: name,
|
response.data,
|
||||||
);
|
providerId: this.id,
|
||||||
return posts.isEmpty ? null : posts.first;
|
providerName: name,
|
||||||
|
);
|
||||||
|
return posts.isEmpty ? null : posts.first;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
_handleDioError(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -142,18 +152,22 @@ class GelbooruProvider
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<PostComment>> getComments(String postId) async {
|
Future<List<PostComment>> getComments(String postId) async {
|
||||||
final response = await _dio.get<dynamic>(
|
try {
|
||||||
'/index.php',
|
final response = await _dio.get<dynamic>(
|
||||||
queryParameters: {
|
'/index.php',
|
||||||
'page': 'dapi',
|
queryParameters: {
|
||||||
's': 'comment',
|
'page': 'dapi',
|
||||||
'q': 'index',
|
's': 'comment',
|
||||||
'json': '1',
|
'q': 'index',
|
||||||
'post_id': postId,
|
'json': '1',
|
||||||
..._queryParameters,
|
'post_id': postId,
|
||||||
},
|
..._queryParameters,
|
||||||
);
|
},
|
||||||
return _commentsFromResponse(response.data, postId);
|
);
|
||||||
|
return _commentsFromResponse(response.data, postId);
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -161,33 +175,39 @@ class GelbooruProvider
|
|||||||
{int limit = 20}) async {
|
{int limit = 20}) async {
|
||||||
final trimmed = query.trim();
|
final trimmed = query.trim();
|
||||||
if (trimmed.isEmpty) return const [];
|
if (trimmed.isEmpty) return const [];
|
||||||
final response = await _dio.get<dynamic>(
|
try {
|
||||||
'/index.php',
|
final response = await _dio.get<dynamic>(
|
||||||
queryParameters: {
|
'/index.php',
|
||||||
'page': 'dapi',
|
queryParameters: {
|
||||||
's': 'tag',
|
'page': 'dapi',
|
||||||
'q': 'index',
|
's': 'tag',
|
||||||
'json': '1',
|
'q': 'index',
|
||||||
'name_pattern': '$trimmed*',
|
'json': '1',
|
||||||
'limit': limit.clamp(1, 100),
|
'name_pattern': '$trimmed%',
|
||||||
..._queryParameters,
|
'orderby': 'count',
|
||||||
},
|
'order': 'DESC',
|
||||||
);
|
'limit': limit.clamp(1, 100),
|
||||||
return _tagItems(response.data)
|
..._queryParameters,
|
||||||
.whereType<Map>()
|
},
|
||||||
.map((item) {
|
);
|
||||||
final json = Map<String, dynamic>.from(item);
|
return _tagItems(response.data)
|
||||||
return TagSuggestion(
|
.whereType<Map>()
|
||||||
name: (json['name'] ?? '').toString(),
|
.map((item) {
|
||||||
category: tagCategoryFromString(
|
final json = Map<String, dynamic>.from(item);
|
||||||
(json['type'] ?? json['tag_type'] ?? json['category']).toString(),
|
return TagSuggestion(
|
||||||
),
|
name: (json['name'] ?? '').toString(),
|
||||||
postCount: _int(json['count'] ?? json['post_count']),
|
category: tagCategoryFromString(
|
||||||
providerId: id,
|
(json['type'] ?? json['tag_type'] ?? json['category']).toString(),
|
||||||
);
|
),
|
||||||
})
|
postCount: _int(json['count'] ?? json['post_count']),
|
||||||
.where((tag) => tag.name.isNotEmpty)
|
providerId: id,
|
||||||
.toList(growable: false);
|
);
|
||||||
|
})
|
||||||
|
.where((tag) => tag.name.isNotEmpty)
|
||||||
|
.toList(growable: false);
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -201,27 +221,81 @@ class GelbooruProvider
|
|||||||
if (cleaned.isEmpty) return const {};
|
if (cleaned.isEmpty) return const {};
|
||||||
|
|
||||||
final groups = <String, List<String>>{};
|
final groups = <String, List<String>>{};
|
||||||
for (final tag in cleaned) {
|
final tagToCategory = <String, TagCategory>{};
|
||||||
try {
|
|
||||||
final suggestions = await suggestTags(tag, limit: 3);
|
// Batch query tag metadata using Gelbooru DAPI "names" parameter
|
||||||
final exact = suggestions.where((item) => item.name == tag).firstOrNull;
|
try {
|
||||||
final category = exact?.category ?? TagCategory.general;
|
for (var i = 0; i < cleaned.length; i += 50) {
|
||||||
final key = switch (category) {
|
final chunk = cleaned.sublist(
|
||||||
TagCategory.artist => 'artist',
|
i,
|
||||||
TagCategory.copyright => 'copyright',
|
(i + 50 > cleaned.length) ? cleaned.length : i + 50,
|
||||||
TagCategory.character => 'character',
|
);
|
||||||
TagCategory.meta => 'meta',
|
final response = await _dio.get<dynamic>(
|
||||||
TagCategory.species => 'species',
|
'/index.php',
|
||||||
TagCategory.general || TagCategory.unknown => 'general',
|
queryParameters: {
|
||||||
};
|
'page': 'dapi',
|
||||||
groups.putIfAbsent(key, () => <String>[]).add(tag);
|
's': 'tag',
|
||||||
} catch (_) {
|
'q': 'index',
|
||||||
groups.putIfAbsent('general', () => <String>[]).add(tag);
|
'json': '1',
|
||||||
|
'names': chunk.join(' '),
|
||||||
|
'limit': chunk.length.clamp(1, 100),
|
||||||
|
..._queryParameters,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for (final item in _tagItems(response.data).whereType<Map>()) {
|
||||||
|
final json = Map<String, dynamic>.from(item);
|
||||||
|
final name = (json['name'] ?? '').toString().trim();
|
||||||
|
if (name.isNotEmpty) {
|
||||||
|
tagToCategory[name] = tagCategoryFromString(
|
||||||
|
(json['type'] ?? json['tag_type'] ?? json['category']).toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Gracefully continue to fallback categorization
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final tag in cleaned) {
|
||||||
|
final category = tagToCategory[tag] ?? TagCategory.general;
|
||||||
|
final key = switch (category) {
|
||||||
|
TagCategory.artist => 'artist',
|
||||||
|
TagCategory.copyright => 'copyright',
|
||||||
|
TagCategory.character => 'character',
|
||||||
|
TagCategory.meta => 'meta',
|
||||||
|
TagCategory.species => 'species',
|
||||||
|
TagCategory.general || TagCategory.unknown => 'general',
|
||||||
|
};
|
||||||
|
groups.putIfAbsent(key, () => <String>[]).add(tag);
|
||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _checkResponse(Response<dynamic> response) {
|
||||||
|
if (response.data is String) {
|
||||||
|
final text = (response.data as String).toLowerCase();
|
||||||
|
if (text.contains('throttled') ||
|
||||||
|
text.contains('authentication required') ||
|
||||||
|
text.contains('requires authentication')) {
|
||||||
|
unavailable(
|
||||||
|
'$name requires authentication or is throttled. '
|
||||||
|
'Please configure API Key & User ID in Settings -> Providers.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Never _handleDioError(DioException error) {
|
||||||
|
final status = error.response?.statusCode;
|
||||||
|
if (status == 401 || status == 403 || status == 429) {
|
||||||
|
unavailable(
|
||||||
|
'$name requires authentication or rate limit exceeded ($status). '
|
||||||
|
'Please enter your API Key and User ID in Settings -> Providers.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
Never unavailable(String message) =>
|
Never unavailable(String message) =>
|
||||||
throw ProviderUnavailableException(message);
|
throw ProviderUnavailableException(message);
|
||||||
|
|
||||||
|
|||||||
@@ -292,19 +292,49 @@ class CloudLinkExtractor {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts clean text (stripping HTML tags) suitable for author commentary display.
|
/// Extracts clean, readable text (stripping HTML tags and formatting links) suitable for author commentary and announcements.
|
||||||
static String cleanCommentary(String? rawHtml) {
|
static String cleanCommentary(String? rawHtml) {
|
||||||
if (rawHtml == null || rawHtml.trim().isEmpty) return '';
|
if (rawHtml == null || rawHtml.trim().isEmpty) return '';
|
||||||
var text = rawHtml
|
var text = rawHtml;
|
||||||
|
|
||||||
|
// Format anchor tags: <a href="URL">TEXT</a>
|
||||||
|
text = text.replaceAllMapped(
|
||||||
|
RegExp(r"""<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""",
|
||||||
|
caseSensitive: false, dotAll: true),
|
||||||
|
(match) {
|
||||||
|
final href = match.group(1)?.trim() ?? '';
|
||||||
|
final label = match.group(2)?.replaceAll(RegExp(r'<[^>]*>'), '').trim() ?? '';
|
||||||
|
if (label.isEmpty || label == href || href.contains(label)) {
|
||||||
|
return href;
|
||||||
|
}
|
||||||
|
return '$label ($href)';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert line breaks and paragraph endings
|
||||||
|
text = text
|
||||||
.replaceAll(RegExp(r'<br\s*\/?>', caseSensitive: false), '\n')
|
.replaceAll(RegExp(r'<br\s*\/?>', caseSensitive: false), '\n')
|
||||||
.replaceAll(RegExp(r'<\/p>', caseSensitive: false), '\n\n')
|
.replaceAll(RegExp(r'<\/p\s*>', caseSensitive: false), '\n\n')
|
||||||
.replaceAll(RegExp(r'<[^>]*>'), '')
|
.replaceAll(RegExp(r'<\/div\s*>', caseSensitive: false), '\n')
|
||||||
|
.replaceAll(RegExp(r'<li\s*>', caseSensitive: false), '• ')
|
||||||
|
.replaceAll(RegExp(r'<\/li\s*>', caseSensitive: false), '\n');
|
||||||
|
|
||||||
|
// Strip remaining HTML tags
|
||||||
|
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
|
||||||
|
|
||||||
|
// Decode HTML entities
|
||||||
|
text = text
|
||||||
.replaceAll(' ', ' ')
|
.replaceAll(' ', ' ')
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
.replaceAll('<', '<')
|
.replaceAll('<', '<')
|
||||||
.replaceAll('>', '>')
|
.replaceAll('>', '>')
|
||||||
.replaceAll('"', '"')
|
.replaceAll('"', '"')
|
||||||
.replaceAll(''', "'");
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(''', "'");
|
||||||
|
|
||||||
|
// Normalize multiple consecutive blank lines
|
||||||
|
text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n');
|
||||||
|
|
||||||
return text.trim();
|
return text.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../../app/app.dart';
|
import '../../../app/app.dart';
|
||||||
import '../../../app/responsive.dart';
|
import '../../../app/responsive.dart';
|
||||||
@@ -230,6 +231,9 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
separatorBuilder: (_, __) => const Divider(height: 24),
|
separatorBuilder: (_, __) => const Divider(height: 24),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final a = _announcements[index];
|
final a = _announcements[index];
|
||||||
|
final cleanContent = CloudLinkExtractor.cleanCommentary(a.content);
|
||||||
|
final detectedLinks = CloudLinkExtractor.extractLinks(content: a.content);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -244,9 +248,35 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
SelectableText(
|
SelectableText(
|
||||||
a.content,
|
cleanContent.isNotEmpty ? cleanContent : a.content,
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (detectedLinks.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: detectedLinks.map((link) {
|
||||||
|
return ActionChip(
|
||||||
|
avatar: Icon(
|
||||||
|
link.iconData,
|
||||||
|
size: 16,
|
||||||
|
color: link.brandColor,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
link.title.isNotEmpty ? link.title : link.serviceName,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
onPressed: () => launchUrl(
|
||||||
|
Uri.parse(link.url),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'${strings.tags} (${post.tags.length})',
|
'${strings.tags} (${post.cleanTags.length})',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleMedium
|
.titleMedium
|
||||||
@@ -515,7 +515,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
|||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'${strings.tags} (${post.tags.length})',
|
'${strings.tags} (${post.cleanTags.length})',
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class CloudMirrorsCard extends StatelessWidget {
|
|||||||
this.commentary,
|
this.commentary,
|
||||||
this.onPlayStream,
|
this.onPlayStream,
|
||||||
this.onDownloadStream,
|
this.onDownloadStream,
|
||||||
|
this.initiallyExpanded = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<CloudMediaLink> links;
|
final List<CloudMediaLink> links;
|
||||||
@@ -20,6 +21,7 @@ class CloudMirrorsCard extends StatelessWidget {
|
|||||||
final String? commentary;
|
final String? commentary;
|
||||||
final ValueChanged<String>? onPlayStream;
|
final ValueChanged<String>? onPlayStream;
|
||||||
final ValueChanged<String>? onDownloadStream;
|
final ValueChanged<String>? onDownloadStream;
|
||||||
|
final bool initiallyExpanded;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -36,84 +38,68 @@ class CloudMirrorsCard extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerLow,
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(18),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.2),
|
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(16),
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Column(
|
child: ExpansionTile(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||||
children: [
|
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||||
// Header
|
initiallyExpanded: initiallyExpanded,
|
||||||
Row(
|
shape: const Border(),
|
||||||
children: [
|
collapsedShape: const Border(),
|
||||||
Container(
|
leading: Icon(
|
||||||
padding: const EdgeInsets.all(8),
|
Icons.cloud_sync_rounded,
|
||||||
decoration: BoxDecoration(
|
color: theme.colorScheme.primary,
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.15),
|
),
|
||||||
shape: BoxShape.circle,
|
title: Row(
|
||||||
),
|
children: [
|
||||||
child: Icon(
|
Expanded(
|
||||||
Icons.cloud_sync_rounded,
|
child: Text(
|
||||||
size: 20,
|
strings.ru
|
||||||
color: theme.colorScheme.primary,
|
? 'Облачные диски и зеркала (${links.length})'
|
||||||
|
: 'Cloud Mirrors & Drives (${links.length})',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
),
|
||||||
Expanded(
|
if (links.any((l) => l.isStreamable)) ...[
|
||||||
child: Column(
|
const SizedBox(width: 8),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.green.withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
const Icon(Icons.play_circle_fill_rounded,
|
||||||
|
size: 13, color: Colors.green),
|
||||||
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
strings.ru
|
strings.ru ? 'Плеер' : 'Stream',
|
||||||
? 'Облачные диски и зеркала'
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
: 'Cloud Mirrors & Drives',
|
color: Colors.green,
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
fontSize: 10,
|
||||||
),
|
|
||||||
Text(
|
|
||||||
strings.ru
|
|
||||||
? '${links.length} внешних источников'
|
|
||||||
: '${links.length} external sources',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (links.any((l) => l.isStreamable))
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.green.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: Colors.green.withValues(alpha: 0.4)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.play_circle_fill_rounded,
|
|
||||||
size: 14, color: Colors.green),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
strings.ru ? 'Плеер доступен' : 'Streamable',
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: Colors.green,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
|
),
|
||||||
|
children: [
|
||||||
// Archive Password Banner
|
// Archive Password Banner
|
||||||
if (detectedPassword.isNotEmpty) ...[
|
if (detectedPassword.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -166,22 +152,22 @@ class CloudMirrorsCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Links list
|
// Links list
|
||||||
if (links.isNotEmpty) ...[
|
if (links.isNotEmpty) ...[
|
||||||
const SizedBox(height: 14),
|
|
||||||
...links.map((link) => _buildLinkTile(context, link, strings)),
|
...links.map((link) => _buildLinkTile(context, link, strings)),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Commentary / Description
|
// Commentary / Description
|
||||||
if (commentary != null && commentary!.trim().isNotEmpty) ...[
|
if (commentary != null && commentary!.trim().isNotEmpty) ...[
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 10),
|
||||||
Divider(
|
Divider(
|
||||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
|
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||||
height: 1,
|
height: 1,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -219,6 +219,14 @@ class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const _ignoredTagGroupKeys = {
|
||||||
|
'cloud_links',
|
||||||
|
'description',
|
||||||
|
'content',
|
||||||
|
'links',
|
||||||
|
'external_links',
|
||||||
|
};
|
||||||
|
|
||||||
Map<String, List<String>> _groups(Post post) {
|
Map<String, List<String>> _groups(Post post) {
|
||||||
if (post.tagGroups.isNotEmpty) {
|
if (post.tagGroups.isNotEmpty) {
|
||||||
final ordered = <String, List<String>>{};
|
final ordered = <String, List<String>>{};
|
||||||
@@ -230,17 +238,40 @@ class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
|
|||||||
'meta',
|
'meta',
|
||||||
'general',
|
'general',
|
||||||
]) {
|
]) {
|
||||||
final tags = post.tagGroups[key];
|
final tags = _filterValidTags(post.tagGroups[key]);
|
||||||
if (tags != null && tags.isNotEmpty) ordered[key] = tags;
|
if (tags.isNotEmpty) ordered[key] = tags;
|
||||||
}
|
}
|
||||||
for (final entry in post.tagGroups.entries) {
|
for (final entry in post.tagGroups.entries) {
|
||||||
if (entry.value.isNotEmpty) {
|
if (_ignoredTagGroupKeys.contains(entry.key.toLowerCase())) continue;
|
||||||
ordered.putIfAbsent(entry.key, () => entry.value);
|
final tags = _filterValidTags(entry.value);
|
||||||
|
if (tags.isNotEmpty) {
|
||||||
|
ordered.putIfAbsent(entry.key, () => tags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ordered;
|
return ordered;
|
||||||
}
|
}
|
||||||
return post.tags.isEmpty ? const {} : {'general': post.tags};
|
final filtered = _filterValidTags(post.tags);
|
||||||
|
return filtered.isEmpty ? const {} : {'general': filtered};
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _filterValidTags(List<String>? rawTags) {
|
||||||
|
if (rawTags == null || rawTags.isEmpty) return const [];
|
||||||
|
return rawTags.where((tag) {
|
||||||
|
final t = tag.trim();
|
||||||
|
if (t.isEmpty) return false;
|
||||||
|
if (t.startsWith('{') && t.endsWith('}')) return false;
|
||||||
|
if (t.contains('"url"') || t.contains('"service"')) return false;
|
||||||
|
if (t.startsWith('http://') || t.startsWith('https://')) return false;
|
||||||
|
if (t.contains('mega.nz') ||
|
||||||
|
t.contains('drive.google.com') ||
|
||||||
|
t.contains('dropbox.com') ||
|
||||||
|
t.contains('pixeldrain.com') ||
|
||||||
|
t.contains('catbox.moe') ||
|
||||||
|
t.contains('mediafire.com')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _label(String key) {
|
String _label(String key) {
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
|
|||||||
controller: _apiKey,
|
controller: _apiKey,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'API key',
|
labelText: 'API key',
|
||||||
helperText: 'Gelbooru: api_key, Danbooru: api_key',
|
helperText:
|
||||||
|
'Gelbooru/Danbooru API key (Gelbooru: Account -> Options)',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -137,7 +138,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
|
|||||||
controller: _userId,
|
controller: _userId,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'User ID',
|
labelText: 'User ID',
|
||||||
helperText: 'Gelbooru-compatible APIs may require user_id',
|
helperText:
|
||||||
|
'Gelbooru User ID (required when throttled, see profile)',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
+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.0+40
|
version: 2.3.2+42
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.0 <4.0.0"
|
sdk: ">=3.3.0 <4.0.0"
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ void main() {
|
|||||||
expect(clean, 'Hello world!\nCheck out the new video.\n\n& enjoy!');
|
expect(clean, 'Hello world!\nCheck out the new video.\n\n& enjoy!');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cleans HTML commentary and announcements correctly without style artifacts', () {
|
||||||
|
const html =
|
||||||
|
'<p style=""><strong>Thank you!</strong> </p><p style="">Here\'s the link to the archive</p><p style=""><a href="https://www.patreon.com/posts/full-fugtrup-14581297" rel="noopener noreferrer nofollow">https://www.patreon.com/posts/full-fugtrup-14581297</a></p><p style="">and the link to my <strong>discord server</strong>, if you\'d like to join it <a href="https://discord.com/invite/xR2AbkX" rel="noopener noreferrer nofollow">https://discord.com/invite/xR2AbkX</a></p>';
|
||||||
|
final clean = CloudLinkExtractor.cleanCommentary(html);
|
||||||
|
expect(clean.contains('<p'), isFalse);
|
||||||
|
expect(clean.contains('style='), isFalse);
|
||||||
|
expect(clean.contains('<strong>'), isFalse);
|
||||||
|
expect(clean.contains('Thank you!'), isTrue);
|
||||||
|
expect(clean.contains('https://discord.com/invite/xR2AbkX'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
test('encodes and decodes CloudMediaLink to and from JSON', () {
|
test('encodes and decodes CloudMediaLink to and from JSON', () {
|
||||||
const link = CloudMediaLink(
|
const link = CloudMediaLink(
|
||||||
url: 'https://mega.nz/file/123#abc',
|
url: 'https://mega.nz/file/123#abc',
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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/gelbooru_provider.dart';
|
||||||
|
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
|
||||||
|
import 'package:gel_rule_app/core/http/dio_client.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('ProviderFactory creates GelbooruProvider with query credentials', () {
|
||||||
|
final factory = ProviderFactory();
|
||||||
|
final config = ContentProviderConfig(
|
||||||
|
id: 'gelbooru',
|
||||||
|
name: 'Gelbooru',
|
||||||
|
baseUrl: 'https://gelbooru.com',
|
||||||
|
apiType: 'gelbooru',
|
||||||
|
enabled: true,
|
||||||
|
priority: 10,
|
||||||
|
timeoutSeconds: 20,
|
||||||
|
customHeaders: const {
|
||||||
|
'query.api_key': 'test_api_key',
|
||||||
|
'query.user_id': '123456',
|
||||||
|
},
|
||||||
|
createdAt: DateTime(2026),
|
||||||
|
updatedAt: DateTime(2026),
|
||||||
|
);
|
||||||
|
|
||||||
|
final provider = factory.create(config);
|
||||||
|
expect(provider, isA<GelbooruProvider>());
|
||||||
|
final gelbooru = provider as GelbooruProvider;
|
||||||
|
expect(gelbooru.id, 'gelbooru');
|
||||||
|
expect(gelbooru.queryParameters['api_key'], 'test_api_key');
|
||||||
|
expect(gelbooru.queryParameters['user_id'], '123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GelbooruProvider mediaHeaders contains Referer and User-Agent', () {
|
||||||
|
final client = DioClient(baseUrl: 'https://gelbooru.com');
|
||||||
|
final provider = GelbooruProvider(
|
||||||
|
id: 'gelbooru',
|
||||||
|
name: 'Gelbooru',
|
||||||
|
baseUrl: 'https://gelbooru.com',
|
||||||
|
dioClient: client,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.baseUrl, 'https://gelbooru.com');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ void main() {
|
|||||||
strings: const AppStrings('ru'),
|
strings: const AppStrings('ru'),
|
||||||
commentary: 'Special Patreon bonus animation for all patrons.',
|
commentary: 'Special Patreon bonus animation for all patrons.',
|
||||||
onPlayStream: (stream) => playedStream = stream,
|
onPlayStream: (stream) => playedStream = stream,
|
||||||
|
initiallyExpanded: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -42,9 +43,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Verify header and counts
|
// Verify header and counts
|
||||||
expect(find.text('Облачные диски и зеркала'), findsOneWidget);
|
expect(find.text('Облачные диски и зеркала (2)'), findsOneWidget);
|
||||||
expect(find.text('2 внешних источников'), findsOneWidget);
|
expect(find.text('Плеер'), findsOneWidget);
|
||||||
expect(find.text('Плеер доступен'), findsOneWidget);
|
|
||||||
|
|
||||||
// Verify password banner
|
// Verify password banner
|
||||||
expect(find.textContaining('archive_pass_42'), findsOneWidget);
|
expect(find.textContaining('archive_pass_42'), findsOneWidget);
|
||||||
|
|||||||
Reference in New Issue
Block a user