reverse search bangs
This commit is contained in:
@@ -128,6 +128,17 @@ class BangDao extends DatabaseAccessor<BangDatabase> with $BangDaoMixin {
|
||||
);
|
||||
}
|
||||
|
||||
Selectable<BangData> getBangDataByTemplateHost(String host) {
|
||||
final httpsPrefix = 'https://$host/%';
|
||||
final httpPrefix = 'http://$host/%';
|
||||
|
||||
return select(db.bangDataView)
|
||||
..where(
|
||||
(t) =>
|
||||
t.urlTemplate.like(httpsPrefix) | t.urlTemplate.like(httpPrefix),
|
||||
);
|
||||
}
|
||||
|
||||
Selectable<BangData> queryBangs(String searchString) {
|
||||
final ftsQuery = db.buildFtsQuery(searchString);
|
||||
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:weblibre/features/bangs/data/providers.dart';
|
||||
import 'package:weblibre/utils/lru_cache.dart';
|
||||
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
||||
|
||||
part 'reverse_match.g.dart';
|
||||
|
||||
const String _placeholder = '{{{s}}}';
|
||||
|
||||
/// A URL-safe sentinel inserted in place of the bang's `{{{s}}}` placeholder
|
||||
/// during template parsing. Picked so it survives URI parsing without
|
||||
/// re-encoding and is extremely unlikely to appear in real templates.
|
||||
const String _sentinel = 'wlbangrevxq7p2zsentinel';
|
||||
|
||||
/// Where the placeholder sits inside a template.
|
||||
enum _Slot { mainQuery, mainPath, fragmentQuery, fragmentPath }
|
||||
|
||||
/// Path + query pieces of a URL (or fragment) reduced to the parts we care
|
||||
/// about for reverse matching.
|
||||
class _Components {
|
||||
final List<String> pathSegments;
|
||||
final Map<String, String> queryParams;
|
||||
|
||||
const _Components({required this.pathSegments, required this.queryParams});
|
||||
}
|
||||
|
||||
/// Parsed form of a bang URL template suitable for reverse matching.
|
||||
///
|
||||
/// The placeholder can sit in one of four slots: main URL query/path or
|
||||
/// fragment query/path. Everything else in the template becomes a required
|
||||
/// constant during matching. Hash-router and #key=value fragments are both
|
||||
/// supported via [_componentsFromFragment].
|
||||
class BangUrlPattern {
|
||||
final String host;
|
||||
|
||||
// Main-URL constraints (always enforced).
|
||||
final List<String> mainPathSegments;
|
||||
final Map<String, String> mainQueryParams;
|
||||
|
||||
// Fragment constraints (only when the template uses a fragment).
|
||||
final List<String>? fragmentPathSegments;
|
||||
final Map<String, String>? fragmentQueryParams;
|
||||
|
||||
// Placeholder location + capture descriptors.
|
||||
final _Slot _slot;
|
||||
final String prefix;
|
||||
final String suffix;
|
||||
final String? paramName; // for *Query slots
|
||||
final int? pathIndex; // for *Path slots
|
||||
|
||||
const BangUrlPattern._({
|
||||
required this.host,
|
||||
required this.mainPathSegments,
|
||||
required this.mainQueryParams,
|
||||
required this.fragmentPathSegments,
|
||||
required this.fragmentQueryParams,
|
||||
required _Slot slot,
|
||||
required this.prefix,
|
||||
required this.suffix,
|
||||
this.paramName,
|
||||
this.pathIndex,
|
||||
}) : _slot = slot;
|
||||
|
||||
/// Specificity score used to break ties between bangs sharing a host:
|
||||
/// more constants matched = more specific template.
|
||||
int get constraintCount =>
|
||||
mainPathSegments.length +
|
||||
mainQueryParams.length +
|
||||
(fragmentPathSegments?.length ?? 0) +
|
||||
(fragmentQueryParams?.length ?? 0);
|
||||
|
||||
/// Extracts the user query from [input] if it matches this pattern. Returns
|
||||
/// null otherwise.
|
||||
String? match(Uri input) {
|
||||
if (input.host.toLowerCase() != host) return null;
|
||||
|
||||
final mainInput = _componentsFromUri(input);
|
||||
if (mainInput == null) return null;
|
||||
if (!_pathSegmentsMatchAround(
|
||||
mainPathSegments,
|
||||
mainInput.pathSegments,
|
||||
_slot == _Slot.mainPath ? pathIndex : null,
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
if (!_requiredQueryParamsMatch(mainQueryParams, mainInput.queryParams)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
_Components? fragmentInput;
|
||||
if (fragmentPathSegments != null || fragmentQueryParams != null) {
|
||||
fragmentInput = _componentsFromFragment(input.fragment);
|
||||
if (fragmentInput == null) return null;
|
||||
|
||||
if (!_pathSegmentsMatchAround(
|
||||
fragmentPathSegments ?? const [],
|
||||
fragmentInput.pathSegments,
|
||||
_slot == _Slot.fragmentPath ? pathIndex : null,
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_requiredQueryParamsMatch(
|
||||
fragmentQueryParams ?? const {},
|
||||
fragmentInput.queryParams,
|
||||
)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
switch (_slot) {
|
||||
case _Slot.mainQuery:
|
||||
return _extractFromQuery(mainInput.queryParams);
|
||||
case _Slot.mainPath:
|
||||
return _extractFromPath(mainInput.pathSegments);
|
||||
case _Slot.fragmentQuery:
|
||||
return _extractFromQuery(fragmentInput!.queryParams);
|
||||
case _Slot.fragmentPath:
|
||||
return _extractFromPath(fragmentInput!.pathSegments);
|
||||
}
|
||||
}
|
||||
|
||||
String? _extractFromQuery(Map<String, String> params) {
|
||||
final value = params[paramName];
|
||||
if (value == null) return null;
|
||||
if (!value.startsWith(prefix)) return null;
|
||||
if (!value.endsWith(suffix)) return null;
|
||||
final captured =
|
||||
value.substring(prefix.length, value.length - suffix.length);
|
||||
return captured.isEmpty ? null : captured;
|
||||
}
|
||||
|
||||
String? _extractFromPath(List<String> segments) {
|
||||
if (pathIndex == null || pathIndex! >= segments.length) return null;
|
||||
final segment = segments[pathIndex!];
|
||||
if (!segment.startsWith(prefix)) return null;
|
||||
if (!segment.endsWith(suffix)) return null;
|
||||
final captured =
|
||||
segment.substring(prefix.length, segment.length - suffix.length);
|
||||
return captured.isEmpty ? null : captured;
|
||||
}
|
||||
|
||||
static BangUrlPattern? parse(String urlTemplate) {
|
||||
final placeholderCount = _placeholder.allMatches(urlTemplate).length;
|
||||
if (placeholderCount != 1) {
|
||||
// Multi-placeholder templates are out of scope.
|
||||
return null;
|
||||
}
|
||||
|
||||
final substituted = urlTemplate.replaceAll(_placeholder, _sentinel);
|
||||
final Uri parsed;
|
||||
try {
|
||||
parsed = Uri.parse(substituted);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed.hasScheme || parsed.host.isEmpty) return null;
|
||||
if (parsed.host.contains(_sentinel)) return null;
|
||||
|
||||
final host = parsed.host.toLowerCase();
|
||||
|
||||
final fragmentSlot = parsed.fragment.contains(_sentinel);
|
||||
final templateMain = _componentsFromUri(parsed);
|
||||
if (templateMain == null) return null;
|
||||
|
||||
if (fragmentSlot) {
|
||||
final fragmentComponents = _componentsFromFragment(parsed.fragment);
|
||||
if (fragmentComponents == null) return null;
|
||||
|
||||
return _withPlaceholderInScope(
|
||||
host: host,
|
||||
mainPathSegments: templateMain.pathSegments,
|
||||
mainQueryParams: templateMain.queryParams,
|
||||
scopeComponents: fragmentComponents,
|
||||
queryslot: _Slot.fragmentQuery,
|
||||
pathSlot: _Slot.fragmentPath,
|
||||
isFragment: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Placeholder lives in the main URL.
|
||||
return _withPlaceholderInScope(
|
||||
host: host,
|
||||
mainPathSegments: templateMain.pathSegments,
|
||||
mainQueryParams: templateMain.queryParams,
|
||||
scopeComponents: templateMain,
|
||||
queryslot: _Slot.mainQuery,
|
||||
pathSlot: _Slot.mainPath,
|
||||
isFragment: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Locates the sentinel inside [scopeComponents] (which may be the main URL
|
||||
/// components or the fragment's components) and assembles a [BangUrlPattern]
|
||||
/// with all required constants extracted.
|
||||
static BangUrlPattern? _withPlaceholderInScope({
|
||||
required String host,
|
||||
required List<String> mainPathSegments,
|
||||
required Map<String, String> mainQueryParams,
|
||||
required _Components scopeComponents,
|
||||
required _Slot queryslot,
|
||||
required _Slot pathSlot,
|
||||
required bool isFragment,
|
||||
}) {
|
||||
// Try query slot first.
|
||||
String? sentinelParam;
|
||||
for (final entry in scopeComponents.queryParams.entries) {
|
||||
if (entry.value.contains(_sentinel)) {
|
||||
if (sentinelParam != null) return null; // ambiguous
|
||||
sentinelParam = entry.key;
|
||||
}
|
||||
}
|
||||
|
||||
if (sentinelParam != null) {
|
||||
final placeholderValue = scopeComponents.queryParams[sentinelParam]!;
|
||||
final sentinelStart = placeholderValue.indexOf(_sentinel);
|
||||
final prefix = placeholderValue.substring(0, sentinelStart);
|
||||
final suffix =
|
||||
placeholderValue.substring(sentinelStart + _sentinel.length);
|
||||
|
||||
// The other params in the same scope become required constants.
|
||||
final required = <String, String>{
|
||||
for (final e in scopeComponents.queryParams.entries)
|
||||
if (e.key != sentinelParam) e.key: e.value,
|
||||
};
|
||||
|
||||
// We already required exactly one sentinel occurrence in [parse], so
|
||||
// if it lives in this scope's queries no path segment can also carry it.
|
||||
// The guard remains as defensive insurance.
|
||||
if (scopeComponents.pathSegments.any((s) => s.contains(_sentinel))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BangUrlPattern._(
|
||||
host: host,
|
||||
mainPathSegments: mainPathSegments,
|
||||
mainQueryParams: isFragment ? mainQueryParams : required,
|
||||
fragmentPathSegments: isFragment ? scopeComponents.pathSegments : null,
|
||||
fragmentQueryParams: isFragment ? required : null,
|
||||
slot: queryslot,
|
||||
prefix: prefix,
|
||||
suffix: suffix,
|
||||
paramName: sentinelParam,
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise the placeholder must be inside a single path segment.
|
||||
final segments = scopeComponents.pathSegments;
|
||||
final pathIndex = segments.indexWhere((s) => s.contains(_sentinel));
|
||||
if (pathIndex < 0) return null;
|
||||
|
||||
final segment = segments[pathIndex];
|
||||
final sentinelStart = segment.indexOf(_sentinel);
|
||||
final prefix = segment.substring(0, sentinelStart);
|
||||
final suffix = segment.substring(sentinelStart + _sentinel.length);
|
||||
|
||||
final pathSegments = List<String>.from(segments);
|
||||
|
||||
final required = scopeComponents.queryParams;
|
||||
if (required.values.any((v) => v.contains(_sentinel))) return null;
|
||||
|
||||
return BangUrlPattern._(
|
||||
host: host,
|
||||
mainPathSegments: isFragment ? mainPathSegments : pathSegments,
|
||||
mainQueryParams: isFragment ? mainQueryParams : required,
|
||||
fragmentPathSegments: isFragment ? pathSegments : null,
|
||||
fragmentQueryParams: isFragment ? required : null,
|
||||
slot: pathSlot,
|
||||
prefix: prefix,
|
||||
suffix: suffix,
|
||||
pathIndex: pathIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares path segments. When [placeholderIndex] is non-null, the segment
|
||||
/// at that index is the placeholder and is skipped here (the caller verifies
|
||||
/// it against the stored prefix/suffix).
|
||||
bool _pathSegmentsMatchAround(
|
||||
List<String> required,
|
||||
List<String> actual,
|
||||
int? placeholderIndex,
|
||||
) {
|
||||
if (actual.length != required.length) return false;
|
||||
for (var i = 0; i < required.length; i++) {
|
||||
if (i == placeholderIndex) continue;
|
||||
if (actual[i] != required[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _requiredQueryParamsMatch(
|
||||
Map<String, String> required,
|
||||
Map<String, String> actual,
|
||||
) {
|
||||
if (required.isEmpty) return true;
|
||||
for (final entry in required.entries) {
|
||||
if (actual[entry.key] != entry.value) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Reduces a [Uri] to the constraint set we care about. Bails on
|
||||
/// duplicate-keyed query params (multi-map semantics aren't worth the
|
||||
/// complexity for our use case).
|
||||
_Components? _componentsFromUri(Uri uri) {
|
||||
final segments =
|
||||
uri.pathSegments.where((s) => s.isNotEmpty).toList(growable: false);
|
||||
final all = uri.queryParametersAll;
|
||||
final params = <String, String>{};
|
||||
for (final entry in all.entries) {
|
||||
if (entry.value.length != 1) return null;
|
||||
params[entry.key] = entry.value.first;
|
||||
}
|
||||
return _Components(pathSegments: segments, queryParams: params);
|
||||
}
|
||||
|
||||
/// Parses a fragment string into pseudo-URI components.
|
||||
///
|
||||
/// Two common shapes are handled:
|
||||
/// - hash-router style: starts with `/` (e.g. `/s/search/foo`)
|
||||
/// - hash-query style: contains `=` without leading `/` (e.g. `s=foo&t=bar`)
|
||||
/// Plain fragments without `/` or `=` are treated as a single path segment.
|
||||
_Components? _componentsFromFragment(String fragment) {
|
||||
if (fragment.isEmpty) return null;
|
||||
final String synthetic;
|
||||
if (fragment.startsWith('/')) {
|
||||
synthetic = 'http://x$fragment';
|
||||
} else if (fragment.contains('=')) {
|
||||
synthetic = 'http://x/?$fragment';
|
||||
} else {
|
||||
synthetic = 'http://x/$fragment';
|
||||
}
|
||||
try {
|
||||
return _componentsFromUri(Uri.parse(synthetic));
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached parsed patterns. `null` entries are cached too, so unsupported
|
||||
/// templates aren't re-parsed.
|
||||
final LRUCache<String, BangUrlPattern?> _patternCache = LRUCache(256);
|
||||
|
||||
BangUrlPattern? _patternFor(String urlTemplate) {
|
||||
if (_patternCache.contains(urlTemplate)) {
|
||||
return _patternCache.get(urlTemplate);
|
||||
}
|
||||
final parsed = BangUrlPattern.parse(urlTemplate);
|
||||
_patternCache.set(urlTemplate, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// Reverse-match result: the bang whose template best matches [url] together
|
||||
/// with the extracted user query.
|
||||
class ReverseBangMatch {
|
||||
final BangData bang;
|
||||
final String query;
|
||||
|
||||
const ReverseBangMatch({required this.bang, required this.query});
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ReverseBangMatcher extends _$ReverseBangMatcher {
|
||||
@override
|
||||
void build() {}
|
||||
|
||||
/// Looks up a bang whose template produces [url] and extracts the query.
|
||||
/// Returns null when no candidate matches.
|
||||
Future<ReverseBangMatch?> match(Uri url) async {
|
||||
final host = url.host;
|
||||
if (host.isEmpty) return null;
|
||||
|
||||
final scheme = url.scheme.toLowerCase();
|
||||
if (scheme != 'http' && scheme != 'https') return null;
|
||||
|
||||
final candidates = await ref
|
||||
.read(bangDatabaseProvider)
|
||||
.bangDao
|
||||
.getBangDataByTemplateHost(host)
|
||||
.get();
|
||||
|
||||
if (candidates.isEmpty) return null;
|
||||
|
||||
ReverseBangMatch? best;
|
||||
int bestScore = -1;
|
||||
int bestFrequency = -1;
|
||||
|
||||
for (final bang in candidates) {
|
||||
final pattern = _patternFor(bang.urlTemplate);
|
||||
if (pattern == null) continue;
|
||||
|
||||
final captured = pattern.match(url);
|
||||
if (captured == null) continue;
|
||||
|
||||
// Don't auto-select when the captured text is itself a URL — usually
|
||||
// means we matched a redirector/shortener and the user wouldn't expect
|
||||
// the address bar to "edit" that.
|
||||
if (uri_parser.tryParseUrl(captured)?.hasScheme == true) continue;
|
||||
|
||||
final score = pattern.constraintCount;
|
||||
final frequency = bang.frequency;
|
||||
if (score > bestScore ||
|
||||
(score == bestScore && frequency > bestFrequency)) {
|
||||
best = ReverseBangMatch(bang: bang, query: captured);
|
||||
bestScore = score;
|
||||
bestFrequency = frequency;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'reverse_match.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(ReverseBangMatcher)
|
||||
final reverseBangMatcherProvider = ReverseBangMatcherProvider._();
|
||||
|
||||
final class ReverseBangMatcherProvider
|
||||
extends $NotifierProvider<ReverseBangMatcher, void> {
|
||||
ReverseBangMatcherProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'reverseBangMatcherProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$reverseBangMatcherHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ReverseBangMatcher create() => ReverseBangMatcher();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$reverseBangMatcherHash() =>
|
||||
r'52ce24b94a780612baa085ab07b7b3d88306b934';
|
||||
|
||||
abstract class _$ReverseBangMatcher extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import 'package:weblibre/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/web_search_bang.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/search.dart';
|
||||
import 'package:weblibre/features/bangs/domain/services/reverse_match.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||
@@ -188,6 +189,12 @@ class SearchScreen extends HookConsumerWidget {
|
||||
final isUrlInput = useState(false);
|
||||
final isEditingAfterSearch = useState(false);
|
||||
|
||||
// Holds the original URL when reverse bang-matching has swapped the
|
||||
// address-bar text for an extracted query. First tap of the clear button
|
||||
// restores this URL; a subsequent tap clears the field normally.
|
||||
final revertUrl = useState<String?>(null);
|
||||
final reverseMatchedQuery = useState<String?>(null);
|
||||
|
||||
useOnListenableChangeSelector(
|
||||
searchTextController,
|
||||
() => searchTextController.text,
|
||||
@@ -257,6 +264,49 @@ class SearchScreen extends HookConsumerWidget {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Try to recognise the current URL as a bang search. If a bang matches,
|
||||
// swap the URL for its extracted query and pre-select the bang so the
|
||||
// user can refine the search instead of editing the raw URL.
|
||||
useEffect(() {
|
||||
if (!startedWithUrl || initialSearchText == null) return null;
|
||||
final uri = Uri.tryParse(initialSearchText!);
|
||||
if (uri == null) return null;
|
||||
|
||||
unawaited(() async {
|
||||
final match = await ref
|
||||
.read(reverseBangMatcherProvider.notifier)
|
||||
.match(uri);
|
||||
if (!context.mounted) return;
|
||||
if (match == null) return;
|
||||
// Bail out if the user has already started editing while we matched.
|
||||
if (searchTextController.text != initialSearchText) return;
|
||||
|
||||
revertUrl.value = initialSearchText;
|
||||
reverseMatchedQuery.value = match.query;
|
||||
searchTextController.value = TextEditingValue(
|
||||
text: match.query,
|
||||
selection: TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: match.query.length,
|
||||
),
|
||||
);
|
||||
// Mutual exclusion: clear any site-scoped selection so the global
|
||||
// auto-match isn't hidden behind a stale site bang (mirrors the
|
||||
// SmartBangSelector selection logic).
|
||||
final tabHost = existingTabState?.url.host;
|
||||
if (tabHost != null && tabHost.isNotEmpty) {
|
||||
ref
|
||||
.read(selectedBangTriggerProvider(domain: tabHost).notifier)
|
||||
.clearTrigger();
|
||||
}
|
||||
ref
|
||||
.read(selectedBangTriggerProvider().notifier)
|
||||
.setTrigger(match.bang.toKey());
|
||||
}());
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
//Request initial focus in a way our useOnListenableChangeSelector is triggered
|
||||
useEffect(() {
|
||||
if (ref.read(searchAutofocusSuppressionProvider)) {
|
||||
@@ -671,6 +721,32 @@ class SearchScreen extends HookConsumerWidget {
|
||||
maxLines: isEditMode ? 3 : 1,
|
||||
label: const Text('Search or enter URL'),
|
||||
unfocusOnTapOutside: false,
|
||||
onClearPressed: () {
|
||||
final url = revertUrl.value;
|
||||
if (url != null &&
|
||||
searchTextController.text ==
|
||||
reverseMatchedQuery.value) {
|
||||
// First press after a reverse-match swap: restore the
|
||||
// original URL and drop the auto-selected bang. The
|
||||
// user can press again to actually clear.
|
||||
searchTextController.value = TextEditingValue(
|
||||
text: url,
|
||||
selection: TextSelection(
|
||||
baseOffset: 0,
|
||||
extentOffset: url.length,
|
||||
),
|
||||
);
|
||||
revertUrl.value = null;
|
||||
reverseMatchedQuery.value = null;
|
||||
ref
|
||||
.read(selectedBangTriggerProvider().notifier)
|
||||
.clearTrigger();
|
||||
} else {
|
||||
revertUrl.value = null;
|
||||
reverseMatchedQuery.value = null;
|
||||
searchTextController.clear();
|
||||
}
|
||||
},
|
||||
onSubmitted: (value) async {
|
||||
if (value.isEmpty) return;
|
||||
|
||||
|
||||
+11
-1
@@ -49,6 +49,11 @@ class SearchField extends HookConsumerWidget {
|
||||
final BangData? activeBang;
|
||||
final bool showBangIcon;
|
||||
|
||||
/// Overrides the clear (`x`) button behaviour. When null, the button just
|
||||
/// clears the text. When provided, the callback decides what to do (e.g.
|
||||
/// restore a previous value first, then clear on the next press).
|
||||
final VoidCallback? onClearPressed;
|
||||
|
||||
const SearchField({
|
||||
super.key,
|
||||
required this.textEditingController,
|
||||
@@ -64,6 +69,7 @@ class SearchField extends HookConsumerWidget {
|
||||
this.autofocus = false,
|
||||
this.textFieldKey,
|
||||
this.hint,
|
||||
this.onClearPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -160,7 +166,11 @@ class SearchField extends HookConsumerWidget {
|
||||
padding: const EdgeInsetsDirectional.only(end: 8.0),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
textEditingController.clear();
|
||||
if (onClearPressed != null) {
|
||||
onClearPressed!();
|
||||
} else {
|
||||
textEditingController.clear();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/bangs/domain/services/reverse_match.dart';
|
||||
|
||||
void main() {
|
||||
group('BangUrlPattern - main URL placeholder', () {
|
||||
test('query param: captures simple search query', () {
|
||||
final p = BangUrlPattern.parse('https://www.google.com/search?q={{{s}}}');
|
||||
expect(p, isNotNull);
|
||||
expect(
|
||||
p!.match(Uri.parse('https://www.google.com/search?q=hello+world')),
|
||||
'hello world',
|
||||
);
|
||||
});
|
||||
|
||||
test('query param: tolerates extra tracking params', () {
|
||||
final p = BangUrlPattern.parse('https://www.google.com/search?q={{{s}}}');
|
||||
expect(
|
||||
p!.match(
|
||||
Uri.parse(
|
||||
'https://www.google.com/search?q=cats&hl=de&utm_source=foo',
|
||||
),
|
||||
),
|
||||
'cats',
|
||||
);
|
||||
});
|
||||
|
||||
test('query param: enforces other required constants', () {
|
||||
final p = BangUrlPattern.parse(
|
||||
'https://www.google.com/search?q={{{s}}}&tbm=isch',
|
||||
);
|
||||
expect(
|
||||
p!.match(Uri.parse('https://www.google.com/search?q=cats&tbm=isch')),
|
||||
'cats',
|
||||
);
|
||||
expect(
|
||||
p.match(Uri.parse('https://www.google.com/search?q=cats')),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('path segment: captures search term', () {
|
||||
final p = BangUrlPattern.parse(
|
||||
'https://en.wikipedia.org/wiki/{{{s}}}',
|
||||
);
|
||||
expect(
|
||||
p!.match(Uri.parse('https://en.wikipedia.org/wiki/Flutter')),
|
||||
'Flutter',
|
||||
);
|
||||
});
|
||||
|
||||
test('path segment: enforces non-placeholder segments', () {
|
||||
final p = BangUrlPattern.parse(
|
||||
'https://www.ebay.com/sch/{{{s}}}/m.html',
|
||||
);
|
||||
expect(
|
||||
p!.match(Uri.parse('https://www.ebay.com/sch/toys/m.html')),
|
||||
'toys',
|
||||
);
|
||||
expect(
|
||||
p.match(Uri.parse('https://www.ebay.com/sch/toys/wrong.html')),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('path segment: supports prefix/suffix in same segment', () {
|
||||
final p = BangUrlPattern.parse(
|
||||
'https://site.com/find/prefix-{{{s}}}-end',
|
||||
);
|
||||
expect(
|
||||
p!.match(Uri.parse('https://site.com/find/prefix-Foo-end')),
|
||||
'Foo',
|
||||
);
|
||||
expect(
|
||||
p.match(Uri.parse('https://site.com/find/Foo-end')),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('host mismatch rejects', () {
|
||||
final p = BangUrlPattern.parse('https://google.com/search?q={{{s}}}');
|
||||
expect(
|
||||
p!.match(Uri.parse('https://bing.com/search?q=foo')),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('path length mismatch rejects', () {
|
||||
final p = BangUrlPattern.parse('https://x.com/a?q={{{s}}}');
|
||||
expect(
|
||||
p!.match(Uri.parse('https://x.com/a/b?q=foo')),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('empty captured query is rejected', () {
|
||||
final p = BangUrlPattern.parse('https://google.com/?q={{{s}}}');
|
||||
expect(p!.match(Uri.parse('https://google.com/?q=')), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('BangUrlPattern - fragment placeholder', () {
|
||||
test('hash query: 4chan-style #s={{{s}}}', () {
|
||||
final p = BangUrlPattern.parse(
|
||||
'https://boards.4chan.org/g/catalog#s={{{s}}}',
|
||||
);
|
||||
expect(p, isNotNull);
|
||||
expect(
|
||||
p!.match(Uri.parse('https://boards.4chan.org/g/catalog#s=keyword')),
|
||||
'keyword',
|
||||
);
|
||||
});
|
||||
|
||||
test('hash path: SPA router /#/foo/{{{s}}}', () {
|
||||
final p = BangUrlPattern.parse(
|
||||
'https://research.lensai.eu/#/s/search/{{{s}}}',
|
||||
);
|
||||
expect(p, isNotNull);
|
||||
expect(
|
||||
p!.match(
|
||||
Uri.parse('https://research.lensai.eu/#/s/search/quantum'),
|
||||
),
|
||||
'quantum',
|
||||
);
|
||||
});
|
||||
|
||||
test('hash path: rejects when fragment path constants do not match', () {
|
||||
final p = BangUrlPattern.parse(
|
||||
'https://research.lensai.eu/#/s/search/{{{s}}}',
|
||||
);
|
||||
expect(
|
||||
p!.match(
|
||||
Uri.parse('https://research.lensai.eu/#/s/answer/quantum'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('hash query: tolerates extra fragment params', () {
|
||||
final p = BangUrlPattern.parse(
|
||||
'https://site.example/#s={{{s}}}',
|
||||
);
|
||||
expect(
|
||||
p!.match(Uri.parse('https://site.example/#s=foo&page=2')),
|
||||
'foo',
|
||||
);
|
||||
});
|
||||
|
||||
test('fragment-required template rejects URLs without fragment', () {
|
||||
final p = BangUrlPattern.parse(
|
||||
'https://boards.4chan.org/g/catalog#s={{{s}}}',
|
||||
);
|
||||
expect(
|
||||
p!.match(Uri.parse('https://boards.4chan.org/g/catalog')),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('BangUrlPattern - unsupported templates', () {
|
||||
test('multi-placeholder returns null', () {
|
||||
expect(
|
||||
BangUrlPattern.parse('https://x.com/?a={{{s}}}&b={{{s}}}'),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('placeholder in host returns null', () {
|
||||
expect(BangUrlPattern.parse('https://{{{s}}}.example.com/'), isNull);
|
||||
});
|
||||
|
||||
test('scheme-less template returns null', () {
|
||||
expect(BangUrlPattern.parse('//example.com/?q={{{s}}}'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('BangUrlPattern - tie-break helpers', () {
|
||||
test('more required constants score higher', () {
|
||||
final plain = BangUrlPattern.parse('https://x.com/?q={{{s}}}')!;
|
||||
final imgs = BangUrlPattern.parse(
|
||||
'https://x.com/?q={{{s}}}&tbm=isch',
|
||||
)!;
|
||||
expect(imgs.constraintCount, greaterThan(plain.constraintCount));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user