intermediate

This commit is contained in:
Fabian Freund
2025-01-23 14:03:55 +01:00
parent bd45ed64ee
commit 78c17a70c5
122 changed files with 8730 additions and 1662 deletions
@@ -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(
+9 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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,
),
],
);
},
),
),
],