From 87e126ec4d3732485e0454fc6a9d25b59236aebb Mon Sep 17 00:00:00 2001 From: RarDog Date: Fri, 4 Sep 2026 12:26:15 +0300 Subject: [PATCH] fix: collapsible cloud mirrors, tag panel sanitization, and clean announcement HTML (v2.3.1) --- README.md | 2 +- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 9 ++ lib/backend/backend.dart | 1 + lib/backend/models/post.dart | 20 +++ .../services/cloud_link_extractor.dart | 40 +++++- .../presentation/artist_posts_screen.dart | 34 ++++- .../presentation/post_details_screen.dart | 4 +- .../widgets/cloud_mirrors_card.dart | 116 ++++++++---------- .../presentation/widgets/post_tags_panel.dart | 41 ++++++- pubspec.yaml | 2 +- test/backend/cloud_link_extractor_test.dart | 11 ++ test/features/cloud_mirrors_card_test.dart | 6 +- 13 files changed, 204 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index d1cbee5..037a330 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version 2.3.0 + Version 2.3.1 Flutter Platforms Gitea diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 5ba95c1..2ff0f52 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '2.3.0'; -const appBuildNumber = 40; +const appDisplayVersion = '2.3.1'; +const appBuildNumber = 41; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 99f762d..38e642b 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -1,4 +1,13 @@ const lunarisChangelog = [ + 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

, , and formatting artifacts from artist announcements, rendering clean readable text and clickable service link chips.', + ], + ), LunarisChange( version: '2.3.0', title: 'Artist Cloud Drives & External Video Mirrors', diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart index af9f3e2..2bbc0cf 100644 --- a/lib/backend/backend.dart +++ b/lib/backend/backend.dart @@ -34,5 +34,6 @@ export 'services/settings_service.dart'; export 'services/update_service.dart'; export 'services/viewed_history_service.dart'; export 'services/backup_service.dart'; +export 'services/cloud_link_extractor.dart'; export 'utils/smart_blacklist.dart'; export 'utils/media_quality.dart'; diff --git a/lib/backend/models/post.dart b/lib/backend/models/post.dart index b175386..55e4810 100644 --- a/lib/backend/models/post.dart +++ b/lib/backend/models/post.dart @@ -60,6 +60,26 @@ class Post { return null; } + List 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({ String? id, String? providerId, diff --git a/lib/backend/services/cloud_link_extractor.dart b/lib/backend/services/cloud_link_extractor.dart index d61d990..544cc98 100644 --- a/lib/backend/services/cloud_link_extractor.dart +++ b/lib/backend/services/cloud_link_extractor.dart @@ -292,19 +292,49 @@ class CloudLinkExtractor { 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) { if (rawHtml == null || rawHtml.trim().isEmpty) return ''; - var text = rawHtml + var text = rawHtml; + + // Format anchor tags: TEXT + text = text.replaceAllMapped( + RegExp(r"""]*href=["']([^"']+)["'][^>]*>(.*?)""", + 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'', caseSensitive: false), '\n') - .replaceAll(RegExp(r'<\/p>', caseSensitive: false), '\n\n') - .replaceAll(RegExp(r'<[^>]*>'), '') + .replaceAll(RegExp(r'<\/p\s*>', caseSensitive: false), '\n\n') + .replaceAll(RegExp(r'<\/div\s*>', caseSensitive: false), '\n') + .replaceAll(RegExp(r'', 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(''', "'"); + + // Normalize multiple consecutive blank lines + text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n'); + return text.trim(); } } diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart index 8730d4b..590e725 100644 --- a/lib/features/artists/presentation/artist_posts_screen.dart +++ b/lib/features/artists/presentation/artist_posts_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../../app/app.dart'; import '../../../app/responsive.dart'; @@ -230,6 +231,9 @@ class _ArtistPostsScreenState extends ConsumerState { separatorBuilder: (_, __) => const Divider(height: 24), itemBuilder: (context, index) { final a = _announcements[index]; + final cleanContent = CloudLinkExtractor.cleanCommentary(a.content); + final detectedLinks = CloudLinkExtractor.extractLinks(content: a.content); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -244,9 +248,35 @@ class _ArtistPostsScreenState extends ConsumerState { ), const SizedBox(height: 6), SelectableText( - a.content, - style: theme.textTheme.bodyMedium, + cleanContent.isNotEmpty ? cleanContent : a.content, + 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(), + ), + ], ], ); }, diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index ce2842c..88f6be3 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -339,7 +339,7 @@ class PostDetailsScreen extends ConsumerWidget { ), const SizedBox(width: 8), Text( - '${strings.tags} (${post.tags.length})', + '${strings.tags} (${post.cleanTags.length})', style: Theme.of(context) .textTheme .titleMedium @@ -515,7 +515,7 @@ class PostDetailsScreen extends ConsumerWidget { color: Theme.of(context).colorScheme.primary, ), title: Text( - '${strings.tags} (${post.tags.length})', + '${strings.tags} (${post.cleanTags.length})', style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), diff --git a/lib/features/post/presentation/widgets/cloud_mirrors_card.dart b/lib/features/post/presentation/widgets/cloud_mirrors_card.dart index 076ec39..cf2f15f 100644 --- a/lib/features/post/presentation/widgets/cloud_mirrors_card.dart +++ b/lib/features/post/presentation/widgets/cloud_mirrors_card.dart @@ -13,6 +13,7 @@ class CloudMirrorsCard extends StatelessWidget { this.commentary, this.onPlayStream, this.onDownloadStream, + this.initiallyExpanded = false, }); final List links; @@ -20,6 +21,7 @@ class CloudMirrorsCard extends StatelessWidget { final String? commentary; final ValueChanged? onPlayStream; final ValueChanged? onDownloadStream; + final bool initiallyExpanded; @override Widget build(BuildContext context) { @@ -36,84 +38,68 @@ class CloudMirrorsCard extends StatelessWidget { return Container( decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(18), border: Border.all( - color: theme.colorScheme.primary.withValues(alpha: 0.2), + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35), ), ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.15), - shape: BoxShape.circle, - ), - child: Icon( - Icons.cloud_sync_rounded, - size: 20, - color: theme.colorScheme.primary, + clipBehavior: Clip.antiAlias, + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), + childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14), + initiallyExpanded: initiallyExpanded, + shape: const Border(), + collapsedShape: const Border(), + leading: Icon( + Icons.cloud_sync_rounded, + color: theme.colorScheme.primary, + ), + title: Row( + children: [ + Expanded( + child: Text( + strings.ru + ? 'Облачные диски и зеркала (${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( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + if (links.any((l) => l.isStreamable)) ...[ + const SizedBox(width: 8), + 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: [ + const Icon(Icons.play_circle_fill_rounded, + size: 13, color: Colors.green), + const SizedBox(width: 4), Text( - strings.ru - ? 'Облачные диски и зеркала' - : 'Cloud Mirrors & Drives', - style: theme.textTheme.titleMedium?.copyWith( + strings.ru ? 'Плеер' : 'Stream', + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.green, fontWeight: FontWeight.bold, - ), - ), - Text( - strings.ru - ? '${links.length} внешних источников' - : '${links.length} external sources', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, ), ), ], ), ), - 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 if (detectedPassword.isNotEmpty) ...[ - const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( @@ -166,22 +152,22 @@ class CloudMirrorsCard extends StatelessWidget { ], ), ), + const SizedBox(height: 10), ], // Links list if (links.isNotEmpty) ...[ - const SizedBox(height: 14), ...links.map((link) => _buildLinkTile(context, link, strings)), ], // Commentary / Description if (commentary != null && commentary!.trim().isNotEmpty) ...[ - const SizedBox(height: 14), + const SizedBox(height: 10), Divider( color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4), height: 1, ), - const SizedBox(height: 12), + const SizedBox(height: 10), Row( children: [ Icon( diff --git a/lib/features/post/presentation/widgets/post_tags_panel.dart b/lib/features/post/presentation/widgets/post_tags_panel.dart index 1b078da..eb9d656 100644 --- a/lib/features/post/presentation/widgets/post_tags_panel.dart +++ b/lib/features/post/presentation/widgets/post_tags_panel.dart @@ -219,6 +219,14 @@ class _PostTagsPanelState extends ConsumerState { ); } + static const _ignoredTagGroupKeys = { + 'cloud_links', + 'description', + 'content', + 'links', + 'external_links', + }; + Map> _groups(Post post) { if (post.tagGroups.isNotEmpty) { final ordered = >{}; @@ -230,17 +238,40 @@ class _PostTagsPanelState extends ConsumerState { 'meta', 'general', ]) { - final tags = post.tagGroups[key]; - if (tags != null && tags.isNotEmpty) ordered[key] = tags; + final tags = _filterValidTags(post.tagGroups[key]); + if (tags.isNotEmpty) ordered[key] = tags; } for (final entry in post.tagGroups.entries) { - if (entry.value.isNotEmpty) { - ordered.putIfAbsent(entry.key, () => entry.value); + if (_ignoredTagGroupKeys.contains(entry.key.toLowerCase())) continue; + final tags = _filterValidTags(entry.value); + if (tags.isNotEmpty) { + ordered.putIfAbsent(entry.key, () => tags); } } return ordered; } - return post.tags.isEmpty ? const {} : {'general': post.tags}; + final filtered = _filterValidTags(post.tags); + return filtered.isEmpty ? const {} : {'general': filtered}; + } + + List _filterValidTags(List? 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) { diff --git a/pubspec.yaml b/pubspec.yaml index bda5087..721f26a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 2.3.0+40 +version: 2.3.1+41 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/test/backend/cloud_link_extractor_test.dart b/test/backend/cloud_link_extractor_test.dart index 5c2b46a..b6958c7 100644 --- a/test/backend/cloud_link_extractor_test.dart +++ b/test/backend/cloud_link_extractor_test.dart @@ -85,6 +85,17 @@ void main() { expect(clean, 'Hello world!\nCheck out the new video.\n\n& enjoy!'); }); + test('cleans HTML commentary and announcements correctly without style artifacts', () { + const html = + '

Thank you!

Here\'s the link to the archive

https://www.patreon.com/posts/full-fugtrup-14581297

and the link to my discord server, if you\'d like to join it https://discord.com/invite/xR2AbkX

'; + final clean = CloudLinkExtractor.cleanCommentary(html); + expect(clean.contains(''), 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', () { const link = CloudMediaLink( url: 'https://mega.nz/file/123#abc', diff --git a/test/features/cloud_mirrors_card_test.dart b/test/features/cloud_mirrors_card_test.dart index 0f963b2..ffe9383 100644 --- a/test/features/cloud_mirrors_card_test.dart +++ b/test/features/cloud_mirrors_card_test.dart @@ -35,6 +35,7 @@ void main() { strings: const AppStrings('ru'), commentary: 'Special Patreon bonus animation for all patrons.', onPlayStream: (stream) => playedStream = stream, + initiallyExpanded: true, ), ), ), @@ -42,9 +43,8 @@ void main() { ); // Verify header and counts - expect(find.text('Облачные диски и зеркала'), findsOneWidget); - expect(find.text('2 внешних источников'), findsOneWidget); - expect(find.text('Плеер доступен'), findsOneWidget); + expect(find.text('Облачные диски и зеркала (2)'), findsOneWidget); + expect(find.text('Плеер'), findsOneWidget); // Verify password banner expect(find.textContaining('archive_pass_42'), findsOneWidget);