fix: collapsible cloud mirrors, tag panel sanitization, and clean announcement HTML (v2.3.1)

This commit is contained in:
2026-09-04 12:26:15 +03:00
parent 639bfc50af
commit e2c106476d
13 changed files with 204 additions and 86 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
</p>
<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/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" />
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.3.0';
const appBuildNumber = 40;
const appDisplayVersion = '2.3.1';
const appBuildNumber = 41;
const appReleaseChannel = 'Stable';
+9
View File
@@ -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 <p style="">, <strong>, 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',
+1
View File
@@ -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';
+20
View File
@@ -60,6 +60,26 @@ class Post {
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({
String? id,
String? providerId,
+35 -5
View File
@@ -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: <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'<\/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'<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('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'");
// Normalize multiple consecutive blank lines
text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n');
return text.trim();
}
}
@@ -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<ArtistPostsScreen> {
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<ArtistPostsScreen> {
),
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(),
),
],
],
);
},
@@ -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,
),
@@ -13,6 +13,7 @@ class CloudMirrorsCard extends StatelessWidget {
this.commentary,
this.onPlayStream,
this.onDownloadStream,
this.initiallyExpanded = false,
});
final List<CloudMediaLink> links;
@@ -20,6 +21,7 @@ class CloudMirrorsCard extends StatelessWidget {
final String? commentary;
final ValueChanged<String>? onPlayStream;
final ValueChanged<String>? onDownloadStream;
final bool initiallyExpanded;
@override
Widget build(BuildContext context) {
@@ -36,57 +38,40 @@ 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(
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,
size: 20,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
title: Row(
children: [
Text(
Expanded(
child: Text(
strings.ru
? 'Облачные диски и зеркала'
: 'Cloud Mirrors & Drives',
style: theme.textTheme.titleMedium?.copyWith(
? 'Облачные диски и зеркала (${links.length})'
: 'Cloud Mirrors & Drives (${links.length})',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
strings.ru
? '${links.length} внешних источников'
: '${links.length} external sources',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (links.any((l) => l.isStreamable))
if (links.any((l) => l.isStreamable)) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
@@ -96,24 +81,25 @@ class CloudMirrorsCard extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_circle_fill_rounded,
size: 14, color: Colors.green),
size: 13, color: Colors.green),
const SizedBox(width: 4),
Text(
strings.ru ? 'Плеер доступен' : 'Streamable',
strings.ru ? 'Плеер' : 'Stream',
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.green,
fontWeight: FontWeight.bold,
fontSize: 10,
),
),
],
),
),
],
],
),
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(
@@ -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) {
if (post.tagGroups.isNotEmpty) {
final ordered = <String, List<String>>{};
@@ -230,17 +238,40 @@ class _PostTagsPanelState extends ConsumerState<PostTagsPanel> {
'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<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) {
+1 -1
View File
@@ -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"
@@ -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 =
'<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', () {
const link = CloudMediaLink(
url: 'https://mega.nz/file/123#abc',
+3 -3
View File
@@ -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);