From 388eba93b73708757c0b9960a85c045c963be74d Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 10 Jun 2024 03:08:23 +0200 Subject: [PATCH] first bang implementation --- app/build.yaml | 16 + app/devtools_options.yaml | 3 + app/lib/core/routing/routes.dart | 28 +- app/lib/core/routing/routes.g.dart | 46 + .../bangs/data/database/daos/bang.dart | 98 ++ .../bangs/data/database/daos/bang.g.dart | 6 + .../bangs/data/database/database.dart | 28 + .../bangs/data/database/database.drift | 88 ++ .../bangs/data/database/database.g.dart | 1309 +++++++++++++++++ .../drift/converters/bang_format.dart | 26 + app/lib/features/bangs/data/models/bang.dart | 145 ++ .../features/bangs/data/models/bang.g.dart | 167 +++ .../features/bangs/data/models/bang_data.dart | 37 + .../bangs/data/models/bang_data.g.dart | 157 ++ app/lib/features/bangs/data/providers.dart | 37 + app/lib/features/bangs/data/providers.g.dart | 24 + .../bangs/data/services/bang_source.dart | 43 + .../bangs/data/services/bang_source.g.dart | 25 + app/lib/features/bangs/domain/providers.dart | 40 + .../features/bangs/domain/providers.g.dart | 330 +++++ .../bangs/domain/repositories/bang.dart | 154 ++ .../bangs/domain/repositories/bang.g.dart | 25 + .../domain/repositories/bang_search.dart | 32 + .../domain/repositories/bang_search.g.dart | 25 + .../bangs/presentation/screens/bang.dart | 61 + .../bangs/presentation/screens/search.dart | 82 ++ .../presentation/widgets/bang_details.dart | 106 ++ .../bangs/presentation/widgets/bang_icon.dart | 43 + .../domain/repositories/chat_archive.dart | 4 +- .../entities/abstract/i_query_builder.dart | 6 + .../query/domain/entities/bareword.dart | 70 + app/lib/features/query/domain/tokenizer.dart | 241 +++ .../search_browser/domain/providers.dart | 25 + .../search_browser/domain/providers.g.dart | 32 + .../presentation/widgets/bang_chips.dart | 93 ++ .../presentation/widgets/tabs/search_tab.dart | 75 +- .../search_browser/utils/url_builder.dart | 6 +- .../presentation/widgets/web_view.dart | 10 +- app/lib/main.dart | 5 + app/lib/utils/image_helper.dart | 12 + .../flutter/generated_plugin_registrant.cc | 4 + app/linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + app/pubspec.lock | 66 +- app/pubspec.yaml | 6 + .../flutter/generated_plugin_registrant.cc | 3 + app/windows/flutter/generated_plugins.cmake | 1 + 47 files changed, 3809 insertions(+), 34 deletions(-) create mode 100644 app/build.yaml create mode 100644 app/devtools_options.yaml create mode 100644 app/lib/features/bangs/data/database/daos/bang.dart create mode 100644 app/lib/features/bangs/data/database/daos/bang.g.dart create mode 100644 app/lib/features/bangs/data/database/database.dart create mode 100644 app/lib/features/bangs/data/database/database.drift create mode 100644 app/lib/features/bangs/data/database/database.g.dart create mode 100644 app/lib/features/bangs/data/database/drift/converters/bang_format.dart create mode 100644 app/lib/features/bangs/data/models/bang.dart create mode 100644 app/lib/features/bangs/data/models/bang.g.dart create mode 100644 app/lib/features/bangs/data/models/bang_data.dart create mode 100644 app/lib/features/bangs/data/models/bang_data.g.dart create mode 100644 app/lib/features/bangs/data/providers.dart create mode 100644 app/lib/features/bangs/data/providers.g.dart create mode 100644 app/lib/features/bangs/data/services/bang_source.dart create mode 100644 app/lib/features/bangs/data/services/bang_source.g.dart create mode 100644 app/lib/features/bangs/domain/providers.dart create mode 100644 app/lib/features/bangs/domain/providers.g.dart create mode 100644 app/lib/features/bangs/domain/repositories/bang.dart create mode 100644 app/lib/features/bangs/domain/repositories/bang.g.dart create mode 100644 app/lib/features/bangs/domain/repositories/bang_search.dart create mode 100644 app/lib/features/bangs/domain/repositories/bang_search.g.dart create mode 100644 app/lib/features/bangs/presentation/screens/bang.dart create mode 100644 app/lib/features/bangs/presentation/screens/search.dart create mode 100644 app/lib/features/bangs/presentation/widgets/bang_details.dart create mode 100644 app/lib/features/bangs/presentation/widgets/bang_icon.dart create mode 100644 app/lib/features/query/domain/entities/abstract/i_query_builder.dart create mode 100644 app/lib/features/query/domain/entities/bareword.dart create mode 100644 app/lib/features/query/domain/tokenizer.dart create mode 100644 app/lib/features/search_browser/presentation/widgets/bang_chips.dart create mode 100644 app/lib/utils/image_helper.dart diff --git a/app/build.yaml b/app/build.yaml new file mode 100644 index 00000000..55bba0f3 --- /dev/null +++ b/app/build.yaml @@ -0,0 +1,16 @@ +targets: + $default: + builders: + drift_dev: + # These options change how drift generates code + options: + skip_verification_code: true + data_class_to_companions: false + named_parameters: true + sql: + dialect: sqlite + options: + version: "3.45" + modules: + - json1 + - fts5 diff --git a/app/devtools_options.yaml b/app/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/app/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index db436424..99ba0a69 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -1,5 +1,7 @@ import 'package:bang_navigator/core/routing/dialog_page.dart'; import 'package:bang_navigator/features/about/presentation/screens/about.dart'; +import 'package:bang_navigator/features/bangs/presentation/screens/bang.dart'; +import 'package:bang_navigator/features/bangs/presentation/screens/search.dart'; import 'package:bang_navigator/features/chat_archive/presentation/screens/detail.dart'; import 'package:bang_navigator/features/chat_archive/presentation/screens/list.dart'; import 'package:bang_navigator/features/search_browser/presentation/screens/browser.dart'; @@ -17,11 +19,19 @@ part 'routes.g.dart'; name: 'AboutRoute', path: 'about', ), + TypedGoRoute( + name: 'BangRoute', + path: 'bangs', + routes: [ + TypedGoRoute( + name: 'BangSearchRoute', + path: 'search', + ), + ], + ), ], ) class KagiRoute extends GoRouteData { - KagiRoute(); - @override Widget build(BuildContext context, GoRouterState state) { return const KagiScreen(); @@ -35,6 +45,20 @@ class AboutRoute extends GoRouteData { } } +class BangRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return const BangScreen(); + } +} + +class BangSearchRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return const BangSearchScreen(); + } +} + @TypedGoRoute( name: 'SettingsRoute', path: '/settings', diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index 6daf3c16..d883dae3 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -22,6 +22,18 @@ RouteBase get $kagiRoute => GoRouteData.$route( name: 'AboutRoute', factory: $AboutRouteExtension._fromState, ), + GoRouteData.$route( + path: 'bangs', + name: 'BangRoute', + factory: $BangRouteExtension._fromState, + routes: [ + GoRouteData.$route( + path: 'search', + name: 'BangSearchRoute', + factory: $BangSearchRouteExtension._fromState, + ), + ], + ), ], ); @@ -59,6 +71,40 @@ extension $AboutRouteExtension on AboutRoute { void replace(BuildContext context) => context.replace(location); } +extension $BangRouteExtension on BangRoute { + static BangRoute _fromState(GoRouterState state) => BangRoute(); + + String get location => GoRouteData.$location( + '/bangs', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +extension $BangSearchRouteExtension on BangSearchRoute { + static BangSearchRoute _fromState(GoRouterState state) => BangSearchRoute(); + + String get location => GoRouteData.$location( + '/bangs/search', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + RouteBase get $settingsRoute => GoRouteData.$route( path: '/settings', name: 'SettingsRoute', diff --git a/app/lib/features/bangs/data/database/daos/bang.dart b/app/lib/features/bangs/data/database/daos/bang.dart new file mode 100644 index 00000000..61bb5053 --- /dev/null +++ b/app/lib/features/bangs/data/database/daos/bang.dart @@ -0,0 +1,98 @@ +import 'package:bang_navigator/features/bangs/data/database/database.dart'; +import 'package:bang_navigator/features/bangs/data/models/bang.dart'; +import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; +import 'package:drift/drift.dart'; + +part 'bang.g.dart'; + +@DriftAccessor() +class BangDao extends DatabaseAccessor with _$BangDaoMixin { + BangDao(super.db); + + Selectable getBangs({Iterable? groups}) { + final selectable = select(db.bang); + if (groups != null) { + selectable.where((t) => t.group.isInValues(groups)); + } + + return selectable; + } + + SingleOrNullSelectable getBangData(String trigger) { + return select(db.bangDataView)..where((t) => t.trigger.equals(trigger)); + } + + Selectable getBangDataList({Iterable? groups}) { + final selectable = select(db.bangDataView); + if (groups != null) { + selectable.where((t) => t.group.isInValues(groups)); + } + + selectable.orderBy([(t) => OrderingTerm.asc(t.websiteName)]); + + return selectable; + } + + Selectable getFrequentBangDataList({Iterable? groups}) { + final selectable = select(db.bangDataView) + ..where((t) => t.frequency.isBiggerThanValue(0)); + if (groups != null) { + selectable.where((t) => t.group.isInValues(groups)); + } + + selectable.orderBy([ + (t) => OrderingTerm.desc(t.frequency), + (t) => OrderingTerm.desc(t.lastUsed), + ]); + + return selectable; + } + + Future increaseFrequency(String trigger) { + return db.bangFrequency.insertOne( + BangFrequencyCompanion.insert( + trigger: trigger, + frequency: 1, + lastUsed: DateTime.now().toUtc(), + ), + onConflict: DoUpdate( + (old) => BangFrequencyCompanion.custom( + frequency: old.frequency + const Constant(1), + lastUsed: Variable(DateTime.now().toUtc()), + ), + ), + ); + } + + Selectable queryBangs(String searchString) { + return db.bangQuery(query: db.buildQuery(searchString)); + } + + Future insertBangs(Iterable bangs) { + return db.bang.insertAll(bangs); + } + + Future upsertIcon(String trigger, Uint8List iconData) { + return db.bangIcon.insertOne( + BangIconCompanion.insert( + trigger: trigger, + iconData: iconData, + fetchDate: DateTime.now().toUtc(), + ), + mode: InsertMode.insertOrReplace, + ); + } + + Future replaceBangs(Iterable bangs) { + return batch( + (batch) { + batch.replaceAll(db.bang, bangs); + }, + ); + } + + Future deleteBangs(Iterable triggers) { + final statement = delete(db.bang)..where((t) => t.trigger.isIn(triggers)); + return statement.go(); + } +} diff --git a/app/lib/features/bangs/data/database/daos/bang.g.dart b/app/lib/features/bangs/data/database/daos/bang.g.dart new file mode 100644 index 00000000..b0d853c0 --- /dev/null +++ b/app/lib/features/bangs/data/database/daos/bang.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bang.dart'; + +// ignore_for_file: type=lint +mixin _$BangDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/bangs/data/database/database.dart b/app/lib/features/bangs/data/database/database.dart new file mode 100644 index 00000000..431fe668 --- /dev/null +++ b/app/lib/features/bangs/data/database/database.dart @@ -0,0 +1,28 @@ +import 'package:bang_navigator/features/bangs/data/database/daos/bang.dart'; +import 'package:bang_navigator/features/bangs/data/database/drift/converters/bang_format.dart'; +import 'package:bang_navigator/features/bangs/data/models/bang.dart'; +import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; +import 'package:bang_navigator/features/query/domain/tokenizer.dart'; +import 'package:drift/drift.dart'; + +part 'database.g.dart'; + +@DriftDatabase(include: {'database.drift'}, daos: [BangDao]) +class BangDatabase extends _$BangDatabase with PrefixQueryBuilderMixin { + @override + final int schemaVersion = 1; + + @override + final int ftsTokenLimit = 6; + @override + final int ftsMinTokenLength = 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + beforeOpen: (details) async { + await customStatement('PRAGMA foreign_keys = ON;'); + }, + ); + + BangDatabase(super.e); +} diff --git a/app/lib/features/bangs/data/database/database.drift b/app/lib/features/bangs/data/database/database.drift new file mode 100644 index 00000000..135ce30f --- /dev/null +++ b/app/lib/features/bangs/data/database/database.drift @@ -0,0 +1,88 @@ +import 'package:bang_navigator/features/bangs/data/database/drift/converters/bang_format.dart'; +import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; +import 'package:bang_navigator/features/bangs/data/models/bang.dart'; + +CREATE TABLE bang ( + "trigger" TEXT PRIMARY KEY NOT NULL, + "group" ENUM(BangGroup), + website_name TEXT NOT NULL, + domain TEXT NOT NULL, + url_template TEXT NOT NULL, + category TEXT, + sub_category TEXT, + format TEXT MAPPED BY `const BangFormatConverter()` +) WITH Bang; + +CREATE TABLE bang_frequency ( + "trigger" TEXT PRIMARY KEY NOT NULL REFERENCES bang ("trigger") ON DELETE CASCADE, + frequency INTEGER NOT NULL, + last_used DATETIME NOT NULL +); + +CREATE TABLE bang_icon ( + "trigger" TEXT PRIMARY KEY NOT NULL REFERENCES bang ("trigger") ON DELETE CASCADE, + icon_data BLOB NOT NULL, + fetch_date DATETIME NOT NULL +); + +CREATE VIRTUAL TABLE bang_fts + USING fts5( + trigger, + website_name, + content=bang, + prefix='2 3' + ); + +CREATE VIEW bang_data_view WITH BangData AS + SELECT + b.*, + bf.frequency, + bf.last_used, + bi.icon_data + FROM + bang b + LEFT JOIN + bang_frequency bf ON b."trigger" = bf."trigger" + LEFT JOIN + bang_icon bi ON b."trigger" = bi."trigger"; + +-- Triggers to keep the FTS index up to date. +CREATE TRIGGER bang_after_insert AFTER INSERT ON bang BEGIN + INSERT INTO + bang_fts(rowid, "trigger", website_name) + VALUES (new.rowid, new."trigger", new.website_name); +END; +CREATE TRIGGER bang_after_delete AFTER DELETE ON bang BEGIN + INSERT INTO + bang_fts(bang_fts, rowid, "trigger", website_name) + VALUES('delete', old.rowid, old."trigger", old.website_name); +END; +CREATE TRIGGER bang_after_update AFTER UPDATE ON bang BEGIN + INSERT INTO + bang_fts(bang_fts, rowid, "trigger", website_name) + VALUES('delete', old.rowid, old."trigger", old.website_name); + INSERT INTO + bang_fts(rowid, "trigger", website_name) + VALUES (new.rowid, new."trigger", new.website_name); +END; + +optimizeFtsIndex: + INSERT INTO bang_fts(bang_fts) VALUES ('optimize'); + +bangQuery WITH BangData: + SELECT + b.*, + bf.frequency, + bf.last_used, + bi.icon_data + FROM + bang_fts(:query) fts + INNER JOIN + bang b ON b.rowid = fts.rowid + LEFT JOIN + bang_frequency bf ON b."trigger" = bf."trigger" + LEFT JOIN + bang_icon bi ON b."trigger" = bi."trigger" + ORDER BY + RANK, + bf.frequency NULLS LAST; \ No newline at end of file diff --git a/app/lib/features/bangs/data/database/database.g.dart b/app/lib/features/bangs/data/database/database.g.dart new file mode 100644 index 00000000..b26e7811 --- /dev/null +++ b/app/lib/features/bangs/data/database/database.g.dart @@ -0,0 +1,1309 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class BangTable extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BangTable(this.attachedDatabase, [this._alias]); + late final GeneratedColumn trigger = GeneratedColumn( + 'trigger', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL'); + late final GeneratedColumnWithTypeConverter group = + GeneratedColumn('group', aliasedName, true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '') + .withConverter(BangTable.$convertergroupn); + late final GeneratedColumn websiteName = GeneratedColumn( + 'website_name', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn domain = GeneratedColumn( + 'domain', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn urlTemplate = GeneratedColumn( + 'url_template', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn category = GeneratedColumn( + 'category', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: ''); + late final GeneratedColumn subCategory = GeneratedColumn( + 'sub_category', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: ''); + late final GeneratedColumnWithTypeConverter?, String> format = + GeneratedColumn('format', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '') + .withConverter?>(BangTable.$converterformat); + @override + List get $columns => [ + trigger, + group, + websiteName, + domain, + urlTemplate, + category, + subCategory, + format + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bang'; + @override + Set get $primaryKey => {trigger}; + @override + Bang map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Bang( + websiteName: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}website_name'])!, + domain: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}domain'])!, + trigger: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}trigger'])!, + urlTemplate: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}url_template'])!, + group: BangTable.$convertergroupn.fromSql(attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}group'])), + category: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}category']), + subCategory: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}sub_category']), + format: BangTable.$converterformat.fromSql(attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}format'])), + ); + } + + @override + BangTable createAlias(String alias) { + return BangTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 $convertergroup = + const EnumIndexConverter(BangGroup.values); + static JsonTypeConverter2 $convertergroupn = + JsonTypeConverter2.asNullable($convertergroup); + static TypeConverter?, String?> $converterformat = + const BangFormatConverter(); + @override + bool get dontWriteConstraints => true; +} + +class BangCompanion extends UpdateCompanion { + final Value trigger; + final Value group; + final Value websiteName; + final Value domain; + final Value urlTemplate; + final Value category; + final Value subCategory; + final Value?> format; + final Value rowid; + const BangCompanion({ + this.trigger = const Value.absent(), + this.group = const Value.absent(), + this.websiteName = const Value.absent(), + this.domain = const Value.absent(), + this.urlTemplate = const Value.absent(), + this.category = const Value.absent(), + this.subCategory = const Value.absent(), + this.format = const Value.absent(), + this.rowid = const Value.absent(), + }); + BangCompanion.insert({ + required String trigger, + this.group = const Value.absent(), + required String websiteName, + required String domain, + required String urlTemplate, + this.category = const Value.absent(), + this.subCategory = const Value.absent(), + this.format = const Value.absent(), + this.rowid = const Value.absent(), + }) : trigger = Value(trigger), + websiteName = Value(websiteName), + domain = Value(domain), + urlTemplate = Value(urlTemplate); + static Insertable custom({ + Expression? trigger, + Expression? group, + Expression? websiteName, + Expression? domain, + Expression? urlTemplate, + Expression? category, + Expression? subCategory, + Expression? format, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (trigger != null) 'trigger': trigger, + if (group != null) 'group': group, + if (websiteName != null) 'website_name': websiteName, + if (domain != null) 'domain': domain, + if (urlTemplate != null) 'url_template': urlTemplate, + if (category != null) 'category': category, + if (subCategory != null) 'sub_category': subCategory, + if (format != null) 'format': format, + if (rowid != null) 'rowid': rowid, + }); + } + + BangCompanion copyWith( + {Value? trigger, + Value? group, + Value? websiteName, + Value? domain, + Value? urlTemplate, + Value? category, + Value? subCategory, + Value?>? format, + Value? rowid}) { + return BangCompanion( + trigger: trigger ?? this.trigger, + group: group ?? this.group, + websiteName: websiteName ?? this.websiteName, + domain: domain ?? this.domain, + urlTemplate: urlTemplate ?? this.urlTemplate, + category: category ?? this.category, + subCategory: subCategory ?? this.subCategory, + format: format ?? this.format, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (trigger.present) { + map['trigger'] = Variable(trigger.value); + } + if (group.present) { + map['group'] = + Variable(BangTable.$convertergroupn.toSql(group.value)); + } + if (websiteName.present) { + map['website_name'] = Variable(websiteName.value); + } + if (domain.present) { + map['domain'] = Variable(domain.value); + } + if (urlTemplate.present) { + map['url_template'] = Variable(urlTemplate.value); + } + if (category.present) { + map['category'] = Variable(category.value); + } + if (subCategory.present) { + map['sub_category'] = Variable(subCategory.value); + } + if (format.present) { + map['format'] = + Variable(BangTable.$converterformat.toSql(format.value)); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BangCompanion(') + ..write('trigger: $trigger, ') + ..write('group: $group, ') + ..write('websiteName: $websiteName, ') + ..write('domain: $domain, ') + ..write('urlTemplate: $urlTemplate, ') + ..write('category: $category, ') + ..write('subCategory: $subCategory, ') + ..write('format: $format, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BangFrequency extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BangFrequency(this.attachedDatabase, [this._alias]); + late final GeneratedColumn trigger = GeneratedColumn( + 'trigger', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'PRIMARY KEY NOT NULL REFERENCES bang("trigger")ON DELETE CASCADE'); + late final GeneratedColumn frequency = GeneratedColumn( + 'frequency', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn lastUsed = GeneratedColumn( + 'last_used', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + @override + List get $columns => [trigger, frequency, lastUsed]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bang_frequency'; + @override + Set get $primaryKey => {trigger}; + @override + BangFrequencyData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BangFrequencyData( + trigger: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}trigger'])!, + frequency: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}frequency'])!, + lastUsed: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}last_used'])!, + ); + } + + @override + BangFrequency createAlias(String alias) { + return BangFrequency(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class BangFrequencyData extends DataClass + implements Insertable { + final String trigger; + final int frequency; + final DateTime lastUsed; + const BangFrequencyData( + {required this.trigger, required this.frequency, required this.lastUsed}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['trigger'] = Variable(trigger); + map['frequency'] = Variable(frequency); + map['last_used'] = Variable(lastUsed); + return map; + } + + factory BangFrequencyData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BangFrequencyData( + trigger: serializer.fromJson(json['trigger']), + frequency: serializer.fromJson(json['frequency']), + lastUsed: serializer.fromJson(json['last_used']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'trigger': serializer.toJson(trigger), + 'frequency': serializer.toJson(frequency), + 'last_used': serializer.toJson(lastUsed), + }; + } + + BangFrequencyData copyWith( + {String? trigger, int? frequency, DateTime? lastUsed}) => + BangFrequencyData( + trigger: trigger ?? this.trigger, + frequency: frequency ?? this.frequency, + lastUsed: lastUsed ?? this.lastUsed, + ); + @override + String toString() { + return (StringBuffer('BangFrequencyData(') + ..write('trigger: $trigger, ') + ..write('frequency: $frequency, ') + ..write('lastUsed: $lastUsed') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(trigger, frequency, lastUsed); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BangFrequencyData && + other.trigger == this.trigger && + other.frequency == this.frequency && + other.lastUsed == this.lastUsed); +} + +class BangFrequencyCompanion extends UpdateCompanion { + final Value trigger; + final Value frequency; + final Value lastUsed; + final Value rowid; + const BangFrequencyCompanion({ + this.trigger = const Value.absent(), + this.frequency = const Value.absent(), + this.lastUsed = const Value.absent(), + this.rowid = const Value.absent(), + }); + BangFrequencyCompanion.insert({ + required String trigger, + required int frequency, + required DateTime lastUsed, + this.rowid = const Value.absent(), + }) : trigger = Value(trigger), + frequency = Value(frequency), + lastUsed = Value(lastUsed); + static Insertable custom({ + Expression? trigger, + Expression? frequency, + Expression? lastUsed, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (trigger != null) 'trigger': trigger, + if (frequency != null) 'frequency': frequency, + if (lastUsed != null) 'last_used': lastUsed, + if (rowid != null) 'rowid': rowid, + }); + } + + BangFrequencyCompanion copyWith( + {Value? trigger, + Value? frequency, + Value? lastUsed, + Value? rowid}) { + return BangFrequencyCompanion( + trigger: trigger ?? this.trigger, + frequency: frequency ?? this.frequency, + lastUsed: lastUsed ?? this.lastUsed, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (trigger.present) { + map['trigger'] = Variable(trigger.value); + } + if (frequency.present) { + map['frequency'] = Variable(frequency.value); + } + if (lastUsed.present) { + map['last_used'] = Variable(lastUsed.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BangFrequencyCompanion(') + ..write('trigger: $trigger, ') + ..write('frequency: $frequency, ') + ..write('lastUsed: $lastUsed, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BangIcon extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BangIcon(this.attachedDatabase, [this._alias]); + late final GeneratedColumn trigger = GeneratedColumn( + 'trigger', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'PRIMARY KEY NOT NULL REFERENCES bang("trigger")ON DELETE CASCADE'); + late final GeneratedColumn iconData = GeneratedColumn( + 'icon_data', aliasedName, false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumn fetchDate = GeneratedColumn( + 'fetch_date', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + @override + List get $columns => [trigger, iconData, fetchDate]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bang_icon'; + @override + Set get $primaryKey => {trigger}; + @override + BangIconData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BangIconData( + trigger: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}trigger'])!, + iconData: attachedDatabase.typeMapping + .read(DriftSqlType.blob, data['${effectivePrefix}icon_data'])!, + fetchDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}fetch_date'])!, + ); + } + + @override + BangIcon createAlias(String alias) { + return BangIcon(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class BangIconData extends DataClass implements Insertable { + final String trigger; + final Uint8List iconData; + final DateTime fetchDate; + const BangIconData( + {required this.trigger, required this.iconData, required this.fetchDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['trigger'] = Variable(trigger); + map['icon_data'] = Variable(iconData); + map['fetch_date'] = Variable(fetchDate); + return map; + } + + factory BangIconData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BangIconData( + trigger: serializer.fromJson(json['trigger']), + iconData: serializer.fromJson(json['icon_data']), + fetchDate: serializer.fromJson(json['fetch_date']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'trigger': serializer.toJson(trigger), + 'icon_data': serializer.toJson(iconData), + 'fetch_date': serializer.toJson(fetchDate), + }; + } + + BangIconData copyWith( + {String? trigger, Uint8List? iconData, DateTime? fetchDate}) => + BangIconData( + trigger: trigger ?? this.trigger, + iconData: iconData ?? this.iconData, + fetchDate: fetchDate ?? this.fetchDate, + ); + @override + String toString() { + return (StringBuffer('BangIconData(') + ..write('trigger: $trigger, ') + ..write('iconData: $iconData, ') + ..write('fetchDate: $fetchDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(trigger, $driftBlobEquality.hash(iconData), fetchDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BangIconData && + other.trigger == this.trigger && + $driftBlobEquality.equals(other.iconData, this.iconData) && + other.fetchDate == this.fetchDate); +} + +class BangIconCompanion extends UpdateCompanion { + final Value trigger; + final Value iconData; + final Value fetchDate; + final Value rowid; + const BangIconCompanion({ + this.trigger = const Value.absent(), + this.iconData = const Value.absent(), + this.fetchDate = const Value.absent(), + this.rowid = const Value.absent(), + }); + BangIconCompanion.insert({ + required String trigger, + required Uint8List iconData, + required DateTime fetchDate, + this.rowid = const Value.absent(), + }) : trigger = Value(trigger), + iconData = Value(iconData), + fetchDate = Value(fetchDate); + static Insertable custom({ + Expression? trigger, + Expression? iconData, + Expression? fetchDate, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (trigger != null) 'trigger': trigger, + if (iconData != null) 'icon_data': iconData, + if (fetchDate != null) 'fetch_date': fetchDate, + if (rowid != null) 'rowid': rowid, + }); + } + + BangIconCompanion copyWith( + {Value? trigger, + Value? iconData, + Value? fetchDate, + Value? rowid}) { + return BangIconCompanion( + trigger: trigger ?? this.trigger, + iconData: iconData ?? this.iconData, + fetchDate: fetchDate ?? this.fetchDate, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (trigger.present) { + map['trigger'] = Variable(trigger.value); + } + if (iconData.present) { + map['icon_data'] = Variable(iconData.value); + } + if (fetchDate.present) { + map['fetch_date'] = Variable(fetchDate.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BangIconCompanion(') + ..write('trigger: $trigger, ') + ..write('iconData: $iconData, ') + ..write('fetchDate: $fetchDate, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BangFts extends Table + with TableInfo, VirtualTableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BangFts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn trigger = GeneratedColumn( + 'trigger', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: ''); + late final GeneratedColumn websiteName = GeneratedColumn( + 'website_name', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: ''); + @override + List get $columns => [trigger, websiteName]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bang_fts'; + @override + Set get $primaryKey => const {}; + @override + BangFt map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BangFt( + trigger: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}trigger'])!, + websiteName: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}website_name'])!, + ); + } + + @override + BangFts createAlias(String alias) { + return BangFts(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; + @override + String get moduleAndArgs => + 'fts5(trigger, website_name, content=bang, prefix=\'2 3\')'; +} + +class BangFt extends DataClass implements Insertable { + final String trigger; + final String websiteName; + const BangFt({required this.trigger, required this.websiteName}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['trigger'] = Variable(trigger); + map['website_name'] = Variable(websiteName); + return map; + } + + factory BangFt.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BangFt( + trigger: serializer.fromJson(json['trigger']), + websiteName: serializer.fromJson(json['website_name']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'trigger': serializer.toJson(trigger), + 'website_name': serializer.toJson(websiteName), + }; + } + + BangFt copyWith({String? trigger, String? websiteName}) => BangFt( + trigger: trigger ?? this.trigger, + websiteName: websiteName ?? this.websiteName, + ); + @override + String toString() { + return (StringBuffer('BangFt(') + ..write('trigger: $trigger, ') + ..write('websiteName: $websiteName') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(trigger, websiteName); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BangFt && + other.trigger == this.trigger && + other.websiteName == this.websiteName); +} + +class BangFtsCompanion extends UpdateCompanion { + final Value trigger; + final Value websiteName; + final Value rowid; + const BangFtsCompanion({ + this.trigger = const Value.absent(), + this.websiteName = const Value.absent(), + this.rowid = const Value.absent(), + }); + BangFtsCompanion.insert({ + required String trigger, + required String websiteName, + this.rowid = const Value.absent(), + }) : trigger = Value(trigger), + websiteName = Value(websiteName); + static Insertable custom({ + Expression? trigger, + Expression? websiteName, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (trigger != null) 'trigger': trigger, + if (websiteName != null) 'website_name': websiteName, + if (rowid != null) 'rowid': rowid, + }); + } + + BangFtsCompanion copyWith( + {Value? trigger, Value? websiteName, Value? rowid}) { + return BangFtsCompanion( + trigger: trigger ?? this.trigger, + websiteName: websiteName ?? this.websiteName, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (trigger.present) { + map['trigger'] = Variable(trigger.value); + } + if (websiteName.present) { + map['website_name'] = Variable(websiteName.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BangFtsCompanion(') + ..write('trigger: $trigger, ') + ..write('websiteName: $websiteName, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BangDataView extends ViewInfo + implements HasResultSet { + final String? _alias; + @override + final _$BangDatabase attachedDatabase; + BangDataView(this.attachedDatabase, [this._alias]); + @override + List get $columns => [ + trigger, + group, + websiteName, + domain, + urlTemplate, + category, + subCategory, + format, + frequency, + lastUsed, + iconData + ]; + @override + String get aliasedName => _alias ?? entityName; + @override + String get entityName => 'bang_data_view'; + @override + Map get createViewStatements => { + SqlDialect.sqlite: + 'CREATE VIEW bang_data_view WITH BangData AS SELECT b.*, bf.frequency, bf.last_used, bi.icon_data FROM bang AS b LEFT JOIN bang_frequency AS bf ON b."trigger" = bf."trigger" LEFT JOIN bang_icon AS bi ON b."trigger" = bi."trigger"', + }; + @override + BangDataView get asDslTable => this; + @override + BangData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BangData( + websiteName: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}website_name'])!, + domain: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}domain'])!, + trigger: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}trigger'])!, + urlTemplate: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}url_template'])!, + category: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}category']), + subCategory: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}sub_category']), + format: BangTable.$converterformat.fromSql(attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}format'])), + frequency: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}frequency']), + lastUsed: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}last_used']), + iconData: attachedDatabase.typeMapping + .read(DriftSqlType.blob, data['${effectivePrefix}icon_data']), + ); + } + + late final GeneratedColumn trigger = GeneratedColumn( + 'trigger', aliasedName, false, + type: DriftSqlType.string); + late final GeneratedColumnWithTypeConverter group = + GeneratedColumn('group', aliasedName, true, type: DriftSqlType.int) + .withConverter(BangTable.$convertergroupn); + late final GeneratedColumn websiteName = GeneratedColumn( + 'website_name', aliasedName, false, + type: DriftSqlType.string); + late final GeneratedColumn domain = GeneratedColumn( + 'domain', aliasedName, false, + type: DriftSqlType.string); + late final GeneratedColumn urlTemplate = GeneratedColumn( + 'url_template', aliasedName, false, + type: DriftSqlType.string); + late final GeneratedColumn category = GeneratedColumn( + 'category', aliasedName, true, + type: DriftSqlType.string); + late final GeneratedColumn subCategory = GeneratedColumn( + 'sub_category', aliasedName, true, + type: DriftSqlType.string); + late final GeneratedColumnWithTypeConverter?, String> format = + GeneratedColumn('format', aliasedName, true, + type: DriftSqlType.string) + .withConverter?>(BangTable.$converterformat); + late final GeneratedColumn frequency = GeneratedColumn( + 'frequency', aliasedName, true, + type: DriftSqlType.int); + late final GeneratedColumn lastUsed = GeneratedColumn( + 'last_used', aliasedName, true, + type: DriftSqlType.dateTime); + late final GeneratedColumn iconData = GeneratedColumn( + 'icon_data', aliasedName, true, + type: DriftSqlType.blob); + @override + BangDataView createAlias(String alias) { + return BangDataView(attachedDatabase, alias); + } + + @override + Query? get query => null; + @override + Set get readTables => const {'bang', 'bang_frequency', 'bang_icon'}; +} + +abstract class _$BangDatabase extends GeneratedDatabase { + _$BangDatabase(QueryExecutor e) : super(e); + _$BangDatabaseManager get managers => _$BangDatabaseManager(this); + late final BangTable bang = BangTable(this); + late final BangFrequency bangFrequency = BangFrequency(this); + late final BangIcon bangIcon = BangIcon(this); + late final BangFts bangFts = BangFts(this); + late final BangDataView bangDataView = BangDataView(this); + late final Trigger bangAfterInsert = Trigger( + 'CREATE TRIGGER bang_after_insert AFTER INSERT ON bang BEGIN INSERT INTO bang_fts ("rowid", "trigger", website_name) VALUES (new."rowid", new."trigger", new.website_name);END', + 'bang_after_insert'); + late final Trigger bangAfterDelete = Trigger( + 'CREATE TRIGGER bang_after_delete AFTER DELETE ON bang BEGIN INSERT INTO bang_fts (bang_fts, "rowid", "trigger", website_name) VALUES (\'delete\', old."rowid", old."trigger", old.website_name);END', + 'bang_after_delete'); + late final Trigger bangAfterUpdate = Trigger( + 'CREATE TRIGGER bang_after_update AFTER UPDATE ON bang BEGIN INSERT INTO bang_fts (bang_fts, "rowid", "trigger", website_name) VALUES (\'delete\', old."rowid", old."trigger", old.website_name);INSERT INTO bang_fts ("rowid", "trigger", website_name) VALUES (new."rowid", new."trigger", new.website_name);END', + 'bang_after_update'); + late final BangDao bangDao = BangDao(this as BangDatabase); + Future optimizeFtsIndex() { + return customInsert( + 'INSERT INTO bang_fts (bang_fts) VALUES (\'optimize\')', + variables: [], + updates: {bangFts}, + ); + } + + Selectable bangQuery({required String query}) { + return customSelect( + 'SELECT b.*, bf.frequency, bf.last_used, bi.icon_data 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" LEFT JOIN bang_icon AS bi ON b."trigger" = bi."trigger" ORDER BY RANK, bf.frequency NULLS LAST', + variables: [ + Variable(query) + ], + readsFrom: { + bangFrequency, + bangIcon, + bang, + bangFts, + }).map((QueryRow row) => BangData( + websiteName: row.read('website_name'), + domain: row.read('domain'), + trigger: row.read('trigger'), + urlTemplate: row.read('url_template'), + category: row.readNullable('category'), + subCategory: row.readNullable('sub_category'), + format: BangTable.$converterformat + .fromSql(row.readNullable('format')), + frequency: row.readNullable('frequency'), + lastUsed: row.readNullable('last_used'), + iconData: row.readNullable('icon_data'), + )); + } + + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + bang, + bangFrequency, + bangIcon, + bangFts, + bangDataView, + bangAfterInsert, + bangAfterDelete, + bangAfterUpdate + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules( + [ + WritePropagation( + on: TableUpdateQuery.onTableName('bang', + limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('bang_frequency', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('bang', + limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('bang_icon', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('bang', + limitUpdateKind: UpdateKind.insert), + result: [ + TableUpdate('bang_fts', kind: UpdateKind.insert), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('bang', + limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('bang_fts', kind: UpdateKind.insert), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('bang', + limitUpdateKind: UpdateKind.update), + result: [ + TableUpdate('bang_fts', kind: UpdateKind.insert), + ], + ), + ], + ); +} + +typedef $BangFrequencyInsertCompanionBuilder = BangFrequencyCompanion Function({ + required String trigger, + required int frequency, + required DateTime lastUsed, + Value rowid, +}); +typedef $BangFrequencyUpdateCompanionBuilder = BangFrequencyCompanion Function({ + Value trigger, + Value frequency, + Value lastUsed, + Value rowid, +}); + +class $BangFrequencyTableManager extends RootTableManager< + _$BangDatabase, + BangFrequency, + BangFrequencyData, + $BangFrequencyFilterComposer, + $BangFrequencyOrderingComposer, + $BangFrequencyProcessedTableManager, + $BangFrequencyInsertCompanionBuilder, + $BangFrequencyUpdateCompanionBuilder> { + $BangFrequencyTableManager(_$BangDatabase db, BangFrequency table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $BangFrequencyFilterComposer(ComposerState(db, table)), + orderingComposer: + $BangFrequencyOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => $BangFrequencyProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value trigger = const Value.absent(), + Value frequency = const Value.absent(), + Value lastUsed = const Value.absent(), + Value rowid = const Value.absent(), + }) => + BangFrequencyCompanion( + trigger: trigger, + frequency: frequency, + lastUsed: lastUsed, + rowid: rowid, + ), + getInsertCompanionBuilder: ({ + required String trigger, + required int frequency, + required DateTime lastUsed, + Value rowid = const Value.absent(), + }) => + BangFrequencyCompanion.insert( + trigger: trigger, + frequency: frequency, + lastUsed: lastUsed, + rowid: rowid, + ), + )); +} + +class $BangFrequencyProcessedTableManager extends ProcessedTableManager< + _$BangDatabase, + BangFrequency, + BangFrequencyData, + $BangFrequencyFilterComposer, + $BangFrequencyOrderingComposer, + $BangFrequencyProcessedTableManager, + $BangFrequencyInsertCompanionBuilder, + $BangFrequencyUpdateCompanionBuilder> { + $BangFrequencyProcessedTableManager(super.$state); +} + +class $BangFrequencyFilterComposer + extends FilterComposer<_$BangDatabase, BangFrequency> { + $BangFrequencyFilterComposer(super.$state); + ColumnFilters get trigger => $state.composableBuilder( + column: $state.table.trigger, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get frequency => $state.composableBuilder( + column: $state.table.frequency, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get lastUsed => $state.composableBuilder( + column: $state.table.lastUsed, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $BangFrequencyOrderingComposer + extends OrderingComposer<_$BangDatabase, BangFrequency> { + $BangFrequencyOrderingComposer(super.$state); + ColumnOrderings get trigger => $state.composableBuilder( + column: $state.table.trigger, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get frequency => $state.composableBuilder( + column: $state.table.frequency, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get lastUsed => $state.composableBuilder( + column: $state.table.lastUsed, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $BangIconInsertCompanionBuilder = BangIconCompanion Function({ + required String trigger, + required Uint8List iconData, + required DateTime fetchDate, + Value rowid, +}); +typedef $BangIconUpdateCompanionBuilder = BangIconCompanion Function({ + Value trigger, + Value iconData, + Value fetchDate, + Value rowid, +}); + +class $BangIconTableManager extends RootTableManager< + _$BangDatabase, + BangIcon, + BangIconData, + $BangIconFilterComposer, + $BangIconOrderingComposer, + $BangIconProcessedTableManager, + $BangIconInsertCompanionBuilder, + $BangIconUpdateCompanionBuilder> { + $BangIconTableManager(_$BangDatabase db, BangIcon table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: $BangIconFilterComposer(ComposerState(db, table)), + orderingComposer: $BangIconOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => $BangIconProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value trigger = const Value.absent(), + Value iconData = const Value.absent(), + Value fetchDate = const Value.absent(), + Value rowid = const Value.absent(), + }) => + BangIconCompanion( + trigger: trigger, + iconData: iconData, + fetchDate: fetchDate, + rowid: rowid, + ), + getInsertCompanionBuilder: ({ + required String trigger, + required Uint8List iconData, + required DateTime fetchDate, + Value rowid = const Value.absent(), + }) => + BangIconCompanion.insert( + trigger: trigger, + iconData: iconData, + fetchDate: fetchDate, + rowid: rowid, + ), + )); +} + +class $BangIconProcessedTableManager extends ProcessedTableManager< + _$BangDatabase, + BangIcon, + BangIconData, + $BangIconFilterComposer, + $BangIconOrderingComposer, + $BangIconProcessedTableManager, + $BangIconInsertCompanionBuilder, + $BangIconUpdateCompanionBuilder> { + $BangIconProcessedTableManager(super.$state); +} + +class $BangIconFilterComposer extends FilterComposer<_$BangDatabase, BangIcon> { + $BangIconFilterComposer(super.$state); + ColumnFilters get trigger => $state.composableBuilder( + column: $state.table.trigger, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get iconData => $state.composableBuilder( + column: $state.table.iconData, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get fetchDate => $state.composableBuilder( + column: $state.table.fetchDate, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $BangIconOrderingComposer + extends OrderingComposer<_$BangDatabase, BangIcon> { + $BangIconOrderingComposer(super.$state); + ColumnOrderings get trigger => $state.composableBuilder( + column: $state.table.trigger, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get iconData => $state.composableBuilder( + column: $state.table.iconData, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get fetchDate => $state.composableBuilder( + column: $state.table.fetchDate, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $BangFtsInsertCompanionBuilder = BangFtsCompanion Function({ + required String trigger, + required String websiteName, + Value rowid, +}); +typedef $BangFtsUpdateCompanionBuilder = BangFtsCompanion Function({ + Value trigger, + Value websiteName, + Value rowid, +}); + +class $BangFtsTableManager extends RootTableManager< + _$BangDatabase, + BangFts, + BangFt, + $BangFtsFilterComposer, + $BangFtsOrderingComposer, + $BangFtsProcessedTableManager, + $BangFtsInsertCompanionBuilder, + $BangFtsUpdateCompanionBuilder> { + $BangFtsTableManager(_$BangDatabase db, BangFts table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: $BangFtsFilterComposer(ComposerState(db, table)), + orderingComposer: $BangFtsOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => $BangFtsProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value trigger = const Value.absent(), + Value websiteName = const Value.absent(), + Value rowid = const Value.absent(), + }) => + BangFtsCompanion( + trigger: trigger, + websiteName: websiteName, + rowid: rowid, + ), + getInsertCompanionBuilder: ({ + required String trigger, + required String websiteName, + Value rowid = const Value.absent(), + }) => + BangFtsCompanion.insert( + trigger: trigger, + websiteName: websiteName, + rowid: rowid, + ), + )); +} + +class $BangFtsProcessedTableManager extends ProcessedTableManager< + _$BangDatabase, + BangFts, + BangFt, + $BangFtsFilterComposer, + $BangFtsOrderingComposer, + $BangFtsProcessedTableManager, + $BangFtsInsertCompanionBuilder, + $BangFtsUpdateCompanionBuilder> { + $BangFtsProcessedTableManager(super.$state); +} + +class $BangFtsFilterComposer extends FilterComposer<_$BangDatabase, BangFts> { + $BangFtsFilterComposer(super.$state); + ColumnFilters get trigger => $state.composableBuilder( + column: $state.table.trigger, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get websiteName => $state.composableBuilder( + column: $state.table.websiteName, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $BangFtsOrderingComposer + extends OrderingComposer<_$BangDatabase, BangFts> { + $BangFtsOrderingComposer(super.$state); + ColumnOrderings get trigger => $state.composableBuilder( + column: $state.table.trigger, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get websiteName => $state.composableBuilder( + column: $state.table.websiteName, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +class _$BangDatabaseManager { + final _$BangDatabase _db; + _$BangDatabaseManager(this._db); + $BangFrequencyTableManager get bangFrequency => + $BangFrequencyTableManager(_db, _db.bangFrequency); + $BangIconTableManager get bangIcon => + $BangIconTableManager(_db, _db.bangIcon); + $BangFtsTableManager get bangFts => $BangFtsTableManager(_db, _db.bangFts); +} diff --git a/app/lib/features/bangs/data/database/drift/converters/bang_format.dart b/app/lib/features/bangs/data/database/drift/converters/bang_format.dart new file mode 100644 index 00000000..b54115b6 --- /dev/null +++ b/app/lib/features/bangs/data/database/drift/converters/bang_format.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +import 'package:bang_navigator/features/bangs/data/models/bang.dart'; +import 'package:drift/drift.dart'; + +class BangFormatConverter extends TypeConverter?, String?> { + const BangFormatConverter(); + + @override + Set? fromSql(String? fromDb) { + if (fromDb == null) { + return null; + } + + return Bang.decodeFormat(jsonDecode(fromDb) as List); + } + + @override + String? toSql(Set? value) { + if (value == null) { + return null; + } + + return jsonEncode(Bang.encodeFormat(value)); + } +} diff --git a/app/lib/features/bangs/data/models/bang.dart b/app/lib/features/bangs/data/models/bang.dart new file mode 100644 index 00000000..94459ed1 --- /dev/null +++ b/app/lib/features/bangs/data/models/bang.dart @@ -0,0 +1,145 @@ +import 'package:bang_navigator/features/bangs/data/database/database.dart' + show BangCompanion; +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:drift/drift.dart' show Expression, Insertable, Value; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'bang.g.dart'; + +enum BangGroup { + assistant, + kagi, +} + +enum BangFormat { + ///When the bang is invoked with no query, opens the base path of the URL (/) + ///instead of any path given in the template (g., /search) + @JsonValue('open_base_path') + openBasePath, + + ///URL encode the search terms. Some sites do not work with this, so it can + ///be disabled by omitting this. + @JsonValue('url_encode_placeholder') + urlEncodePlaceholder, + + ///URL encodes spaces as +, instead of %20. Some sites only work correctly + ///with one or the other. + @JsonValue('url_encode_space_to_plus') + urlEncodeSpaceToPlus, +} + +@JsonSerializable() +@CopyWith() +class Bang with FastEquatable implements Insertable { + static const _templateQueryPlaceholder = '{{{s}}}'; + + @JsonKey(includeFromJson: false, includeToJson: false) + final BangGroup? group; + + ///The name of the website associated with the bang. + @JsonKey(name: 's') + final String websiteName; + + ///The domain name of the websit + @JsonKey(name: 'd') + final String domain; + + ///The specific trigger word or phrase used to invoke the bang. + @JsonKey(name: 't') + final String trigger; + + ///The URL template to use when the bang is invoked, where `{{{s}}}` is replaced by the user's query. + @JsonKey(name: 'u') + final String urlTemplate; + + ///The category of the website, if applicable + @JsonKey(name: 'c') + final String? category; + + ///The subcategory of the website, if applicable + @JsonKey(name: 'sc') + final String? subCategory; + + ///The format flags indicating how the query should be processed. + @JsonKey(name: 'fmt') + final Set? format; + + String formatQuery(String input) { + return (format == null || + format!.contains(BangFormat.urlEncodePlaceholder) == true) + ? (format == null || + format?.contains(BangFormat.urlEncodeSpaceToPlus) == true) + ? Uri.encodeQueryComponent(input) + : Uri.encodeComponent(input) + : input; + } + + Uri getUrl(String? query) { + final url = (query != null) + ? urlTemplate.replaceAll(_templateQueryPlaceholder, formatQuery(query)) + : urlTemplate; + + var template = Uri.parse(url); + if (!template.hasScheme || template.origin.isEmpty) { + template = Uri.https(domain).replace( + path: template.path, + query: template.query, + ); + } + + return template; + } + + static Set decodeFormat(Iterable input) { + return input.map((e) => $enumDecode(_$BangFormatEnumMap, e)).toSet(); + } + + static List encodeFormat(Iterable format) { + return format.map((e) => _$BangFormatEnumMap[e]!).toList(); + } + + Bang({ + required this.websiteName, + required this.domain, + required this.trigger, + required this.urlTemplate, + this.group, + this.category, + this.subCategory, + this.format, + }); + + factory Bang.fromJson(Map json) => _$BangFromJson(json); + + Map toJson() => _$BangToJson(this); + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [ + group, + websiteName, + domain, + trigger, + urlTemplate, + category, + subCategory, + format, + ]; + + @override + Map> toColumns(bool nullToAbsent) { + return BangCompanion( + trigger: Value(trigger), + websiteName: Value(websiteName), + domain: Value(domain), + urlTemplate: Value(urlTemplate), + group: Value.absentIfNull(group), + category: Value.absentIfNull(category), + subCategory: Value.absentIfNull(subCategory), + format: Value.absentIfNull(format), + ).toColumns(nullToAbsent); + } +} diff --git a/app/lib/features/bangs/data/models/bang.g.dart b/app/lib/features/bangs/data/models/bang.g.dart new file mode 100644 index 00000000..bc3f1581 --- /dev/null +++ b/app/lib/features/bangs/data/models/bang.g.dart @@ -0,0 +1,167 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bang.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$BangCWProxy { + Bang websiteName(String websiteName); + + Bang domain(String domain); + + Bang trigger(String trigger); + + Bang urlTemplate(String urlTemplate); + + Bang group(BangGroup? group); + + Bang category(String? category); + + Bang subCategory(String? subCategory); + + Bang format(Set? format); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `Bang(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// Bang(...).copyWith(id: 12, name: "My name") + /// ```` + Bang call({ + String? websiteName, + String? domain, + String? trigger, + String? urlTemplate, + BangGroup? group, + String? category, + String? subCategory, + Set? format, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfBang.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfBang.copyWith.fieldName(...)` +class _$BangCWProxyImpl implements _$BangCWProxy { + const _$BangCWProxyImpl(this._value); + + final Bang _value; + + @override + Bang websiteName(String websiteName) => this(websiteName: websiteName); + + @override + Bang domain(String domain) => this(domain: domain); + + @override + Bang trigger(String trigger) => this(trigger: trigger); + + @override + Bang urlTemplate(String urlTemplate) => this(urlTemplate: urlTemplate); + + @override + Bang group(BangGroup? group) => this(group: group); + + @override + Bang category(String? category) => this(category: category); + + @override + Bang subCategory(String? subCategory) => this(subCategory: subCategory); + + @override + Bang format(Set? format) => this(format: format); + + @override + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `Bang(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// Bang(...).copyWith(id: 12, name: "My name") + /// ```` + Bang call({ + Object? websiteName = const $CopyWithPlaceholder(), + Object? domain = const $CopyWithPlaceholder(), + Object? trigger = const $CopyWithPlaceholder(), + Object? urlTemplate = const $CopyWithPlaceholder(), + Object? group = const $CopyWithPlaceholder(), + Object? category = const $CopyWithPlaceholder(), + Object? subCategory = const $CopyWithPlaceholder(), + Object? format = const $CopyWithPlaceholder(), + }) { + return Bang( + websiteName: + websiteName == const $CopyWithPlaceholder() || websiteName == null + ? _value.websiteName + // ignore: cast_nullable_to_non_nullable + : websiteName as String, + domain: domain == const $CopyWithPlaceholder() || domain == null + ? _value.domain + // ignore: cast_nullable_to_non_nullable + : domain as String, + trigger: trigger == const $CopyWithPlaceholder() || trigger == null + ? _value.trigger + // ignore: cast_nullable_to_non_nullable + : trigger as String, + urlTemplate: + urlTemplate == const $CopyWithPlaceholder() || urlTemplate == null + ? _value.urlTemplate + // ignore: cast_nullable_to_non_nullable + : urlTemplate as String, + group: group == const $CopyWithPlaceholder() + ? _value.group + // ignore: cast_nullable_to_non_nullable + : group as BangGroup?, + category: category == const $CopyWithPlaceholder() + ? _value.category + // ignore: cast_nullable_to_non_nullable + : category as String?, + subCategory: subCategory == const $CopyWithPlaceholder() + ? _value.subCategory + // ignore: cast_nullable_to_non_nullable + : subCategory as String?, + format: format == const $CopyWithPlaceholder() + ? _value.format + // ignore: cast_nullable_to_non_nullable + : format as Set?, + ); + } +} + +extension $BangCopyWith on Bang { + /// Returns a callable class that can be used as follows: `instanceOfBang.copyWith(...)` or like so:`instanceOfBang.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$BangCWProxy get copyWith => _$BangCWProxyImpl(this); +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Bang _$BangFromJson(Map json) => Bang( + websiteName: json['s'] as String, + domain: json['d'] as String, + trigger: json['t'] as String, + urlTemplate: json['u'] as String, + category: json['c'] as String?, + subCategory: json['sc'] as String?, + format: (json['fmt'] as List?) + ?.map((e) => $enumDecode(_$BangFormatEnumMap, e)) + .toSet(), + ); + +Map _$BangToJson(Bang instance) => { + 's': instance.websiteName, + 'd': instance.domain, + 't': instance.trigger, + 'u': instance.urlTemplate, + 'c': instance.category, + 'sc': instance.subCategory, + 'fmt': instance.format?.map((e) => _$BangFormatEnumMap[e]!).toList(), + }; + +const _$BangFormatEnumMap = { + BangFormat.openBasePath: 'open_base_path', + BangFormat.urlEncodePlaceholder: 'url_encode_placeholder', + BangFormat.urlEncodeSpaceToPlus: 'url_encode_space_to_plus', +}; diff --git a/app/lib/features/bangs/data/models/bang_data.dart b/app/lib/features/bangs/data/models/bang_data.dart new file mode 100644 index 00000000..9a13f4aa --- /dev/null +++ b/app/lib/features/bangs/data/models/bang_data.dart @@ -0,0 +1,37 @@ +import 'package:bang_navigator/features/bangs/data/models/bang.dart'; +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:drift/drift.dart'; + +part 'bang_data.g.dart'; + +@CopyWith() +class BangData extends Bang { + final int frequency; + final DateTime? lastUsed; + + final Uint8List? iconData; + + BangData({ + required super.websiteName, + required super.domain, + required super.trigger, + required super.urlTemplate, + super.category, + super.subCategory, + super.format, + int? frequency, + this.lastUsed, + this.iconData, + }) : frequency = frequency ?? 0; + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [ + ...super.hashParameters, + frequency, + lastUsed, + iconData, + ]; +} diff --git a/app/lib/features/bangs/data/models/bang_data.g.dart b/app/lib/features/bangs/data/models/bang_data.g.dart new file mode 100644 index 00000000..d657568e --- /dev/null +++ b/app/lib/features/bangs/data/models/bang_data.g.dart @@ -0,0 +1,157 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bang_data.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$BangDataCWProxy { + BangData websiteName(String websiteName); + + BangData domain(String domain); + + BangData trigger(String trigger); + + BangData urlTemplate(String urlTemplate); + + BangData category(String? category); + + BangData subCategory(String? subCategory); + + BangData format(Set? format); + + BangData frequency(int? frequency); + + BangData lastUsed(DateTime? lastUsed); + + BangData iconData(Uint8List? iconData); + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `BangData(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// BangData(...).copyWith(id: 12, name: "My name") + /// ```` + BangData call({ + String? websiteName, + String? domain, + String? trigger, + String? urlTemplate, + String? category, + String? subCategory, + Set? format, + int? frequency, + DateTime? lastUsed, + Uint8List? iconData, + }); +} + +/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfBangData.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfBangData.copyWith.fieldName(...)` +class _$BangDataCWProxyImpl implements _$BangDataCWProxy { + const _$BangDataCWProxyImpl(this._value); + + final BangData _value; + + @override + BangData websiteName(String websiteName) => this(websiteName: websiteName); + + @override + BangData domain(String domain) => this(domain: domain); + + @override + BangData trigger(String trigger) => this(trigger: trigger); + + @override + BangData urlTemplate(String urlTemplate) => this(urlTemplate: urlTemplate); + + @override + BangData category(String? category) => this(category: category); + + @override + BangData subCategory(String? subCategory) => this(subCategory: subCategory); + + @override + BangData format(Set? format) => this(format: format); + + @override + BangData frequency(int? frequency) => this(frequency: frequency); + + @override + BangData lastUsed(DateTime? lastUsed) => this(lastUsed: lastUsed); + + @override + BangData iconData(Uint8List? iconData) => this(iconData: iconData); + + @override + + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `BangData(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. + /// + /// Usage + /// ```dart + /// BangData(...).copyWith(id: 12, name: "My name") + /// ```` + BangData call({ + Object? websiteName = const $CopyWithPlaceholder(), + Object? domain = const $CopyWithPlaceholder(), + Object? trigger = const $CopyWithPlaceholder(), + Object? urlTemplate = const $CopyWithPlaceholder(), + Object? category = const $CopyWithPlaceholder(), + Object? subCategory = const $CopyWithPlaceholder(), + Object? format = const $CopyWithPlaceholder(), + Object? frequency = const $CopyWithPlaceholder(), + Object? lastUsed = const $CopyWithPlaceholder(), + Object? iconData = const $CopyWithPlaceholder(), + }) { + return BangData( + websiteName: + websiteName == const $CopyWithPlaceholder() || websiteName == null + ? _value.websiteName + // ignore: cast_nullable_to_non_nullable + : websiteName as String, + domain: domain == const $CopyWithPlaceholder() || domain == null + ? _value.domain + // ignore: cast_nullable_to_non_nullable + : domain as String, + trigger: trigger == const $CopyWithPlaceholder() || trigger == null + ? _value.trigger + // ignore: cast_nullable_to_non_nullable + : trigger as String, + urlTemplate: + urlTemplate == const $CopyWithPlaceholder() || urlTemplate == null + ? _value.urlTemplate + // ignore: cast_nullable_to_non_nullable + : urlTemplate as String, + category: category == const $CopyWithPlaceholder() + ? _value.category + // ignore: cast_nullable_to_non_nullable + : category as String?, + subCategory: subCategory == const $CopyWithPlaceholder() + ? _value.subCategory + // ignore: cast_nullable_to_non_nullable + : subCategory as String?, + format: format == const $CopyWithPlaceholder() + ? _value.format + // ignore: cast_nullable_to_non_nullable + : format as Set?, + frequency: frequency == const $CopyWithPlaceholder() + ? _value.frequency + // ignore: cast_nullable_to_non_nullable + : frequency as int?, + lastUsed: lastUsed == const $CopyWithPlaceholder() + ? _value.lastUsed + // ignore: cast_nullable_to_non_nullable + : lastUsed as DateTime?, + iconData: iconData == const $CopyWithPlaceholder() + ? _value.iconData + // ignore: cast_nullable_to_non_nullable + : iconData as Uint8List?, + ); + } +} + +extension $BangDataCopyWith on BangData { + /// Returns a callable class that can be used as follows: `instanceOfBangData.copyWith(...)` or like so:`instanceOfBangData.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$BangDataCWProxy get copyWith => _$BangDataCWProxyImpl(this); +} diff --git a/app/lib/features/bangs/data/providers.dart b/app/lib/features/bangs/data/providers.dart new file mode 100644 index 00000000..b5fb8528 --- /dev/null +++ b/app/lib/features/bangs/data/providers.dart @@ -0,0 +1,37 @@ +import 'package:bang_navigator/features/bangs/data/database/database.dart'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart' as path_provider; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; +import 'package:universal_io/io.dart'; + +part 'providers.g.dart'; + +@Riverpod(keepAlive: true) +BangDatabase bangDatabase(BangDatabaseRef ref) { + return BangDatabase( + LazyDatabase(() async { + // put the database file, called db.sqlite here, into the documents folder + // for your app. + final dbFolder = await path_provider.getApplicationDocumentsDirectory(); + final file = File(p.join(dbFolder.path, 'bang.db')); + + // Also work around limitations on old Android versions + if (Platform.isAndroid) { + await applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); + } + + // Make sqlite3 pick a more suitable location for temporary files - the + // one from the system may be inaccessible due to sandboxing. + final cachebase = (await path_provider.getTemporaryDirectory()).path; + // We can't access /tmp on Android, which sqlite3 would try by default. + // Explicitly tell it about the correct temporary directory. + sqlite3.tempDirectory = cachebase; + + return NativeDatabase.createInBackground(file); + }), + ); +} diff --git a/app/lib/features/bangs/data/providers.g.dart b/app/lib/features/bangs/data/providers.g.dart new file mode 100644 index 00000000..3086995b --- /dev/null +++ b/app/lib/features/bangs/data/providers.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$bangDatabaseHash() => r'bbca2edf32faa378dcb6270b12019758d5156662'; + +/// See also [bangDatabase]. +@ProviderFor(bangDatabase) +final bangDatabaseProvider = Provider.internal( + bangDatabase, + name: r'bangDatabaseProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$bangDatabaseHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef BangDatabaseRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/bangs/data/services/bang_source.dart b/app/lib/features/bangs/data/services/bang_source.dart new file mode 100644 index 00000000..fbfead4c --- /dev/null +++ b/app/lib/features/bangs/data/services/bang_source.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +import 'package:bang_navigator/core/http_error_handler.dart'; +import 'package:bang_navigator/features/bangs/data/models/bang.dart'; +import 'package:exceptions/exceptions.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'bang_source.g.dart'; + +@Riverpod() +class BangSource extends _$BangSource { + late http.Client _client; + + @override + void build() { + _client = http.Client(); + } + + Future>> getBangs(Uri url, BangGroup? group) async { + return Result.fromAsync( + () async { + final response = await _client.get(url); + return await compute( + (args) => jsonDecode(utf8.decode(args[0])) as List, + [response.bodyBytes], + ).then( + (json) => json.map((e) { + var bang = Bang.fromJson(e as Map); + + if (group != null) { + bang = bang.copyWith.group(group); + } + + return bang; + }).toList(), + ); + }, + exceptionHandler: handleHttpError, + ); + } +} diff --git a/app/lib/features/bangs/data/services/bang_source.g.dart b/app/lib/features/bangs/data/services/bang_source.g.dart new file mode 100644 index 00000000..e6a1df54 --- /dev/null +++ b/app/lib/features/bangs/data/services/bang_source.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bang_source.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$bangSourceHash() => r'6ca70e8be16a0ae104fa6b739e3c0f2c59eab565'; + +/// See also [BangSource]. +@ProviderFor(BangSource) +final bangSourceProvider = + AutoDisposeNotifierProvider.internal( + BangSource.new, + name: r'bangSourceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$bangSourceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$BangSource = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/bangs/domain/providers.dart b/app/lib/features/bangs/domain/providers.dart new file mode 100644 index 00000000..0a3923cb --- /dev/null +++ b/app/lib/features/bangs/domain/providers.dart @@ -0,0 +1,40 @@ +import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; +import 'package:bang_navigator/features/bangs/domain/repositories/bang.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'providers.g.dart'; + +@Riverpod() +Stream bang(BangRef ref, String? trigger) { + final repository = ref.watch(bangRepositoryProvider.notifier); + return repository.watchBang(trigger); +} + +@Riverpod(keepAlive: true) +Stream kagiSearchBang(KagiSearchBangRef ref) { + final repository = ref.watch(bangRepositoryProvider.notifier); + return repository.watchBang('kagi'); +} + +@Riverpod() +Stream> allBangs(AllBangsRef ref) { + final repository = ref.watch(bangRepositoryProvider.notifier); + return repository.watchBangs(); +} + +@Riverpod() +Stream> frequentBangs(FrequentBangsRef ref) { + final repository = ref.watch(bangRepositoryProvider.notifier); + return repository.watchFrequentBangs(); +} + +@Riverpod() +Future ensureIconAvailable( + EnsureIconAvailableRef ref, + BangData bang, +) { + final repository = ref.watch(bangRepositoryProvider.notifier); + return repository.ensureIconAvailable(bang).then( + (value) => value.value, + ); +} diff --git a/app/lib/features/bangs/domain/providers.g.dart b/app/lib/features/bangs/domain/providers.g.dart new file mode 100644 index 00000000..d5baf458 --- /dev/null +++ b/app/lib/features/bangs/domain/providers.g.dart @@ -0,0 +1,330 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$bangHash() => r'e3d44af52e27831b568f292d38b447a443acec95'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [bang]. +@ProviderFor(bang) +const bangProvider = BangFamily(); + +/// See also [bang]. +class BangFamily extends Family> { + /// See also [bang]. + const BangFamily(); + + /// See also [bang]. + BangProvider call( + String? trigger, + ) { + return BangProvider( + trigger, + ); + } + + @override + BangProvider getProviderOverride( + covariant BangProvider provider, + ) { + return call( + provider.trigger, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'bangProvider'; +} + +/// See also [bang]. +class BangProvider extends AutoDisposeStreamProvider { + /// See also [bang]. + BangProvider( + String? trigger, + ) : this._internal( + (ref) => bang( + ref as BangRef, + trigger, + ), + from: bangProvider, + name: r'bangProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$bangHash, + dependencies: BangFamily._dependencies, + allTransitiveDependencies: BangFamily._allTransitiveDependencies, + trigger: trigger, + ); + + BangProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.trigger, + }) : super.internal(); + + final String? trigger; + + @override + Override overrideWith( + Stream Function(BangRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: BangProvider._internal( + (ref) => create(ref as BangRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + trigger: trigger, + ), + ); + } + + @override + AutoDisposeStreamProviderElement createElement() { + return _BangProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is BangProvider && other.trigger == trigger; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, trigger.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin BangRef on AutoDisposeStreamProviderRef { + /// The parameter `trigger` of this provider. + String? get trigger; +} + +class _BangProviderElement extends AutoDisposeStreamProviderElement + with BangRef { + _BangProviderElement(super.provider); + + @override + String? get trigger => (origin as BangProvider).trigger; +} + +String _$kagiSearchBangHash() => r'ec830f26cf1d23e1e75747e1523cb1868e77d40b'; + +/// See also [kagiSearchBang]. +@ProviderFor(kagiSearchBang) +final kagiSearchBangProvider = StreamProvider.internal( + kagiSearchBang, + name: r'kagiSearchBangProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$kagiSearchBangHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef KagiSearchBangRef = StreamProviderRef; +String _$allBangsHash() => r'212201f81e21fd0c1b5fb192359fb6ff8269a2a6'; + +/// See also [allBangs]. +@ProviderFor(allBangs) +final allBangsProvider = AutoDisposeStreamProvider>.internal( + allBangs, + name: r'allBangsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$allBangsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef AllBangsRef = AutoDisposeStreamProviderRef>; +String _$frequentBangsHash() => r'12867819c6e2bbc5f2b6342aacc286f62d3a1803'; + +/// See also [frequentBangs]. +@ProviderFor(frequentBangs) +final frequentBangsProvider = + AutoDisposeStreamProvider>.internal( + frequentBangs, + name: r'frequentBangsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$frequentBangsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef FrequentBangsRef = AutoDisposeStreamProviderRef>; +String _$ensureIconAvailableHash() => + r'2e112c88da8cf9bf12bdc5e5d525d59fe97a9419'; + +/// See also [ensureIconAvailable]. +@ProviderFor(ensureIconAvailable) +const ensureIconAvailableProvider = EnsureIconAvailableFamily(); + +/// See also [ensureIconAvailable]. +class EnsureIconAvailableFamily extends Family> { + /// See also [ensureIconAvailable]. + const EnsureIconAvailableFamily(); + + /// See also [ensureIconAvailable]. + EnsureIconAvailableProvider call( + BangData bang, + ) { + return EnsureIconAvailableProvider( + bang, + ); + } + + @override + EnsureIconAvailableProvider getProviderOverride( + covariant EnsureIconAvailableProvider provider, + ) { + return call( + provider.bang, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'ensureIconAvailableProvider'; +} + +/// See also [ensureIconAvailable]. +class EnsureIconAvailableProvider extends AutoDisposeFutureProvider { + /// See also [ensureIconAvailable]. + EnsureIconAvailableProvider( + BangData bang, + ) : this._internal( + (ref) => ensureIconAvailable( + ref as EnsureIconAvailableRef, + bang, + ), + from: ensureIconAvailableProvider, + name: r'ensureIconAvailableProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$ensureIconAvailableHash, + dependencies: EnsureIconAvailableFamily._dependencies, + allTransitiveDependencies: + EnsureIconAvailableFamily._allTransitiveDependencies, + bang: bang, + ); + + EnsureIconAvailableProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.bang, + }) : super.internal(); + + final BangData bang; + + @override + Override overrideWith( + FutureOr Function(EnsureIconAvailableRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: EnsureIconAvailableProvider._internal( + (ref) => create(ref as EnsureIconAvailableRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + bang: bang, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _EnsureIconAvailableProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is EnsureIconAvailableProvider && other.bang == bang; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, bang.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin EnsureIconAvailableRef on AutoDisposeFutureProviderRef { + /// The parameter `bang` of this provider. + BangData get bang; +} + +class _EnsureIconAvailableProviderElement + extends AutoDisposeFutureProviderElement + with EnsureIconAvailableRef { + _EnsureIconAvailableProviderElement(super.provider); + + @override + BangData get bang => (origin as EnsureIconAvailableProvider).bang; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/bangs/domain/repositories/bang.dart b/app/lib/features/bangs/domain/repositories/bang.dart new file mode 100644 index 00000000..e49c111d --- /dev/null +++ b/app/lib/features/bangs/domain/repositories/bang.dart @@ -0,0 +1,154 @@ +import 'package:bang_navigator/domain/services/generic_website.dart'; +import 'package:bang_navigator/features/bangs/data/database/database.dart'; +import 'package:bang_navigator/features/bangs/data/models/bang.dart'; +import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; +import 'package:bang_navigator/features/bangs/data/providers.dart'; +import 'package:bang_navigator/features/bangs/data/services/bang_source.dart'; +import 'package:bang_navigator/utils/image_helper.dart'; +import 'package:drift/isolate.dart'; +import 'package:exceptions/exceptions.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'bang.g.dart'; + +Future> _syncBangGroupStatic( + BangSource source, + BangDatabase db, + Uri url, + BangGroup? group, +) async { + final result = await source.getBangs(url, group); + + return result.flatMapAsync( + (value) async { + final sourcedBangMap = + Map.fromEntries(value.map((e) => MapEntry(e.trigger, e))); + final localBangMap = + await db.bangDao.getBangs(groups: [group]).get().then( + (bangs) => + Map.fromEntries(bangs.map((e) => MapEntry(e.trigger, e))), + ); + + final sourcedBangTriggers = sourcedBangMap.keys.toSet(); + final localBangTriggers = localBangMap.keys.toSet(); + + final removedBangs = localBangTriggers.difference(sourcedBangTriggers); + final addedBangs = sourcedBangTriggers.difference(localBangTriggers).map( + (e) => sourcedBangMap[e]!, + ); + + final changedBangs = sourcedBangTriggers + .intersection(localBangTriggers) + .where((e) => sourcedBangMap[e] != localBangMap[e]) + .map( + (e) => sourcedBangMap[e]!, + ); + + await db.bangDao.deleteBangs(removedBangs); + await db.bangDao.insertBangs(addedBangs); + await db.bangDao.replaceBangs(changedBangs); + + await db.optimizeFtsIndex(); + }, + ); +} + +@Riverpod(keepAlive: true) +class BangRepository extends _$BangRepository { + late BangDatabase _db; + + @override + void build() { + _db = ref.watch(bangDatabaseProvider); + } + + Stream watchBang(String? trigger) { + if (trigger != null) { + return _db.bangDao.getBangData(trigger).watchSingleOrNull(); + } else { + return Stream.value(null); + } + } + + Stream> watchBangs({Iterable? groups}) { + return _db.bangDao.getBangDataList(groups: groups).watch(); + } + + Stream> watchFrequentBangs({Iterable? groups}) { + return _db.bangDao.getFrequentBangDataList(groups: groups).watch(); + } + + Future increaseFrequency(String trigger) { + return _db.bangDao.increaseFrequency(trigger); + } + + Future> ensureIconAvailable(BangData bang) async { + if (bang.iconData != null) { + return Result.success(bang); + } + + final result = await ref + .read(genericWebsiteServiceProvider.notifier) + .getFaviconBytes(Uri.parse(bang.getUrl('').origin)); + + return result.flatMapAsync( + (faviconBytes) async { + if (faviconBytes != null && await isImageValid(faviconBytes)) { + await _db.bangDao.upsertIcon(bang.trigger, faviconBytes); + return bang.copyWith.iconData(faviconBytes); + } + + return bang; + }, + ); + } + + Future> syncBangGroup( + Uri url, + BangGroup? group, + ) => + _syncBangGroupStatic( + ref.read(bangSourceProvider.notifier), + _db, + url, + group, + ); + + Future syncAllBangGroups() async { + await _db.computeWithDatabase( + connect: BangDatabase.new, + computation: (db) async { + final ref = ProviderContainer(); + final source = ref.read(bangSourceProvider.notifier); + + await _syncBangGroupStatic( + source, + db, + Uri.parse( + 'https://raw.githubusercontent.com/kagisearch/bangs/main/data/bangs.json', + ), + null, + ); + + await _syncBangGroupStatic( + source, + db, + Uri.parse( + 'https://raw.githubusercontent.com/kagisearch/bangs/main/data/kagi_bangs.json', + ), + BangGroup.kagi, + ); + + await _syncBangGroupStatic( + source, + db, + Uri.parse( + 'https://raw.githubusercontent.com/kagisearch/bangs/main/data/assistant_bangs.json', + ), + BangGroup.assistant, + ); + }, + ); + } +} diff --git a/app/lib/features/bangs/domain/repositories/bang.g.dart b/app/lib/features/bangs/domain/repositories/bang.g.dart new file mode 100644 index 00000000..75b6b327 --- /dev/null +++ b/app/lib/features/bangs/domain/repositories/bang.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bang.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$bangRepositoryHash() => r'd405550e2ad11c57f3644f18390348efb71bdf0f'; + +/// See also [BangRepository]. +@ProviderFor(BangRepository) +final bangRepositoryProvider = NotifierProvider.internal( + BangRepository.new, + name: r'bangRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bangRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$BangRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/bangs/domain/repositories/bang_search.dart b/app/lib/features/bangs/domain/repositories/bang_search.dart new file mode 100644 index 00000000..19f5466b --- /dev/null +++ b/app/lib/features/bangs/domain/repositories/bang_search.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:bang_navigator/features/bangs/data/database/database.dart'; +import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; +import 'package:bang_navigator/features/bangs/data/providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'bang_search.g.dart'; + +@Riverpod() +class BangSearch extends _$BangSearch { + late StreamController> _streamController; + late BangDatabase _db; + + Future search(String input) async { + if (input.isNotEmpty) { + await _db.bangDao.queryBangs(input).get().then(_streamController.add); + } + } + + @override + Stream> build() { + _db = ref.watch(bangDatabaseProvider); + _streamController = StreamController(); + + ref.onDispose(() async { + await _streamController.close(); + }); + + return _streamController.stream; + } +} diff --git a/app/lib/features/bangs/domain/repositories/bang_search.g.dart b/app/lib/features/bangs/domain/repositories/bang_search.g.dart new file mode 100644 index 00000000..fdddf0a2 --- /dev/null +++ b/app/lib/features/bangs/domain/repositories/bang_search.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bang_search.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$bangSearchHash() => r'abd71f10288ac1f559e66fc11035f9f2b027c353'; + +/// See also [BangSearch]. +@ProviderFor(BangSearch) +final bangSearchProvider = + AutoDisposeStreamNotifierProvider>.internal( + BangSearch.new, + name: r'bangSearchProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$bangSearchHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$BangSearch = AutoDisposeStreamNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/bangs/presentation/screens/bang.dart b/app/lib/features/bangs/presentation/screens/bang.dart new file mode 100644 index 00000000..8d52e957 --- /dev/null +++ b/app/lib/features/bangs/presentation/screens/bang.dart @@ -0,0 +1,61 @@ +import 'package:bang_navigator/core/routing/routes.dart'; +import 'package:bang_navigator/features/bangs/domain/providers.dart'; +import 'package:bang_navigator/features/bangs/presentation/widgets/bang_details.dart'; +import 'package:bang_navigator/features/search_browser/domain/entities/modes.dart'; +import 'package:bang_navigator/features/search_browser/domain/entities/sheet.dart'; +import 'package:bang_navigator/features/search_browser/domain/providers.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class BangScreen extends HookConsumerWidget { + const BangScreen({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final bangsAsync = ref.watch(allBangsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Bangs'), + actions: [ + IconButton( + onPressed: () async { + await context.push(BangSearchRoute().location); + }, + icon: const Icon(Icons.search), + ), + ], + ), + body: bangsAsync.when( + data: (bangs) { + return ListView.builder( + itemCount: bangs.length, + itemBuilder: (context, index) { + final bang = bangs[index]; + return BangDetails( + bang, + onTap: () { + ref + .read(selectedBangTriggerProvider.notifier) + .setTrigger(bang.trigger); + + if (ref.read(bottomSheetProvider) is! CreateTab) { + ref.read(bottomSheetProvider.notifier).show( + CreateTab( + preferredTool: KagiTool.search, + ), + ); + } + + context.go(KagiRoute().location); + }, + ); + }, + ); + }, + error: (error, stackTrace) => SizedBox.shrink(), + loading: () => SizedBox.shrink(), + ), + ); + } +} diff --git a/app/lib/features/bangs/presentation/screens/search.dart b/app/lib/features/bangs/presentation/screens/search.dart new file mode 100644 index 00000000..ec2be849 --- /dev/null +++ b/app/lib/features/bangs/presentation/screens/search.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:bang_navigator/core/routing/routes.dart'; +import 'package:bang_navigator/features/bangs/domain/repositories/bang_search.dart'; +import 'package:bang_navigator/features/bangs/presentation/widgets/bang_details.dart'; +import 'package:bang_navigator/features/search_browser/domain/entities/modes.dart'; +import 'package:bang_navigator/features/search_browser/domain/entities/sheet.dart'; +import 'package:bang_navigator/features/search_browser/domain/providers.dart'; +import 'package:bang_navigator/presentation/hooks/listenable_callback.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class BangSearchScreen extends HookConsumerWidget { + const BangSearchScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final resultsAsync = ref.watch(bangSearchProvider); + + final textEditingController = useTextEditingController(); + + useListenableCallback(textEditingController, () async { + unawaited( + ref + .read(bangSearchProvider.notifier) + .search(textEditingController.text), + ); + }); + + return Scaffold( + appBar: AppBar( + title: TextField( + controller: textEditingController, + decoration: const InputDecoration.collapsed(hintText: 'Search'), + ), + actions: [ + IconButton( + onPressed: () { + if (textEditingController.text.isEmpty) { + context.pop(); + } else { + textEditingController.clear(); + } + }, + icon: const Icon(Icons.clear), + ), + ], + ), + body: resultsAsync.when( + skipLoadingOnReload: true, + data: (bangs) => ListView.builder( + itemCount: bangs.length, + itemBuilder: (context, index) { + final bang = bangs[index]; + return BangDetails( + bang, + onTap: () { + ref + .read(selectedBangTriggerProvider.notifier) + .setTrigger(bang.trigger); + + if (ref.read(bottomSheetProvider) is! CreateTab) { + ref.read(bottomSheetProvider.notifier).show( + CreateTab( + preferredTool: KagiTool.search, + ), + ); + } + + context.go(KagiRoute().location); + }, + ); + }, + ), + error: (error, stackTrace) => SizedBox.shrink(), + loading: () => SizedBox.shrink(), + ), + ); + } +} diff --git a/app/lib/features/bangs/presentation/widgets/bang_details.dart b/app/lib/features/bangs/presentation/widgets/bang_details.dart new file mode 100644 index 00000000..967e6fc8 --- /dev/null +++ b/app/lib/features/bangs/presentation/widgets/bang_details.dart @@ -0,0 +1,106 @@ +import 'package:bang_navigator/core/routing/routes.dart'; +import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; +import 'package:bang_navigator/features/bangs/presentation/widgets/bang_icon.dart'; +import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; +import 'package:bang_navigator/features/web_view/presentation/controllers/switch_new_tab.dart'; +import 'package:bang_navigator/utils/ui_helper.dart' as ui_helper; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class BangDetails extends HookConsumerWidget { + final BangData bangData; + final void Function()? onTap; + + const BangDetails(this.bangData, {this.onTap, super.key}); + + String? _categoryString(BangData bang) { + if (bang.category == null) { + return null; + } else if (bang.subCategory == null) { + return bang.category; + } else { + return '${bang.category} / ${bang.subCategory}'; + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + BangIcon(bangData), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + bangData.websiteName.trim(), + style: theme.textTheme.titleMedium, + ), + if (bangData.category != null) + Text( + _categoryString(bangData)!, + style: theme.textTheme.titleSmall, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8.0), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FilledButton.tonalIcon( + style: + const ButtonStyle(visualDensity: VisualDensity.compact), + onPressed: () async { + final url = Uri.parse(bangData.getUrl('').origin); + + final launchExternal = ref + .read(settingsRepositoryProvider) + .valueOrNull + ?.launchUrlExternal ?? + false; + + if (launchExternal) { + await ui_helper.launchUrlFeedback(context, url); + } else { + await ref + .read(switchNewTabControllerProvider.notifier) + .add(url); + + if (context.mounted) { + context.go(KagiRoute().location); + } + } + }, + label: Text(bangData.domain), + icon: const Icon(Icons.open_in_new), + ), + Text( + '!${bangData.trigger}', + style: theme.textTheme.titleSmall, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/bangs/presentation/widgets/bang_icon.dart b/app/lib/features/bangs/presentation/widgets/bang_icon.dart new file mode 100644 index 00000000..c6202220 --- /dev/null +++ b/app/lib/features/bangs/presentation/widgets/bang_icon.dart @@ -0,0 +1,43 @@ +import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; +import 'package:bang_navigator/features/bangs/domain/providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +class BangIcon extends HookConsumerWidget { + final double iconSize; + final BangData bangData; + + const BangIcon(this.bangData, {this.iconSize = 34.0, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bangAsync = ref.watch(ensureIconAvailableProvider(bangData)); + final bang = bangAsync.valueOrNull ?? bangData; + + return Skeletonizer( + enabled: bangAsync.isLoading, + child: SizedBox.square( + dimension: iconSize, + child: (bang.iconData != null) + ? Image.memory( + bang.iconData!, + height: iconSize, + width: iconSize, + fit: BoxFit.fill, + errorBuilder: (context, error, stackTrace) { + return Icon( + MdiIcons.web, + size: iconSize, + ); + }, + ) + : Icon( + MdiIcons.web, + size: iconSize, + ), + ), + ); + } +} diff --git a/app/lib/features/chat_archive/domain/repositories/chat_archive.dart b/app/lib/features/chat_archive/domain/repositories/chat_archive.dart index 0e1a1fc0..aec7174d 100644 --- a/app/lib/features/chat_archive/domain/repositories/chat_archive.dart +++ b/app/lib/features/chat_archive/domain/repositories/chat_archive.dart @@ -64,7 +64,9 @@ class ChatArchiveRepository extends _$ChatArchiveRepository { @Riverpod() Future readArchivedChat( - ReadArchivedChatRef ref, String fileName) async { + ReadArchivedChatRef ref, + String fileName, +) async { final result = await ref.read(chatArchiveRepositoryProvider.notifier).readChat(fileName); diff --git a/app/lib/features/query/domain/entities/abstract/i_query_builder.dart b/app/lib/features/query/domain/entities/abstract/i_query_builder.dart new file mode 100644 index 00000000..080f704e --- /dev/null +++ b/app/lib/features/query/domain/entities/abstract/i_query_builder.dart @@ -0,0 +1,6 @@ +abstract interface class IQueryBuilder { + int get ftsTokenLimit; + int get ftsMinTokenLength; + + String buildQuery(String input); +} diff --git a/app/lib/features/query/domain/entities/bareword.dart b/app/lib/features/query/domain/entities/bareword.dart new file mode 100644 index 00000000..c3b065e2 --- /dev/null +++ b/app/lib/features/query/domain/entities/bareword.dart @@ -0,0 +1,70 @@ +const barewordConcat = ' '; + +sealed class Bareword { + final String word; + + @override + int get hashCode => word.hashCode; + + Bareword(String word) : word = word.replaceAll('"', '""'); + + @override + bool operator ==(Object other) { + if (other is Bareword) { + return word == other.word; + } + + return false; + } + + JoinedBareword join(Bareword other) { + return JoinedBareword(other.word, this); + } +} + +final class SimpleBareword extends Bareword { + SimpleBareword(super.word); + + @override + String toString() => word; +} + +final class EnclosedBareword extends Bareword { + EnclosedBareword(super.word); + + @override + String toString() { + return '"$word"'; + } +} + +final class JoinedBareword extends Bareword { + final Bareword parent; + + JoinedBareword(super.bareword, this.parent); + + bool hasDependency(Bareword other) { + if (parent is JoinedBareword) { + return (parent as JoinedBareword).hasDependency(other); + } else { + return parent == other; + } + } + + Iterable get dependencies { + final depencies = [parent]; + while (depencies.last is JoinedBareword) { + depencies.add((depencies.last as JoinedBareword).parent); + } + + return depencies.reversed; + } + + @override + String toString() { + final deps = dependencies + .map((bareword) => bareword.toString()) + .join(barewordConcat); + return '"$deps$barewordConcat$word"'; + } +} diff --git a/app/lib/features/query/domain/tokenizer.dart b/app/lib/features/query/domain/tokenizer.dart new file mode 100644 index 00000000..a54351c4 --- /dev/null +++ b/app/lib/features/query/domain/tokenizer.dart @@ -0,0 +1,241 @@ +import 'dart:math' as math; + +import 'package:bang_navigator/features/query/domain/entities/abstract/i_query_builder.dart'; +import 'package:bang_navigator/features/query/domain/entities/bareword.dart'; +import 'package:collection/collection.dart'; + +typedef _Phrase = List; + +sealed class QueryBuilder { + ///Matching quoted strings like "xda bda" (group 1), as well as strings delimetered by + ///a whitespace (group 2) + static final _tokenizePattern = RegExp('"([^"]+)"|([^ ]+)'); + + ///Reserved FTS5 baewords + static const _reservedBarewords = {'AND', 'OR', 'NOT'}; + + ///Checks if string is an bareword accoring to: + ///https://www.sqlite.org/fts5.html#fts5_strings + /// + ///Elsewise ths string needs to get quoted + static final _barewordPattern = + RegExp(r'^([^\x00-\x7F]|[\w]|[\d]|[_]|[\x1A])+$'); + + static bool _isReservedBareword(Bareword input) => + _reservedBarewords.contains(input.word); + + late final _Phrase _tokens; + + QueryBuilder.tokenize({ + required String input, + required int minTokenLength, + required int tokenLimit, + }) { + final matches = _tokenizePattern.allMatches(input); + + final barewords = matches + .map((match) { + if (match.group(1) != null) { + return EnclosedBareword(match.group(1)!); + } else if (_barewordPattern.hasMatch(match.group(2)!)) { + return SimpleBareword(match.group(2)!); + } else { + return EnclosedBareword(match.group(2)!); + } + }) + .where((token) => token.word.isNotEmpty) + .whereNot(_isReservedBareword) + .toList(); + + //Merge short tokens + _mergeShortBarewords(barewords, minTokenLength); + + _tokens = barewords.take(tokenLimit).toList(); + } + + static void _mergeShortBarewords( + List barewords, + int minTokenLength, + ) { + for (var i = 0; i < barewords.length; i++) { + final bareword = barewords[i]; + + if (i != 0) { + if (bareword.word.length < minTokenLength) { + barewords[i] = barewords[i - 1].join(bareword); + } + } + } + } + + //Get weighted groups of tokens + //Weight depends on order + static List<_Phrase> _getWeighted(List tokens) { + if (tokens.isEmpty) { + return []; + } + + final phrases = List.generate( + tokens.length - 1, + (index) => tokens.sublist(0, tokens.length - index), + ); + + phrases.addAll(tokens.map((token) => [token])); + + return phrases; + } + + //Get all combinations of input tokens and treat them as equally + //Match of bigger group will have greater score + static List<_Phrase> _getCombinations(List tokens) { + if (tokens.isEmpty) { + return []; + } + + final combinations = <_Phrase>[]; + final slent = math.pow(2, tokens.length); + + for (var i = 0; i < slent; i++) { + final temp = []; + + for (var j = 0; j < tokens.length; j++) { + if ((i & math.pow(2, j).toInt()) > 0) { + temp.add(tokens[j]); + } + } + + if (temp.isNotEmpty) { + combinations.add(temp); + } + } + + return combinations; + } + + //If we have multiple unenclosed token, combine them into quoted to + //get a better full range match + //TODO: Before migration this was only used for non-prefix tokens, check for side effects + static List<_Phrase> _combineUnenclosed(_Phrase phrase) { + return [ + phrase, + if (phrase.length > 2 && + phrase.every((bareword) => bareword is SimpleBareword)) + [EnclosedBareword(phrase.join(barewordConcat))], + ]; + } + + //Decinding query strategy + static List<_Phrase> _generatePhrases(List tokens) { + if (tokens.length <= 4) { + return _getCombinations(tokens); + } else if (tokens.length <= 8) { + return _getWeighted(tokens); + } else { + return [tokens]; + } + } + + static _Phrase _removeDependendBarewords(List phrase) { + final joinedPhrase = List.of(phrase); + + for (var i = 0; i < joinedPhrase.length; i++) { + final bareword = joinedPhrase[i]; + + //Remove dependend phrases + if (joinedPhrase.whereType().any( + (joined) => joined.dependencies.contains(bareword), + )) { + joinedPhrase.removeAt(i); + i--; + } + } + + return joinedPhrase; + } + + String _buildQuery( + List tokens, + String Function(List phrase) barewordConcat, + String phraseConcat, + ) { + final phrases = _generatePhrases(tokens) + .map(_removeDependendBarewords) + .expand(_combineUnenclosed); + + return phrases + .where((phrase) => phrase.isNotEmpty) + .sortedBy((x) => x.length) + .reversed + .map(barewordConcat) + .toSet() + .join(phraseConcat); + } + + String build(); +} + +final class PrefixQueryBuilder extends QueryBuilder { + PrefixQueryBuilder.tokenize({ + required super.input, + required super.minTokenLength, + required super.tokenLimit, + }) : super.tokenize(); + + @override + String build() { + return _buildQuery( + _tokens, + (phrase) { + if (phrase.length == 1) { + return '${phrase.first}*'; + } + + return 'NEAR(${phrase.map((bareword) => '$bareword*').join(' ')})'; + }, + ' OR ', + ); + } +} + +final class TrigramQueryBuilder extends QueryBuilder { + TrigramQueryBuilder.tokenize({ + required super.input, + required super.minTokenLength, + required super.tokenLimit, + }) : super.tokenize(); + + @override + String build() { + return _buildQuery( + _tokens, + (phrase) => phrase.join(' '), + ' OR ', + ); + } +} + +mixin PrefixQueryBuilderMixin implements IQueryBuilder { + @override + String buildQuery(String input) { + final queryBuilder = PrefixQueryBuilder.tokenize( + input: input, + minTokenLength: ftsMinTokenLength, + tokenLimit: ftsTokenLimit, + ); + + return queryBuilder.build(); + } +} + +mixin TrigramQueryBuilderMixin implements IQueryBuilder { + @override + String buildQuery(String input) { + final queryBuilder = TrigramQueryBuilder.tokenize( + input: input, + minTokenLength: ftsMinTokenLength, + tokenLimit: ftsTokenLimit, + ); + + return queryBuilder.build(); + } +} diff --git a/app/lib/features/search_browser/domain/providers.dart b/app/lib/features/search_browser/domain/providers.dart index 2ca121d8..815ba32c 100644 --- a/app/lib/features/search_browser/domain/providers.dart +++ b/app/lib/features/search_browser/domain/providers.dart @@ -2,6 +2,8 @@ import 'dart:async'; +import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; +import 'package:bang_navigator/features/bangs/domain/repositories/bang.dart'; import 'package:bang_navigator/features/search_browser/domain/entities/sheet.dart'; import 'package:bang_navigator/features/search_browser/domain/services/create_tab.dart'; import 'package:flutter/widgets.dart'; @@ -65,3 +67,26 @@ class BottomSheetExtend extends _$BottomSheetExtend { .sampleTime(const Duration(milliseconds: 50)); } } + +@Riverpod(keepAlive: true) +class SelectedBangTrigger extends _$SelectedBangTrigger { + void setTrigger(String trigger) { + state = trigger; + } + + void clearTrigger() { + state = null; + } + + @override + String? build() { + return null; + } +} + +@Riverpod() +Stream selectedBangData(SelectedBangDataRef ref) { + final repository = ref.watch(bangRepositoryProvider.notifier); + final selectedBangTrigger = ref.watch(selectedBangTriggerProvider); + return repository.watchBang(selectedBangTrigger); +} diff --git a/app/lib/features/search_browser/domain/providers.g.dart b/app/lib/features/search_browser/domain/providers.g.dart index e919c703..1bf33c81 100644 --- a/app/lib/features/search_browser/domain/providers.g.dart +++ b/app/lib/features/search_browser/domain/providers.g.dart @@ -6,6 +6,21 @@ part of 'providers.dart'; // RiverpodGenerator // ************************************************************************** +String _$selectedBangDataHash() => r'6d99a08f4a02027cc007ee1df30e5ac7923ca5f5'; + +/// See also [selectedBangData]. +@ProviderFor(selectedBangData) +final selectedBangDataProvider = AutoDisposeStreamProvider.internal( + selectedBangData, + name: r'selectedBangDataProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedBangDataHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef SelectedBangDataRef = AutoDisposeStreamProviderRef; String _$overlayDialogHash() => r'7a0376fff0ba12c70a257a47391e7c049dabd36d'; /// See also [OverlayDialog]. @@ -53,5 +68,22 @@ final bottomSheetExtendProvider = ); typedef _$BottomSheetExtend = StreamNotifier; +String _$selectedBangTriggerHash() => + r'd110e4f8bd9d362e0cc54ee56a4f247d0870c760'; + +/// See also [SelectedBangTrigger]. +@ProviderFor(SelectedBangTrigger) +final selectedBangTriggerProvider = + NotifierProvider.internal( + SelectedBangTrigger.new, + name: r'selectedBangTriggerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedBangTriggerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SelectedBangTrigger = Notifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/search_browser/presentation/widgets/bang_chips.dart b/app/lib/features/search_browser/presentation/widgets/bang_chips.dart new file mode 100644 index 00000000..b3f5dc7d --- /dev/null +++ b/app/lib/features/search_browser/presentation/widgets/bang_chips.dart @@ -0,0 +1,93 @@ +import 'package:bang_navigator/core/routing/routes.dart'; +import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; +import 'package:bang_navigator/features/bangs/domain/providers.dart'; +import 'package:bang_navigator/features/bangs/presentation/widgets/bang_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class BangChips extends HookConsumerWidget { + static const _maxBangCount = 25; + + final BangData? selectedBang; + final void Function(String trigger)? onSelected; + final void Function(String trigger)? onDeleted; + + const BangChips({ + this.selectedBang, + this.onSelected, + this.onDeleted, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final frequentBangsAsync = ref.watch(frequentBangsProvider); + + return frequentBangsAsync.when( + data: (bangs) { + bangs = bangs.take(_maxBangCount).toList(); + if (selectedBang != null) { + final selectedIndex = + bangs.indexWhere((bang) => bang.trigger == selectedBang?.trigger); + if (selectedIndex < 0) { + bangs = [ + selectedBang!, + ...bangs, + ]; + } else { + bangs = [ + bangs.removeAt(selectedIndex), + ...bangs, + ]; + } + } + + return SizedBox( + height: 48, + child: Row( + children: [ + Expanded( + child: ListView.builder( + scrollDirection: Axis.horizontal, + shrinkWrap: true, + itemCount: bangs.length, + itemBuilder: (context, index) { + final bang = bangs[index]; + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: FilterChip( + selected: bang.trigger == selectedBang?.trigger, + showCheckmark: false, + onSelected: (value) { + if (value) { + onSelected?.call(bang.trigger); + } else { + onDeleted?.call(bang.trigger); + } + }, + onDeleted: () { + onDeleted?.call(bang.trigger); + }, + avatar: BangIcon(bang, iconSize: 20), + label: Text(bang.websiteName), + ), + ); + }, + ), + ), + IconButton( + onPressed: () async { + await context.push(BangRoute().location); + }, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ); + }, + error: (error, stackTrace) => SizedBox.shrink(), + loading: () => SizedBox.shrink(), + ); + } +} diff --git a/app/lib/features/search_browser/presentation/widgets/tabs/search_tab.dart b/app/lib/features/search_browser/presentation/widgets/tabs/search_tab.dart index 85b3045d..291cbf3f 100644 --- a/app/lib/features/search_browser/presentation/widgets/tabs/search_tab.dart +++ b/app/lib/features/search_browser/presentation/widgets/tabs/search_tab.dart @@ -1,9 +1,11 @@ +import 'package:bang_navigator/features/bangs/domain/providers.dart'; +import 'package:bang_navigator/features/bangs/domain/repositories/bang.dart'; +import 'package:bang_navigator/features/bangs/presentation/widgets/bang_icon.dart'; import 'package:bang_navigator/features/kagi/domain/repositories/autosuggest.dart'; import 'package:bang_navigator/features/search_browser/domain/providers.dart'; +import 'package:bang_navigator/features/search_browser/presentation/widgets/bang_chips.dart'; import 'package:bang_navigator/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart'; import 'package:bang_navigator/features/search_browser/presentation/widgets/speech_to_text_button.dart'; -import 'package:bang_navigator/features/search_browser/utils/url_builder.dart' - as uri_builder; import 'package:bang_navigator/features/share_intent/domain/entities/shared_content.dart'; import 'package:bang_navigator/presentation/widgets/autocomplete.dart'; import 'package:flutter/material.dart'; @@ -105,8 +107,16 @@ class SearchTab extends HookConsumerWidget { final formKey = useMemoized(() => GlobalKey()); final focusNode = useFocusNode(); + + final selectedBangAsync = ref.watch(selectedBangDataProvider); + final defaultSearchBangAsync = ref.watch(kagiSearchBangProvider); + + final activeBang = + selectedBangAsync.valueOrNull ?? defaultSearchBangAsync.valueOrNull; + final textController = useTextEditingController(text: sharedContent?.toString()); + final quickAnswer = useListenableSelector( textController, () => textController.text.endsWith('?'), @@ -118,6 +128,28 @@ class SearchTab extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 4), + selectedBangAsync.when( + skipLoadingOnReload: true, + data: (selectedBang) { + return BangChips( + selectedBang: selectedBang, + onSelected: (trigger) { + ref + .read(selectedBangTriggerProvider.notifier) + .setTrigger(trigger); + }, + onDeleted: (trigger) { + if (ref.read(selectedBangTriggerProvider) == trigger) { + ref + .read(selectedBangTriggerProvider.notifier) + .clearTrigger(); + } + }, + ); + }, + error: (error, stackTrace) => SizedBox.shrink(), + loading: () => SizedBox.shrink(), + ), Consumer( builder: (context, ref, child) { final optionsStream = ref.watch(autosuggestRepositoryProvider); @@ -169,7 +201,12 @@ class SearchTab extends HookConsumerWidget { controller: textEditingController, focusNode: focusNode, decoration: InputDecoration( - // border: OutlineInputBorder(), + 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, @@ -190,16 +227,15 @@ class SearchTab extends HookConsumerWidget { onTapOutside: (event) { focusNode.unfocus(); }, - onFieldSubmitted: (value) { - if (formKey.currentState?.validate() ?? false) { - onSubmit( - uri_builder.searchUri( - searchQuery: textController.text, - ), - ); - } + onFieldSubmitted: (_) async { + if (activeBang != null && + (formKey.currentState?.validate() == true)) { + await ref + .read(bangRepositoryProvider.notifier) + .increaseFrequency(activeBang.trigger); - // onFieldSubmitted(); + onSubmit(activeBang.getUrl(textController.text)); + } }, ); }, @@ -226,13 +262,14 @@ class SearchTab extends HookConsumerWidget { SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: () { - if (formKey.currentState?.validate() ?? false) { - onSubmit( - uri_builder.searchUri( - searchQuery: textController.text, - ), - ); + onPressed: () async { + if (activeBang != null && + (formKey.currentState?.validate() == true)) { + await ref + .read(bangRepositoryProvider.notifier) + .increaseFrequency(activeBang.trigger); + + onSubmit(activeBang.getUrl(textController.text)); } }, label: const Text('Search'), diff --git a/app/lib/features/search_browser/utils/url_builder.dart b/app/lib/features/search_browser/utils/url_builder.dart index 7e562076..0a7a5033 100644 --- a/app/lib/features/search_browser/utils/url_builder.dart +++ b/app/lib/features/search_browser/utils/url_builder.dart @@ -36,6 +36,7 @@ Uri summarizerUri({ _baseUrl.replace( pathSegments: [ 'summarizer', + //TODO: check if really necessary 'index.html', ], queryParameters: { @@ -47,8 +48,3 @@ Uri summarizerUri({ _ => null, }, ); - -Uri searchUri({required String searchQuery}) => _baseUrl.replace( - pathSegments: ['search'], - queryParameters: {'q': searchQuery}, - ); diff --git a/app/lib/features/web_view/presentation/widgets/web_view.dart b/app/lib/features/web_view/presentation/widgets/web_view.dart index d51ff0a2..637d7cc5 100644 --- a/app/lib/features/web_view/presentation/widgets/web_view.dart +++ b/app/lib/features/web_view/presentation/widgets/web_view.dart @@ -1,13 +1,12 @@ import 'dart:async'; import 'package:bang_navigator/core/logger.dart'; +import 'package:bang_navigator/features/bangs/domain/providers.dart'; import 'package:bang_navigator/features/chat_archive/domain/entities/chat_entity.dart'; import 'package:bang_navigator/features/chat_archive/domain/repositories/chat_archive.dart'; import 'package:bang_navigator/features/search_browser/domain/entities/modes.dart'; import 'package:bang_navigator/features/search_browser/domain/entities/sheet.dart'; import 'package:bang_navigator/features/search_browser/domain/providers.dart'; -import 'package:bang_navigator/features/search_browser/utils/url_builder.dart' - as uri_builder; import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; import 'package:bang_navigator/features/web_view/domain/entities/web_view_page.dart'; import 'package:bang_navigator/features/web_view/presentation/controllers/switch_new_tab.dart'; @@ -158,13 +157,12 @@ class _WebViewState extends ConsumerState { await widget.page.value.controller?.getSelectedText(); if (selectedText != null && selectedText.isNotEmpty) { - final url = uri_builder.searchUri( - searchQuery: selectedText, - ); + final searchBang = + await ref.read(kagiSearchBangProvider.future); await ref .read(switchNewTabControllerProvider.notifier) - .add(url); + .add(searchBang!.getUrl(selectedText)); } }, ), diff --git a/app/lib/main.dart b/app/lib/main.dart index 492e0f7d..2e530d0c 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,6 +1,7 @@ import 'package:bang_navigator/core/logger.dart'; import 'package:bang_navigator/core/providers.dart'; import 'package:bang_navigator/features/about/data/repositories/package_info_repository.dart'; +import 'package:bang_navigator/features/bangs/domain/repositories/bang.dart'; import 'package:bang_navigator/features/search_browser/domain/services/session.dart'; import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; import 'package:bang_navigator/presentation/hooks/on_initialization.dart'; @@ -40,6 +41,10 @@ void main() async { () async { await ref.read(packageInfoProvider.future); + await ref + .read(bangRepositoryProvider.notifier) + .syncAllBangGroups(); + final settings = await ref.read(settingsRepositoryProvider.future); diff --git a/app/lib/utils/image_helper.dart b/app/lib/utils/image_helper.dart new file mode 100644 index 00000000..2a9582e8 --- /dev/null +++ b/app/lib/utils/image_helper.dart @@ -0,0 +1,12 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +Future isImageValid(Uint8List bytes) async { + try { + final codec = await instantiateImageCodec(bytes, targetWidth: 32); + final frameInfo = await codec.getNextFrame(); + return frameInfo.image.width > 0; + } catch (e) { + return false; + } +} diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc index 38dd0bc6..a35cce61 100644 --- a/app/linux/flutter/generated_plugin_registrant.cc +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -7,12 +7,16 @@ #include "generated_plugin_registrant.h" #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake index 65240e99..2aa89bb0 100644 --- a/app/linux/flutter/generated_plugins.cmake +++ b/app/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + sqlite3_flutter_libs url_launcher_linux ) diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index f7113f8c..f4f0342c 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import package_info_plus import path_provider_foundation import share_plus import shared_preferences_foundation +import sqlite3_flutter_libs import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -20,5 +21,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/app/pubspec.lock b/app/pubspec.lock index fc970520..52780848 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -137,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" checked_yaml: dependency: transitive description: @@ -273,6 +281,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + drift: + dependency: "direct main" + description: + name: drift + sha256: "6acedc562ffeed308049f78fb1906abad3d65714580b6745441ee6d50ec564cd" + url: "https://pub.dev" + source: hosted + version: "2.18.0" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: d9b020736ea85fff1568699ce18b89fabb3f0f042e8a7a05e84a3ec20d39acde + url: "https://pub.dev" + source: hosted + version: "2.18.0" exceptions: dependency: "direct main" description: @@ -644,13 +668,21 @@ packages: source: hosted version: "0.6.7" json_annotation: - dependency: transitive + dependency: "direct main" description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b + url: "https://pub.dev" + source: hosted + version: "6.8.0" lint: dependency: "direct dev" description: @@ -843,6 +875,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" riverpod: dependency: "direct main" description: @@ -1032,6 +1072,30 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: b384f598b813b347c5a7e5ffad82cbaff1bec3d1561af267041e66f6f0899295 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: "9f89a7e7dc36eac2035808427eba1c3fbd79e59c3a22093d8dace6d36b1fe89e" + url: "https://pub.dev" + source: hosted + version: "0.5.23" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: ade9a67fd70d0369329ed3373208de7ebd8662470e8c396fc8d0d60f9acdfc9f + url: "https://pub.dev" + source: hosted + version: "0.36.0" stack_trace: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index ad6150c0..90b79748 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -10,6 +10,7 @@ dependencies: animations: ^2.0.11 collection: ^1.18.0 copy_with_extension: ^5.0.4 + drift: ^2.18.0 exceptions: ^0.6.0 expandable_page_view: ^1.0.17 fast_equatable: ^1.1.0 @@ -28,6 +29,7 @@ dependencies: hooks_riverpod: ^2.5.1 html: ^0.15.4 http: ^1.2.1 + json_annotation: ^4.9.0 logger: ^2.2.0 markdown: ^7.2.2 mime: ^1.0.5 @@ -41,6 +43,8 @@ dependencies: shared_preferences: ^2.2.3 skeletonizer: ^1.1.1 speech_to_text_google_dialog: ^0.0.6 + sqlite3: ^2.4.3 + sqlite3_flutter_libs: ^0.5.23 text_scroll: ^0.2.0 universal_io: ^2.2.2 uri_to_file: ^1.0.0 @@ -52,9 +56,11 @@ dev_dependencies: copy_with_extension_gen: ^5.0.4 custom_lint: ^0.6.4 dependency_validator: ^4.1.0 + drift_dev: ^2.18.0 fast_equatable_lint: ^0.3.0 flutter_launcher_icons: ^0.13.1 go_router_builder: ^2.4.1 + json_serializable: ^6.8.0 lint: ^2.3.0 riverpod_generator: ^2.4.0 riverpod_lint: ^2.3.10 diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc index be054214..6eedea70 100644 --- a/app/windows/flutter/generated_plugin_registrant.cc +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + Sqlite3FlutterLibsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake index 7b6e42f3..983afec1 100644 --- a/app/windows/flutter/generated_plugins.cmake +++ b/app/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows share_plus + sqlite3_flutter_libs url_launcher_windows )