Compare commits

...
4 Commits
9 changed files with 315 additions and 125 deletions
+8 -7
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Version-2.0.6-blueviolet?style=for-the-badge" alt="Version 2.0.6" />
<img src="https://img.shields.io/badge/Version-2.0.8-blueviolet?style=for-the-badge" alt="Version 2.0.8" />
<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" />
@@ -20,11 +20,12 @@
## ✨ Основные возможности
- **🌐 Общая лента Pawchive**: Недавние посты со всех платформ прямо в главной ленте (Feed) с поддержкой поиска.
- **🎯 Общие модификаторы в мультипоиске**: При вводе `cat and dog anthro` модификатор `anthro` автоматически применится к обоим тегам (`cat anthro` + `dog anthro`).
- **🔀 Раздельный мульти-поиск по тегам через `and`**: Оператор `and` опрашивает серверную базу по каждому тегу независимо и плавно чередует посты обоих тегов в общей ленте.
- **🌐 Pawchive в главной ленте**: Глобальная лента недавних постов с Pawchive подключена прямо в общий Feed наравне с Rule34, Gelbooru, e621 и Realbooru.
- **🏷️ Интерактивные теги автора**: Быстрая фильтрация по темам с живыми счётчиками постов (`#tag (count)`).
- **📢 Анонсы и новости авторов**: Всплывающее окно анонсов автора с датами и текстом обращений к аудитории.
- **🔗 Связанные аккаунты**: Удобный переход между профилями автора на разных платформах (Patreon, Fanbox, Discord и др.).
- **🎬 Серверная фильтрация по типу медиа**: Поиск фото, видео и GIF по всей глубине архива автора через API.
- **⚡ Мгновенный фильтр фото, видео и GIF**: Локальная быстрая фильтрация постов автора без задержек.
- **📱 Плавная лента в стиле Pinterest**: Masonry-сетка с переключением режима (Masonry, 1:1 Сетка, Список) в шапке ленты.
- **⚡ Сверхбыстрые видео-превью**: Мгновенная загрузка превью без фризов и лишней нагрузки на процессор и память смартфона.
- **↔️ Листание постов влево и вправо**: Полноэкранный пейджер с плавными свайпами между фото и видео во всех разделах.
@@ -65,9 +66,9 @@
🔗 **[Gitea Releases (RarDog/Lunaris)](https://gitea.rardogsynapse.online/RarDog/Lunaris/releases)**
- **Android**: `Lunaris-v2.0.6.apk`
- **Linux (x64)**: `Lunaris-v2.0.6-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.6.exe` / `LunarisPortable-v2.0.6.zip`
- **Android**: `Lunaris-v2.0.8.apk`
- **Linux (x64)**: `Lunaris-v2.0.8-linux-x64.tar.gz`
- **Windows**: `LunarisSetup-v2.0.8.exe` / `LunarisPortable-v2.0.8.zip`
---
+2 -2
View File
@@ -1,3 +1,3 @@
const appDisplayVersion = '2.0.6';
const appBuildNumber = 28;
const appDisplayVersion = '2.0.8';
const appBuildNumber = 30;
const appReleaseChannel = 'Stable';
+20
View File
@@ -1,4 +1,24 @@
const lunarisChangelog = [
LunarisChange(
version: '2.0.8',
title: 'Shared Context Modifiers for Multi-Tag Search & Clean Tag Filtering',
bullets: [
'Trailing context modifiers: typing "cat and dog anthro" automatically distributes "anthro" to all "and" branches (cat anthro + dog anthro).',
'Optional grouping parenthesis support: e.g. "(cat and dog) anthro".',
'Resolved "No posts yet" bug by adapting client post validation to multi-tag stream groups and stripping operator keywords from booru queries.',
],
),
LunarisChange(
version: '2.0.7',
title: 'Independent Multi-Tag Search Streams, Local "and" Connector, Pawchive in Main Feed & Reverts',
bullets: [
'Independent multi-tag search: separate tags query providers individually and interleave results so each tag brings its own content without mutual exclusion.',
'Local "and" tag connector: typing "and" between tags groups them into a strict intersection query (e.g. "genshin and raiden").',
'Enabled Pawchive in the main feed alongside Rule34, Gelbooru, e621, and Realbooru.',
'Restored instantaneous client-side media type filtering in artist posts.',
'Removed external linked accounts bar from artist posts screen.',
],
),
LunarisChange(
version: '2.0.6',
title: 'Pawchive Deep Integration: Global Posts Feed, Artist Tags, Announcements, Links & Server Media Filter',
+144 -2
View File
@@ -163,6 +163,68 @@ class ProviderManager {
return Success(results);
}
static List<List<String>> splitTagGroups(List<String> rawTags) {
final segments = <List<String>>[];
List<String> current = [];
for (final raw in rawTags) {
var token = raw.trim();
token = token.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '').trim();
if (token.isEmpty) continue;
if (token.toLowerCase() == 'and') {
if (current.isNotEmpty) {
segments.add(current);
current = [];
}
} else {
current.add(token);
}
}
if (current.isNotEmpty) {
segments.add(current);
}
if (segments.isEmpty) {
return [const []];
}
if (segments.length == 1) {
return segments;
}
// Multiple segments separated by 'and'
// If the last segment has trailing tags after the branch tag (e.g. 'cat and dog anthro'):
// The trailing tags apply as common modifier tags to ALL branches!
final lastSegment = segments.last;
final prevSegment = segments[segments.length - 2];
final branchLen = prevSegment.length.clamp(1, lastSegment.length);
if (lastSegment.length > branchLen) {
final commonTail = lastSegment.sublist(branchLen);
final adjustedSegments = <List<String>>[];
for (int i = 0; i < segments.length; i++) {
final seg = i == segments.length - 1
? lastSegment
: [...segments[i], ...commonTail];
final seen = <String>{};
final deduped = <String>[];
for (final t in seg) {
if (seen.add(t.toLowerCase())) {
deduped.add(t);
}
}
adjustedSegments.add(deduped);
}
return adjustedSegments;
}
return segments;
}
Future<Result<List<Post>>> searchAcrossProviders({
required List<String> tags,
required int page,
@@ -181,12 +243,68 @@ class ProviderManager {
providers.where((provider) => provider.id == providerId).toList();
}
final tagGroups = splitTagGroups(tags);
if (tagGroups.length <= 1) {
final effectiveTags = tagGroups.isEmpty ? tags : tagGroups.first;
return _searchForTagGroup(
providers: providers,
tags: effectiveTags,
page: page,
limit: limit,
rating: rating,
providerId: providerId,
topPeriod: topPeriod,
);
}
// Multiple independent tag groups: query each tag group as if other tags are not present, then interleave
final seen = <String>{};
final groupPerLimit = (limit / tagGroups.length).ceil().clamp(10, 50);
final groupFutures = tagGroups.map((groupTags) {
return _searchForTagGroup(
providers: providers,
tags: groupTags,
page: page,
limit: groupPerLimit,
rating: rating,
providerId: providerId,
topPeriod: topPeriod,
);
}).toList(growable: false);
final results = await Future.wait(groupFutures);
final validGroupPosts = <List<Post>>[];
for (final res in results) {
if (res is Success<List<Post>>) {
validGroupPosts.add(res.data);
}
}
final interleaved = _interleaveTagGroupResults(validGroupPosts, seen);
return Success(interleaved);
}
Future<Result<List<Post>>> _searchForTagGroup({
required List<ContentProvider> providers,
required List<String> tags,
required int page,
required int limit,
required String? rating,
required String? providerId,
required TopPeriodFilter topPeriod,
}) async {
final cleanTags = tags
.map((t) => t.trim())
.where((t) => t.isNotEmpty && t.toLowerCase() != 'and')
.toList(growable: false);
final seen = <String>{};
final providerResults = <List<Post>>[];
for (final provider in providers) {
try {
final providerPosts = await provider.searchPosts(
tags: tags,
tags: cleanTags,
page: page,
limit: limit,
rating: rating,
@@ -234,6 +352,29 @@ class ProviderManager {
return Success(posts);
}
List<Post> _interleaveTagGroupResults(
List<List<Post>> groupResults, Set<String> seen) {
final combined = <Post>[];
var hasMore = true;
var index = 0;
while (hasMore) {
hasMore = false;
for (final group in groupResults) {
if (index < group.length) {
hasMore = true;
final post = group[index];
if (seen.add(post.cacheKey)) {
combined.add(post);
}
}
}
index++;
}
return combined;
}
List<Post> _flattenProviderResults(
List<List<Post>> results, Set<String> seen) {
final posts = <Post>[];
@@ -584,7 +725,8 @@ class ProviderManager {
}
static bool _isFeedConfig(ContentProviderConfig config) {
return !_isArtistConfig(config);
final type = config.apiType.toLowerCase();
return type != 'kemono' && type != 'coomer';
}
static bool _isLegacyRemovedConfig(ContentProviderConfig config) {
+15 -6
View File
@@ -112,13 +112,21 @@ class FeedService {
}
bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
final requested = requestedTags
.map((tag) => tag.trim().toLowerCase())
.where((tag) => tag.isNotEmpty && !tag.startsWith('-'))
.toList(growable: false);
if (requested.isEmpty) return true;
if (post.providerId == 'pawchive') return true;
final groups = ProviderManager.splitTagGroups(requestedTags);
if (groups.isEmpty) return true;
final postTags = _postTagSet(post);
return requested.every(
if (postTags.isEmpty) return true;
return groups.any((group) {
final cleanGroup = group
.map((tag) => tag.trim().toLowerCase())
.where((tag) => tag.isNotEmpty && !tag.startsWith('-') && tag != 'and')
.toList(growable: false);
if (cleanGroup.isEmpty) return true;
return cleanGroup.every(
(requestedTag) => postTags.any(
(postTag) =>
postTag == requestedTag ||
@@ -126,6 +134,7 @@ bool postMatchesRequestedTags(Post post, List<String> requestedTags) {
postTag.startsWith('$requestedTag-'),
),
);
});
}
bool postPassesTagFilters(Post post, AppSettings settings) {
@@ -49,7 +49,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
final Set<String> _selectedTypes = <String>{};
List<ArtistTag> _tags = [];
List<ArtistLink> _links = [];
List<ArtistAnnouncement> _announcements = [];
String? _selectedTag;
@@ -87,15 +86,14 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
(item) => (item as ContentProvider).id == widget.providerId,
);
final tagsRes = await provider.getArtistTags(widget.service, widget.artistId);
final linksRes = await provider.getArtistLinks(widget.service, widget.artistId);
final tagsRes =
await provider.getArtistTags(widget.service, widget.artistId);
final annRes =
await provider.getArtistAnnouncements(widget.service, widget.artistId);
if (!mounted) return;
setState(() {
_tags = tagsRes;
_links = linksRes;
_announcements = annRes;
});
} catch (_) {}
@@ -118,18 +116,11 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
_error = null;
});
String? queryText;
if (_selectedTypes.length == 1) {
if (_selectedTypes.contains('video')) queryText = 'mp4';
if (_selectedTypes.contains('gif')) queryText = 'gif';
}
final query = ArtistWorkQuery(
providerId: widget.providerId,
service: widget.service,
artistId: widget.artistId,
artistName: widget.artistName,
queryText: queryText,
tagFilter: _selectedTag,
);
@@ -191,21 +182,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
_loadMore();
}
void _onMediaTypeToggled(String type, bool selected) {
setState(() {
if (selected) {
_selectedTypes.add(type);
} else {
_selectedTypes.remove(type);
}
_posts.clear();
_page = 0;
_hasMore = true;
_error = null;
});
_loadMore();
}
bool _matchesTypeFilter(Post post) {
if (_selectedTypes.isEmpty) return true;
final isVid = MediaUrlSelector.isVideo(post);
@@ -299,18 +275,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
FilterChip(
selected: _selectedTypes.isEmpty,
label: Text('Все (${_posts.length})'),
onSelected: (_) {
if (_selectedTypes.isNotEmpty) {
setState(() {
_selectedTypes.clear();
_posts.clear();
_page = 0;
_hasMore = true;
_error = null;
});
_loadMore();
}
},
onSelected: (_) => setState(() => _selectedTypes.clear()),
),
const SizedBox(width: 8),
FilterChip(
@@ -321,7 +286,15 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant),
label: Text('Фото ($photoCount)'),
onSelected: (selected) => _onMediaTypeToggled('photo', selected),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedTypes.add('photo');
} else {
_selectedTypes.remove('photo');
}
});
},
),
const SizedBox(width: 8),
FilterChip(
@@ -332,7 +305,15 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant),
label: Text('Видео ($videoCount)'),
onSelected: (selected) => _onMediaTypeToggled('video', selected),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedTypes.add('video');
} else {
_selectedTypes.remove('video');
}
});
},
),
const SizedBox(width: 8),
FilterChip(
@@ -343,7 +324,15 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant),
label: Text('GIF ($gifCount)'),
onSelected: (selected) => _onMediaTypeToggled('gif', selected),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedTypes.add('gif');
} else {
_selectedTypes.remove('gif');
}
});
},
),
],
),
@@ -382,58 +371,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
);
}
Widget _buildLinksBar() {
if (_links.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final link in _links) ...[
ActionChip(
avatar: Icon(
_serviceIcon(link.service),
size: 16,
color: theme.colorScheme.primary,
),
label: Text('${_serviceLabel(link.service)}: ${link.name}'),
onPressed: () {
context.push(
'/artists/${widget.providerId}/${link.service}/${link.id}?name=${Uri.encodeComponent(link.name)}',
);
},
),
const SizedBox(width: 8),
],
],
),
),
);
}
IconData _serviceIcon(String service) {
return switch (service.toLowerCase()) {
'patreon' => Icons.loyalty_rounded,
'fanbox' => Icons.favorite_border_rounded,
'discord' => Icons.chat_bubble_outline_rounded,
'boosty' => Icons.bolt_rounded,
_ => Icons.link_rounded,
};
}
String _serviceLabel(String service) {
return switch (service.toLowerCase()) {
'patreon' => 'Patreon',
'fanbox' => 'Pixiv Fanbox',
'discord' => 'Discord',
'boosty' => 'Boosty',
'fantia' => 'Fantia',
_ => service,
};
}
@override
Widget build(BuildContext context) {
final settings =
@@ -472,7 +409,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
: _posts.isEmpty
? Column(
children: [
_buildLinksBar(),
_buildTagsBar(),
const Expanded(
child: EmptyView(
@@ -486,7 +422,6 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
children: [
_buildMediaTypeFilters(),
_buildTagsBar(),
_buildLinksBar(),
Expanded(
child: displayedPosts.isEmpty
? Center(
@@ -510,11 +445,7 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
setState(() {
_selectedTypes.clear();
_selectedTag = null;
_posts.clear();
_page = 0;
_hasMore = true;
});
_loadMore();
},
child: const Text('Сбросить фильтры'),
),
+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.0.6+28
version: 2.0.8+30
environment:
sdk: ">=3.3.0 <4.0.0"
+2 -1
View File
@@ -43,11 +43,12 @@ void main() {
expect(postPassesTagFilters(post, settings), isFalse);
});
test('requested search tags are all required', () {
test('requested search tags are all required unless separated by and', () {
final post = _post(tags: ['touhou', 'hakurei_reimu']);
expect(postMatchesRequestedTags(post, ['touhou']), isTrue);
expect(postMatchesRequestedTags(post, ['touhou', 'cirno']), isFalse);
expect(postMatchesRequestedTags(post, ['touhou', 'and', 'cirno']), isTrue);
});
test('requested search tags can match tag prefixes', () {
+86
View File
@@ -0,0 +1,86 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/providers/provider_manager.dart';
void main() {
group('Tag groups splitting: space groups together, and separates streams', () {
test('single tag returns one group', () {
final groups = ProviderManager.splitTagGroups(['cat']);
expect(groups, equals([['cat']]));
});
test('tags separated by space are grouped together in one query', () {
final groups = ProviderManager.splitTagGroups(['raiden', 'miku']);
expect(groups, equals([
['raiden', 'miku'],
]));
});
test('tags separated by and are split into independent query streams', () {
final groups = ProviderManager.splitTagGroups(['raiden', 'and', 'miku']);
expect(groups, equals([
['raiden'],
['miku'],
]));
});
test('cat and dog anthro applies trailing anthro tag to both branches', () {
final groups = ProviderManager.splitTagGroups(['cat', 'and', 'dog', 'anthro']);
expect(groups, equals([
['cat', 'anthro'],
['dog', 'anthro'],
]));
});
test('multiple trailing modifiers apply to all and-branches', () {
final groups = ProviderManager.splitTagGroups([
'cat',
'and',
'dog',
'and',
'fox',
'anthro',
'solo',
]);
expect(groups, equals([
['cat', 'anthro', 'solo'],
['dog', 'anthro', 'solo'],
['fox', 'anthro', 'solo'],
]));
});
test('supports optional parentheses around and branches', () {
final groups = ProviderManager.splitTagGroups(['(cat', 'and', 'dog)', 'anthro']);
expect(groups, equals([
['cat', 'anthro'],
['dog', 'anthro'],
]));
});
test('multiple multi-tag groups separated by and', () {
final groups = ProviderManager.splitTagGroups([
'genshin',
'raiden',
'and',
'vocaloid',
'miku',
]);
expect(groups, equals([
['genshin', 'raiden'],
['vocaloid', 'miku'],
]));
});
test('case-insensitive AND separator', () {
final groups = ProviderManager.splitTagGroups(['cat', 'AND', 'dog']);
expect(groups, equals([
['cat'],
['dog'],
]));
});
test('empty tags return empty group list', () {
final groups = ProviderManager.splitTagGroups([]);
expect(groups, equals([const <String>[]]));
});
});
}