feat: instant tag suggestions, persistent disk cache, history limit settings, match highlighting and category badges (v2.0.9)
This commit is contained in:
@@ -1,43 +1,99 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../core/utils/result.dart';
|
||||
import '../models/search_history.dart';
|
||||
import '../models/tag_suggestion.dart';
|
||||
import '../providers/provider_manager.dart';
|
||||
import '../repositories/search_repository.dart';
|
||||
import 'tag_cache_service.dart';
|
||||
|
||||
class SearchService {
|
||||
SearchService(this._repository, [this._providerManager]);
|
||||
SearchService(
|
||||
this._repository, [
|
||||
this._providerManager,
|
||||
this._tagCacheService,
|
||||
]);
|
||||
|
||||
final SearchRepository _repository;
|
||||
final ProviderManager? _providerManager;
|
||||
final TagCacheService? _tagCacheService;
|
||||
|
||||
TagCacheService? get tagCacheService => _tagCacheService;
|
||||
|
||||
static String sanitizeToken(String raw) {
|
||||
return raw
|
||||
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
List<String> parseTags(String query) {
|
||||
return query
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.map(sanitizeToken)
|
||||
.where((tag) => tag.isNotEmpty && tag != 'and')
|
||||
.toSet()
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<Result<void>> saveSearch(String query, int resultCount) {
|
||||
Future<Result<void>> saveSearch(
|
||||
String query,
|
||||
int resultCount, {
|
||||
int maxHistory = 500,
|
||||
}) async {
|
||||
final tags = parseTags(query);
|
||||
return _repository.save(
|
||||
SearchHistory(
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}',
|
||||
query: query.trim(),
|
||||
tags: tags,
|
||||
searchedAt: DateTime.now(),
|
||||
resultCount: resultCount,
|
||||
),
|
||||
final history = SearchHistory(
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}:${tags.join(',')}',
|
||||
query: query.trim(),
|
||||
tags: tags,
|
||||
searchedAt: DateTime.now(),
|
||||
resultCount: resultCount,
|
||||
);
|
||||
|
||||
// Also register user-searched tags in persistent tag cache
|
||||
if (_tagCacheService != null && tags.isNotEmpty) {
|
||||
unawaited(
|
||||
_tagCacheService.cacheTags(
|
||||
tags.map(
|
||||
(tag) => TagSuggestion(
|
||||
name: tag,
|
||||
category: TagCategory.general,
|
||||
postCount: resultCount,
|
||||
providerId: 'history',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _repository.save(history, maxItems: maxHistory);
|
||||
}
|
||||
|
||||
Future<Result<List<SearchHistory>>> recentSearches({int? limit}) {
|
||||
return _repository.recent(limit: limit);
|
||||
}
|
||||
|
||||
Future<Result<List<String>>> autocomplete(String prefix,
|
||||
{int limit = 10}) async {
|
||||
/// Instant local tag suggestions (0 ms) from disk cache & recent search history.
|
||||
List<TagSuggestion> instantSuggestions(
|
||||
String prefix, {
|
||||
int limit = 16,
|
||||
Iterable<String>? priorityTags,
|
||||
}) {
|
||||
final clean = sanitizeToken(prefix);
|
||||
if (clean.isEmpty || clean == 'and') return const [];
|
||||
return _tagCacheService?.findLocal(
|
||||
clean,
|
||||
limit: limit,
|
||||
priorityTags: priorityTags,
|
||||
) ??
|
||||
const [];
|
||||
}
|
||||
|
||||
Future<Result<List<String>>> autocomplete(
|
||||
String prefix, {
|
||||
int limit = 10,
|
||||
}) async {
|
||||
final result = await autocompleteDetailed(prefix, limit: limit);
|
||||
return result.fold(
|
||||
onSuccess: (items) => Success(items.map((item) => item.name).toList()),
|
||||
@@ -47,12 +103,65 @@ class SearchService {
|
||||
|
||||
Future<Result<List<TagSuggestion>>> autocompleteDetailed(
|
||||
String prefix, {
|
||||
int limit = 10,
|
||||
int limit = 16,
|
||||
int tagCacheLimit = 5000,
|
||||
Iterable<String>? priorityTags,
|
||||
}) async {
|
||||
final clean = sanitizeToken(prefix);
|
||||
if (clean.isEmpty || clean == 'and') return const Success([]);
|
||||
|
||||
final manager = _providerManager;
|
||||
if (manager == null || prefix.trim().isEmpty) return const Success([]);
|
||||
return manager.suggestTags(prefix.trim(), limit: limit);
|
||||
if (manager == null) {
|
||||
final local = instantSuggestions(clean, limit: limit, priorityTags: priorityTags);
|
||||
return Success(local);
|
||||
}
|
||||
|
||||
final netResult = await manager.suggestTags(clean, limit: limit);
|
||||
if (netResult is Error<List<TagSuggestion>>) {
|
||||
// Fallback to local cached suggestions if network fails
|
||||
final local = instantSuggestions(clean, limit: limit, priorityTags: priorityTags);
|
||||
return Success(local);
|
||||
}
|
||||
|
||||
final remoteItems = (netResult as Success<List<TagSuggestion>>).data;
|
||||
|
||||
// Cache remote suggestions to disk
|
||||
if (_tagCacheService != null && remoteItems.isNotEmpty) {
|
||||
unawaited(_tagCacheService.cacheTags(remoteItems, maxLimit: tagCacheLimit));
|
||||
}
|
||||
|
||||
// Merge local matches with remote results
|
||||
final merged = <String, TagSuggestion>{};
|
||||
for (final item in instantSuggestions(clean, limit: limit, priorityTags: priorityTags)) {
|
||||
merged[item.name.toLowerCase()] = item;
|
||||
}
|
||||
for (final item in remoteItems) {
|
||||
final key = item.name.toLowerCase();
|
||||
final existing = merged[key];
|
||||
if (existing == null || item.postCount > existing.postCount) {
|
||||
merged[key] = item;
|
||||
}
|
||||
}
|
||||
|
||||
final sorted = merged.values.toList()
|
||||
..sort((a, b) {
|
||||
final aKey = a.name.toLowerCase();
|
||||
final bKey = b.name.toLowerCase();
|
||||
final aExact = aKey == clean;
|
||||
final bExact = bKey == clean;
|
||||
if (aExact != bExact) return aExact ? -1 : 1;
|
||||
|
||||
final countCmp = b.postCount.compareTo(a.postCount);
|
||||
if (countCmp != 0) return countCmp;
|
||||
return aKey.compareTo(bKey);
|
||||
});
|
||||
|
||||
return Success(sorted.take(limit).toList(growable: false));
|
||||
}
|
||||
|
||||
Future<Result<void>> clearHistory() => _repository.clear();
|
||||
|
||||
Future<void> clearTagCache() async {
|
||||
await _tagCacheService?.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ class AppSettings {
|
||||
this.gridMode = 'masonry',
|
||||
this.searchPresets = const [],
|
||||
this.downloadPathTemplate = '{Artist}/{ID}',
|
||||
this.searchHistoryLimit = 500,
|
||||
this.tagCacheLimit = 5000,
|
||||
});
|
||||
|
||||
final List<String> favoriteArtists;
|
||||
@@ -106,6 +108,8 @@ class AppSettings {
|
||||
final String gridMode;
|
||||
final List<String> searchPresets;
|
||||
final String downloadPathTemplate;
|
||||
final int searchHistoryLimit;
|
||||
final int tagCacheLimit;
|
||||
|
||||
static const defaults = AppSettings(
|
||||
enabledProviderIds: ['gelbooru', 'rule34', 'realbooru'],
|
||||
@@ -149,6 +153,8 @@ class AppSettings {
|
||||
lastUpdateCheckAt: null,
|
||||
defaultRatingFilter: null,
|
||||
lastFeedRating: null,
|
||||
searchHistoryLimit: 500,
|
||||
tagCacheLimit: 5000,
|
||||
);
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -199,6 +205,8 @@ class AppSettings {
|
||||
String? gridMode,
|
||||
List<String>? searchPresets,
|
||||
String? downloadPathTemplate,
|
||||
int? searchHistoryLimit,
|
||||
int? tagCacheLimit,
|
||||
}) {
|
||||
return AppSettings(
|
||||
favoriteArtists: favoriteArtists ?? this.favoriteArtists,
|
||||
@@ -254,6 +262,8 @@ class AppSettings {
|
||||
gridMode: gridMode ?? this.gridMode,
|
||||
searchPresets: searchPresets ?? this.searchPresets,
|
||||
downloadPathTemplate: downloadPathTemplate ?? this.downloadPathTemplate,
|
||||
searchHistoryLimit: searchHistoryLimit ?? this.searchHistoryLimit,
|
||||
tagCacheLimit: tagCacheLimit ?? this.tagCacheLimit,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,6 +314,8 @@ class AppSettings {
|
||||
'gridMode': gridMode,
|
||||
'searchPresets': searchPresets,
|
||||
'downloadPathTemplate': downloadPathTemplate,
|
||||
'searchHistoryLimit': searchHistoryLimit,
|
||||
'tagCacheLimit': tagCacheLimit,
|
||||
};
|
||||
|
||||
factory AppSettings.fromJson(Map<String, dynamic> json) => AppSettings(
|
||||
@@ -411,6 +423,10 @@ class AppSettings {
|
||||
),
|
||||
downloadPathTemplate:
|
||||
(json['downloadPathTemplate'] as String?) ?? '{Artist}/{ID}',
|
||||
searchHistoryLimit: (json['searchHistoryLimit'] as num?)?.toInt() ??
|
||||
defaults.searchHistoryLimit,
|
||||
tagCacheLimit: (json['tagCacheLimit'] as num?)?.toInt() ??
|
||||
defaults.tagCacheLimit,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../models/tag_suggestion.dart';
|
||||
|
||||
class TagCacheService {
|
||||
TagCacheService({String? customPath}) : _customPath = customPath;
|
||||
|
||||
final String? _customPath;
|
||||
final Map<String, TagSuggestion> _tags = {};
|
||||
bool _isInitialized = false;
|
||||
Timer? _saveDebounce;
|
||||
String? _resolvedPath;
|
||||
|
||||
bool get isInitialized => _isInitialized;
|
||||
int get tagCount => _tags.length;
|
||||
|
||||
Future<String> _getCacheFilePath() async {
|
||||
if (_resolvedPath != null) return _resolvedPath!;
|
||||
if (_customPath != null) {
|
||||
_resolvedPath = _customPath;
|
||||
return _resolvedPath!;
|
||||
}
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_resolvedPath = '${dir.path}/lunaris_tag_cache.json';
|
||||
return _resolvedPath!;
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
if (_isInitialized) return;
|
||||
try {
|
||||
final path = await _getCacheFilePath();
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
final content = await file.readAsString();
|
||||
if (content.trim().isNotEmpty) {
|
||||
final decoded = jsonDecode(content);
|
||||
if (decoded is List) {
|
||||
for (final item in decoded) {
|
||||
if (item is Map) {
|
||||
final suggestion = TagSuggestion.fromJson(
|
||||
Map<String, dynamic>.from(item),
|
||||
);
|
||||
final key = suggestion.name.trim().toLowerCase();
|
||||
if (key.isNotEmpty) {
|
||||
_tags[key] = suggestion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Non-critical: corrupted cache file can be rebuilt seamlessly
|
||||
} finally {
|
||||
_isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds suggestions locally in memory (0 ms response).
|
||||
List<TagSuggestion> findLocal(
|
||||
String prefix, {
|
||||
int limit = 16,
|
||||
Iterable<String>? priorityTags,
|
||||
}) {
|
||||
final cleanPrefix = prefix
|
||||
.replaceAll(RegExp(r'^[\(\)]+|[\(\)]+$'), '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (cleanPrefix.isEmpty) return const [];
|
||||
|
||||
final prioritySet = priorityTags
|
||||
?.map((t) => t.trim().toLowerCase())
|
||||
.where((t) => t.isNotEmpty)
|
||||
.toSet() ??
|
||||
const <String>{};
|
||||
|
||||
final matches = <TagSuggestion>[];
|
||||
final seen = <String>{};
|
||||
|
||||
// First check priority tags (e.g. from recent search history)
|
||||
for (final tag in prioritySet) {
|
||||
if (tag.startsWith(cleanPrefix) && !seen.contains(tag)) {
|
||||
seen.add(tag);
|
||||
final existing = _tags[tag];
|
||||
matches.add(
|
||||
existing ??
|
||||
TagSuggestion(
|
||||
name: tag,
|
||||
category: TagCategory.general,
|
||||
postCount: 0,
|
||||
providerId: 'history',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Then check all cached tags
|
||||
for (final entry in _tags.entries) {
|
||||
if (seen.contains(entry.key)) continue;
|
||||
if (entry.key.startsWith(cleanPrefix)) {
|
||||
seen.add(entry.key);
|
||||
matches.add(entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort((a, b) {
|
||||
final aKey = a.name.toLowerCase();
|
||||
final bKey = b.name.toLowerCase();
|
||||
final aExact = aKey == cleanPrefix;
|
||||
final bExact = bKey == cleanPrefix;
|
||||
if (aExact != bExact) return aExact ? -1 : 1;
|
||||
|
||||
final aPriority = prioritySet.contains(aKey);
|
||||
final bPriority = prioritySet.contains(bKey);
|
||||
if (aPriority != bPriority) return aPriority ? -1 : 1;
|
||||
|
||||
final countCmp = b.postCount.compareTo(a.postCount);
|
||||
if (countCmp != 0) return countCmp;
|
||||
|
||||
return aKey.compareTo(bKey);
|
||||
});
|
||||
|
||||
return matches.take(limit.clamp(1, 50)).toList(growable: false);
|
||||
}
|
||||
|
||||
/// Caches newly discovered suggestions and debounces saving to disk.
|
||||
Future<void> cacheTags(
|
||||
Iterable<TagSuggestion> suggestions, {
|
||||
int maxLimit = 5000,
|
||||
}) async {
|
||||
if (!_isInitialized) await init();
|
||||
var changed = false;
|
||||
|
||||
for (final item in suggestions) {
|
||||
final key = item.name.trim().toLowerCase();
|
||||
if (key.isEmpty) continue;
|
||||
final existing = _tags[key];
|
||||
if (existing == null || item.postCount > existing.postCount) {
|
||||
_tags[key] = item;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
// Prune least popular tags if exceeding max limit
|
||||
if (_tags.length > maxLimit) {
|
||||
final sorted = _tags.values.toList()
|
||||
..sort((a, b) => b.postCount.compareTo(a.postCount));
|
||||
_tags.clear();
|
||||
for (final item in sorted.take(maxLimit)) {
|
||||
_tags[item.name.toLowerCase()] = item;
|
||||
}
|
||||
}
|
||||
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
void _scheduleSave() {
|
||||
_saveDebounce?.cancel();
|
||||
_saveDebounce = Timer(const Duration(milliseconds: 1200), () {
|
||||
flush();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> flush() async {
|
||||
_saveDebounce?.cancel();
|
||||
try {
|
||||
final path = await _getCacheFilePath();
|
||||
final file = File(path);
|
||||
final list = _tags.values.map((t) => t.toJson()).toList(growable: false);
|
||||
final jsonString = jsonEncode(list);
|
||||
await file.writeAsString(jsonString, flush: true);
|
||||
} catch (_) {
|
||||
// Silent error on persistence failure
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> getFileSizeBytes() async {
|
||||
try {
|
||||
final path = await _getCacheFilePath();
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
return await file.length();
|
||||
}
|
||||
} catch (_) {}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
_saveDebounce?.cancel();
|
||||
_tags.clear();
|
||||
try {
|
||||
final path = await _getCacheFilePath();
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user