intermediate
This commit is contained in:
@@ -91,7 +91,17 @@ class BangDao extends DatabaseAccessor<BangDatabase> with _$BangDaoMixin {
|
||||
}
|
||||
|
||||
Selectable<BangData> queryBangs(String searchString) {
|
||||
return db.bangQuery(query: db.buildFtsQuery(searchString));
|
||||
final ftsQuery = db.buildFtsQuery(searchString);
|
||||
|
||||
if (ftsQuery.isNotEmpty) {
|
||||
return db.queryBangs(
|
||||
query: ftsQuery,
|
||||
);
|
||||
} else {
|
||||
return db.queryBangsBasic(
|
||||
query: db.buildLikeQuery(searchString),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> addSearchEntry(String trigger, String searchQuery) {
|
||||
|
||||
@@ -72,20 +72,56 @@ END;
|
||||
optimizeFtsIndex:
|
||||
INSERT INTO bang_fts(bang_fts) VALUES ('optimize');
|
||||
|
||||
bangQuery WITH BangData:
|
||||
SELECT
|
||||
queryBangs WITH BangData:
|
||||
WITH weights AS (
|
||||
SELECT
|
||||
-- Customize these weights (higher = more important)
|
||||
10.0 AS "trigger", -- Title matches are most important
|
||||
5.0 AS website_name -- URL matches are quite important
|
||||
)
|
||||
SELECT
|
||||
b.*,
|
||||
bf.frequency,
|
||||
bf.last_used
|
||||
FROM
|
||||
bf.last_used,
|
||||
bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank
|
||||
FROM
|
||||
bang_fts(:query) fts
|
||||
INNER JOIN
|
||||
bang b ON b.rowid = fts.rowid
|
||||
LEFT JOIN
|
||||
bang_frequency bf ON b."trigger" = bf."trigger"
|
||||
ORDER BY
|
||||
RANK,
|
||||
bf.frequency NULLS LAST;
|
||||
CROSS JOIN
|
||||
weights
|
||||
ORDER BY
|
||||
weighted_rank ASC,
|
||||
bf.frequency NULLS LAST;
|
||||
|
||||
queryBangsBasic WITH BangData:
|
||||
WITH weights AS (
|
||||
SELECT
|
||||
-- Customize these weights (higher = more important)
|
||||
10.0 AS "trigger", -- Title matches are most important
|
||||
5.0 AS website_name -- URL matches are quite important
|
||||
)
|
||||
SELECT
|
||||
b.*,
|
||||
bf.frequency,
|
||||
bf.last_used,
|
||||
bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank
|
||||
FROM
|
||||
bang_fts fts
|
||||
INNER JOIN
|
||||
bang b ON b.rowid = fts.rowid
|
||||
LEFT JOIN
|
||||
bang_frequency bf ON b."trigger" = bf."trigger"
|
||||
CROSS JOIN
|
||||
weights
|
||||
WHERE
|
||||
fts."trigger" LIKE :query OR
|
||||
fts.website_name LIKE :query
|
||||
ORDER BY
|
||||
weighted_rank ASC,
|
||||
bf.frequency NULLS LAST;
|
||||
|
||||
categoriesJson:
|
||||
WITH categories AS (
|
||||
|
||||
@@ -1095,16 +1095,40 @@ abstract class _$BangDatabase extends GeneratedDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
Selectable<BangData> bangQuery({required String query}) {
|
||||
Selectable<BangData> queryBangs({required String query}) {
|
||||
return customSelect(
|
||||
'SELECT b.*, bf.frequency, bf.last_used FROM bang_fts(?1)AS fts INNER JOIN bang AS b ON b."rowid" = fts."rowid" LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" ORDER BY RANK, bf.frequency NULLS LAST',
|
||||
'WITH weights AS (SELECT 10.0 AS "trigger", 5.0 AS website_name) SELECT b.*, bf.frequency, bf.last_used, bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank FROM bang_fts(?1)AS fts INNER JOIN bang AS b ON b."rowid" = fts."rowid" LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" CROSS JOIN weights ORDER BY weighted_rank ASC, bf.frequency NULLS LAST',
|
||||
variables: [
|
||||
Variable<String>(query)
|
||||
],
|
||||
readsFrom: {
|
||||
bangFrequency,
|
||||
bang,
|
||||
bangFts,
|
||||
bang,
|
||||
}).map((QueryRow row) => BangData(
|
||||
websiteName: row.read<String>('website_name'),
|
||||
domain: row.read<String>('domain'),
|
||||
trigger: row.read<String>('trigger'),
|
||||
urlTemplate: row.read<String>('url_template'),
|
||||
category: row.readNullable<String>('category'),
|
||||
subCategory: row.readNullable<String>('sub_category'),
|
||||
format: BangTable.$converterformat
|
||||
.fromSql(row.readNullable<String>('format')),
|
||||
frequency: row.readNullable<int>('frequency'),
|
||||
lastUsed: row.readNullable<DateTime>('last_used'),
|
||||
));
|
||||
}
|
||||
|
||||
Selectable<BangData> queryBangsBasic({required String query}) {
|
||||
return customSelect(
|
||||
'WITH weights AS (SELECT 10.0 AS "trigger", 5.0 AS website_name) SELECT b.*, bf.frequency, bf.last_used, bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank FROM bang_fts AS fts INNER JOIN bang AS b ON b."rowid" = fts."rowid" LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" CROSS JOIN weights WHERE fts."trigger" LIKE ?1 OR fts.website_name LIKE ?1 ORDER BY weighted_rank ASC, bf.frequency NULLS LAST',
|
||||
variables: [
|
||||
Variable<String>(query)
|
||||
],
|
||||
readsFrom: {
|
||||
bangFrequency,
|
||||
bangFts,
|
||||
bang,
|
||||
}).map((QueryRow row) => BangData(
|
||||
websiteName: row.read<String>('website_name'),
|
||||
domain: row.read<String>('domain'),
|
||||
@@ -1236,8 +1260,8 @@ final class $BangTableReferences
|
||||
db.bang.trigger, db.bangFrequency.trigger));
|
||||
|
||||
$BangFrequencyProcessedTableManager get bangFrequencyRefs {
|
||||
final manager = $BangFrequencyTableManager($_db, $_db.bangFrequency)
|
||||
.filter((f) => f.trigger.trigger($_item.trigger));
|
||||
final manager = $BangFrequencyTableManager($_db, $_db.bangFrequency).filter(
|
||||
(f) => f.trigger.trigger.sqlEquals($_itemColumn<String>('trigger')!));
|
||||
|
||||
final cache = $_typedResult.readTableOrNull(_bangFrequencyRefsTable($_db));
|
||||
return ProcessedTableManager(
|
||||
@@ -1251,8 +1275,8 @@ final class $BangTableReferences
|
||||
$_aliasNameGenerator(db.bang.trigger, db.bangHistory.trigger));
|
||||
|
||||
$BangHistoryProcessedTableManager get bangHistoryRefs {
|
||||
final manager = $BangHistoryTableManager($_db, $_db.bangHistory)
|
||||
.filter((f) => f.trigger.trigger($_item.trigger));
|
||||
final manager = $BangHistoryTableManager($_db, $_db.bangHistory).filter(
|
||||
(f) => f.trigger.trigger.sqlEquals($_itemColumn<String>('trigger')!));
|
||||
|
||||
final cache = $_typedResult.readTableOrNull(_bangHistoryRefsTable($_db));
|
||||
return ProcessedTableManager(
|
||||
@@ -1704,8 +1728,10 @@ final class $BangFrequencyReferences
|
||||
$_aliasNameGenerator(db.bangFrequency.trigger, db.bang.trigger));
|
||||
|
||||
$BangTableProcessedTableManager get trigger {
|
||||
final $_column = $_itemColumn<String>('trigger')!;
|
||||
|
||||
final manager = $BangTableTableManager($_db, $_db.bang)
|
||||
.filter((f) => f.trigger($_item.trigger!));
|
||||
.filter((f) => f.trigger.sqlEquals($_column));
|
||||
final item = $_typedResult.readTableOrNull(_triggerTable($_db));
|
||||
if (item == null) return manager;
|
||||
return ProcessedTableManager(
|
||||
@@ -1941,8 +1967,10 @@ final class $BangHistoryReferences
|
||||
$_aliasNameGenerator(db.bangHistory.trigger, db.bang.trigger));
|
||||
|
||||
$BangTableProcessedTableManager get trigger {
|
||||
final $_column = $_itemColumn<String>('trigger')!;
|
||||
|
||||
final manager = $BangTableTableManager($_db, $_db.bang)
|
||||
.filter((f) => f.trigger($_item.trigger!));
|
||||
.filter((f) => f.trigger.sqlEquals($_column));
|
||||
final item = $_typedResult.readTableOrNull(_triggerTable($_db));
|
||||
if (item == null) return manager;
|
||||
return ProcessedTableManager(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:lensai/features/bangs/data/database/database.dart';
|
||||
@@ -13,7 +15,7 @@ part 'providers.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
BangDatabase bangDatabase(Ref ref) {
|
||||
return BangDatabase(
|
||||
final db = BangDatabase(
|
||||
LazyDatabase(() async {
|
||||
// put the database file, called db.sqlite here, into the documents folder
|
||||
// for your app.
|
||||
@@ -35,4 +37,10 @@ BangDatabase bangDatabase(Ref ref) {
|
||||
return NativeDatabase.createInBackground(file);
|
||||
}),
|
||||
);
|
||||
|
||||
ref.onDispose(() {
|
||||
unawaited(db.close());
|
||||
});
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'providers.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$bangDatabaseHash() => r'd7964e9cdbb6e60fb6d2a78f07bfd4fa34bcb236';
|
||||
String _$bangDatabaseHash() => r'1ff3fb8bfa29269f6e515f3b22c216998769f578';
|
||||
|
||||
/// See also [bangDatabase].
|
||||
@ProviderFor(bangDatabase)
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:lensai/features/bangs/domain/providers/bangs.dart';
|
||||
import 'package:lensai/features/bangs/presentation/widgets/bang_details.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/domain/entities/sheet.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:lensai/features/geckoview/features/controllers/bottom_sheet.dart';
|
||||
import 'package:lensai/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:lensai/features/kagi/data/entities/modes.dart';
|
||||
import 'package:lensai/presentation/widgets/failure_widget.dart';
|
||||
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
// The default Material-style Autocomplete options.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lensai/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/speech_to_text_button.dart';
|
||||
import 'package:lensai/features/search/domain/providers/search_suggestions.dart';
|
||||
import 'package:lensai/features/user/domain/repositories/settings.dart';
|
||||
import 'package:lensai/presentation/widgets/autocomplete.dart';
|
||||
|
||||
class SearchField extends HookConsumerWidget {
|
||||
static const defaultMaxOptionsHeight = 158.0;
|
||||
|
||||
final TextEditingController textController;
|
||||
final BangData? activeBang;
|
||||
final OptionsViewOpenDirection openDirection;
|
||||
final double maxOptionsHeight;
|
||||
final void Function(String)? onFieldSubmitted;
|
||||
|
||||
const SearchField({
|
||||
required this.textController,
|
||||
required this.activeBang,
|
||||
this.openDirection = OptionsViewOpenDirection.up,
|
||||
this.maxOptionsHeight = defaultMaxOptionsHeight,
|
||||
this.onFieldSubmitted,
|
||||
super.key,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final incognitoEnabled = ref.watch(
|
||||
settingsRepositoryProvider.select((value) => value.incognitoMode),
|
||||
);
|
||||
|
||||
final optionsStream = ref.watch(searchSuggestionsProvider());
|
||||
|
||||
final focusNode = useFocusNode();
|
||||
|
||||
// final quickAnswer = useListenableSelector(
|
||||
// textController,
|
||||
// () => textController.text.endsWith('?'),
|
||||
// );
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ExternalResultsAutocomplete<String>(
|
||||
textEditingController: textController,
|
||||
optionsStream: optionsStream,
|
||||
focusNode: focusNode,
|
||||
optionsViewOpenDirection: openDirection,
|
||||
displayStringForOption:
|
||||
// ignore: avoid_redundant_argument_values
|
||||
RawAutocomplete.defaultStringForOption,
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
return _AutocompleteOptions(
|
||||
//Must match RawAutocomplete parent
|
||||
displayStringForOption: RawAutocomplete.defaultStringForOption,
|
||||
onSelected: onSelected,
|
||||
options: options,
|
||||
//Must match RawAutocomplete parent
|
||||
openDirection: openDirection,
|
||||
maxOptionsHeight: maxOptionsHeight,
|
||||
);
|
||||
},
|
||||
onTextChanged: (textEditingValue) {
|
||||
ref
|
||||
.read(searchSuggestionsProvider().notifier)
|
||||
.addQuery(textEditingValue.text);
|
||||
},
|
||||
fieldViewBuilder: (
|
||||
context,
|
||||
textEditingController,
|
||||
focusNode,
|
||||
onFieldSubmitted,
|
||||
) {
|
||||
return TextFormField(
|
||||
controller: textEditingController,
|
||||
enableIMEPersonalizedLearning: !incognitoEnabled,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: (activeBang != null)
|
||||
? Padding(
|
||||
padding: const EdgeInsetsDirectional.all(12.0),
|
||||
child: BangIcon(activeBang!, iconSize: 24.0),
|
||||
)
|
||||
: null,
|
||||
// label: const Text('Query'),
|
||||
hintText: 'Ask anything...',
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
suffixIcon: SpeechToTextButton(
|
||||
onTextReceived: (data) {
|
||||
textEditingController.text = data.toString();
|
||||
},
|
||||
),
|
||||
),
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
validator: (value) {
|
||||
if (value?.isEmpty ?? true) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
onTapOutside: (event) {
|
||||
focusNode.unfocus();
|
||||
},
|
||||
onFieldSubmitted: this.onFieldSubmitted,
|
||||
);
|
||||
},
|
||||
),
|
||||
// if (activeBang?.domain.endsWith('kagi.com') == true)
|
||||
// SwitchListTile(
|
||||
// value: quickAnswer,
|
||||
// onChanged: (_) {
|
||||
// if (textController.text.endsWith('?')) {
|
||||
// textController.text = textController.text
|
||||
// .substring(0, textController.text.length - 1);
|
||||
// } else {
|
||||
// textController.text = '${textController.text}?';
|
||||
// }
|
||||
// },
|
||||
// contentPadding: EdgeInsets.zero,
|
||||
// title: const Text('Quick Answer'),
|
||||
// secondary: const Icon(MdiIcons.lightningBolt),
|
||||
// ),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AutocompleteOptions<T extends Object> extends StatelessWidget {
|
||||
const _AutocompleteOptions({
|
||||
super.key,
|
||||
required this.displayStringForOption,
|
||||
required this.onSelected,
|
||||
required this.openDirection,
|
||||
required this.options,
|
||||
required this.maxOptionsHeight,
|
||||
});
|
||||
|
||||
final AutocompleteOptionToString<T> displayStringForOption;
|
||||
|
||||
final AutocompleteOnSelected<T> onSelected;
|
||||
final OptionsViewOpenDirection openDirection;
|
||||
|
||||
final Iterable<T> options;
|
||||
final double maxOptionsHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AlignmentDirectional optionsAlignment = switch (openDirection) {
|
||||
OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart,
|
||||
OptionsViewOpenDirection.down => AlignmentDirectional.topStart,
|
||||
};
|
||||
return Align(
|
||||
alignment: optionsAlignment,
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxOptionsHeight),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
reverse: switch (openDirection) {
|
||||
OptionsViewOpenDirection.up => true,
|
||||
OptionsViewOpenDirection.down => false,
|
||||
},
|
||||
itemCount: options.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final T option = options.elementAt(index);
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
onSelected(option);
|
||||
},
|
||||
child: Builder(
|
||||
builder: (BuildContext context) {
|
||||
final bool highlight =
|
||||
AutocompleteHighlightedOption.of(context) == index;
|
||||
// if (highlight) {
|
||||
// SchedulerBinding.instance.addPostFrameCallback(
|
||||
// (Duration timeStamp) async {
|
||||
// await Scrollable.ensureVisible(
|
||||
// context,
|
||||
// alignment: 0.5,
|
||||
// );
|
||||
// },
|
||||
// debugLabel: 'AutocompleteOptions.ensureVisible',
|
||||
// );
|
||||
// }
|
||||
return Container(
|
||||
color: highlight ? Theme.of(context).focusColor : null,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(displayStringForOption(option)),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lensai/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:lensai/features/bangs/domain/providers/search.dart';
|
||||
import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart';
|
||||
import 'package:lensai/features/bangs/presentation/widgets/search_field.dart';
|
||||
import 'package:lensai/features/geckoview/domain/controllers/overlay_dialog.dart';
|
||||
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:lensai/features/geckoview/features/controllers/overlay_dialog.dart';
|
||||
import 'package:lensai/features/search/domain/providers/search_suggestions.dart';
|
||||
import 'package:lensai/features/search/presentation/widgets/search_field.dart';
|
||||
import 'package:lensai/features/search/presentation/widgets/search_suggestion_list.dart';
|
||||
import 'package:lensai/presentation/hooks/listenable_callback.dart';
|
||||
import 'package:lensai/presentation/widgets/selectable_chips.dart';
|
||||
|
||||
class SiteSearch extends HookConsumerWidget {
|
||||
@@ -25,6 +29,17 @@ class SiteSearch extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final formKey = useMemoized(() => GlobalKey<FormState>());
|
||||
|
||||
final searchTextController = useTextEditingController();
|
||||
|
||||
useListenableCallback(
|
||||
searchTextController,
|
||||
() async {
|
||||
ref
|
||||
.read(searchSuggestionsProvider().notifier)
|
||||
.addQuery(searchTextController.text);
|
||||
},
|
||||
);
|
||||
|
||||
final selectedBang = ref.watch(
|
||||
selectedBangDataProvider(domain: domain)
|
||||
.select((value) => value.valueOrNull),
|
||||
@@ -32,17 +47,17 @@ class SiteSearch extends HookConsumerWidget {
|
||||
|
||||
final activeBang = selectedBang ?? availableBangs.firstOrNull;
|
||||
|
||||
final textController = useTextEditingController();
|
||||
|
||||
Future<void> submitSearch() async {
|
||||
Future<void> submitSearch(String query) async {
|
||||
if (activeBang != null && (formKey.currentState?.validate() == true)) {
|
||||
final searchUri = await ref.read(
|
||||
triggerBangSearchProvider(activeBang, textController.text).future,
|
||||
triggerBangSearchProvider(activeBang, query).future,
|
||||
);
|
||||
|
||||
await ref.read(tabRepositoryProvider.notifier).addTab(url: searchUri);
|
||||
|
||||
ref.read(overlayDialogControllerProvider.notifier).dismiss();
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,21 +97,29 @@ class SiteSearch extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
SearchField(
|
||||
textController: textController,
|
||||
textEditingController: searchTextController,
|
||||
activeBang: activeBang,
|
||||
onFieldSubmitted: (_) async {
|
||||
await submitSearch();
|
||||
await submitSearch(searchTextController.text);
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: submitSearch,
|
||||
label: const Text('Search on Site'),
|
||||
icon: const Icon(MdiIcons.cloudSearch),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 150),
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return CustomScrollView(
|
||||
shrinkWrap: true,
|
||||
controller: controller,
|
||||
slivers: [
|
||||
SearchSuggestionList(
|
||||
searchTextController: searchTextController,
|
||||
submitSearch: submitSearch,
|
||||
showHistory: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user