Compare commits

..
1 Commits
13 changed files with 204 additions and 86 deletions
+1 -1
View File
@@ -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" />
+2 -2
View File
@@ -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';
+9
View File
@@ -1,4 +1,13 @@
const lunarisChangelog = [ 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( LunarisChange(
version: '2.3.0', version: '2.3.0',
title: 'Artist Cloud Drives & External Video Mirrors', 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/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';
+20
View File
@@ -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,
+35 -5
View File
@@ -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('&nbsp;', ' ') .replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&') .replaceAll('&amp;', '&')
.replaceAll('&lt;', '<') .replaceAll('&lt;', '<')
.replaceAll('&gt;', '>') .replaceAll('&gt;', '>')
.replaceAll('&quot;', '"') .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(); 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) {
+1 -1
View File
@@ -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.1+41
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',
+3 -3
View File
@@ -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);