prepare for multiple apps

This commit is contained in:
Fabian Freund
2026-04-06 12:23:11 +02:00
parent bd1600e8dc
commit 5afc323f04
904 changed files with 29 additions and 29 deletions
@@ -0,0 +1,169 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/features/bangs/data/database/daos/bang.drift.dart';
import 'package:weblibre/features/bangs/data/database/database.dart';
import 'package:weblibre/features/bangs/data/database/definitions.drift.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
@DriftAccessor()
class BangDao extends DatabaseAccessor<BangDatabase> with $BangDaoMixin {
BangDao(super.db);
Selectable<Bang> getBangList({Iterable<BangGroup>? groups}) {
final selectable = select(db.bang);
if (groups != null) {
selectable.where((t) => t.group.isInValues(groups));
}
return selectable;
}
SingleSelectable<int> getBangCount({Iterable<BangGroup>? groups}) {
return db.bang.count(
where: groups.mapNotNull(
(groups) =>
(t) => t.group.isInValues(groups),
),
);
}
Future<int> upsertBang(Bang bang) {
return db.bang.insertOne(bang, mode: InsertMode.insertOrReplace);
}
SingleOrNullSelectable<BangData> getBangData(
BangGroup group,
String trigger,
) {
return select(db.bangDataView)
..where((t) => t.group.equalsValue(group) & t.trigger.equals(trigger));
}
Selectable<BangData> getBangDataList({
Iterable<String>? triggers,
Iterable<BangGroup>? groups,
String? domain,
String? category,
String? subCategory,
bool? orderMostFrequentFirst,
}) {
final selectable = select(db.bangDataView);
if (triggers != null) {
selectable.where((t) => t.trigger.isIn(triggers));
}
if (groups != null) {
selectable.where((t) => t.group.isInValues(groups));
}
if (domain != null) {
selectable.where((t) => t.domain.equals(domain));
}
if (category != null) {
selectable.where((t) => t.category.equals(category));
if (subCategory != null) {
selectable.where((t) => t.subCategory.equals(subCategory));
}
}
selectable.orderBy([
if (orderMostFrequentFirst == true) (t) => OrderingTerm.desc(t.frequency),
(t) => OrderingTerm.asc(t.websiteName),
]);
return selectable;
}
Selectable<BangData> getFrequentBangDataList({Iterable<BangGroup>? 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<int> increaseBangFrequency(BangKey key) {
return db.bangFrequency.insertOne(
BangFrequencyCompanion.insert(
trigger: key.trigger,
group: key.group,
frequency: 1,
lastUsed: DateTime.now(),
),
onConflict: DoUpdate(
(old) => BangFrequencyCompanion.custom(
frequency: old.frequency + const Constant(1),
lastUsed: Variable(DateTime.now()),
),
),
);
}
Selectable<BangData> queryBangs(String searchString) {
final ftsQuery = db.buildFtsQuery(searchString);
if (ftsQuery.isNotEmpty) {
return db.definitionsDrift.queryBangs(query: ftsQuery);
} else {
return db.definitionsDrift.queryBangsBasic(
query: db.buildLikeQuery(searchString),
);
}
}
Future<int> addSearchEntry(
BangGroup group,
String trigger,
String searchQuery,
) {
return db.bangHistory.insertOne(
BangHistoryCompanion.insert(
searchQuery: searchQuery,
trigger: trigger,
group: group,
searchDate: DateTime.now(),
),
onConflict: DoUpdate(
target: [db.bangHistory.searchQuery],
(old) => BangHistoryCompanion(
trigger: Value(trigger),
group: Value(group),
searchDate: Value(DateTime.now()),
),
),
);
}
Future<int> removeSearchEntry(String searchQuery) {
return db.bangHistory.deleteWhere((t) => t.searchQuery.equals(searchQuery));
}
}
@@ -0,0 +1,13 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/bangs/data/database/database.dart' as i1;
mixin $BangDaoMixin on i0.DatabaseAccessor<i1.BangDatabase> {
BangDaoManager get managers => BangDaoManager(this);
}
class BangDaoManager {
final $BangDaoMixin _db;
BangDaoManager(this._db);
}
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:weblibre/features/bangs/data/database/daos/sync.drift.dart';
import 'package:weblibre/features/bangs/data/database/database.dart';
import 'package:weblibre/features/bangs/data/database/definitions.drift.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
@DriftAccessor()
class SyncDao extends DatabaseAccessor<BangDatabase> with $SyncDaoMixin {
SyncDao(super.db);
SingleOrNullSelectable<DateTime?> getLastSyncOfGroup(BangGroup group) {
final query = selectOnly(db.bangSync)
..addColumns([db.bangSync.lastSync])
..where(db.bangSync.group.equalsValue(group));
return query.map((row) => row.read(db.bangSync.lastSync));
}
Future<void> upsertLastSyncOfGroup(BangGroup group, DateTime lastSync) {
return db.bangSync.insertOne(
BangSyncCompanion.insert(group: Value(group), lastSync: lastSync),
onConflict: DoUpdate(
(old) => BangSyncCompanion(lastSync: Value(lastSync)),
),
);
}
Future<void> insertBangs(Iterable<Bang> bangs) {
return db.bang.insertAll(bangs);
}
Future<void> replaceBangs(Iterable<Bang> bangs) {
return batch((batch) {
batch.replaceAll(db.bang, bangs);
});
}
Future<int> deleteBangs(BangGroup group, Iterable<String> triggers) {
final statement = delete(db.bang)
..where((t) => t.group.equalsValue(group) & t.trigger.isIn(triggers));
return statement.go();
}
Future<void> syncBangs({
required BangGroup group,
required Iterable<Bang> remoteBangs,
required DateTime syncTime,
}) async {
final remoteBangMap = Map.fromEntries(
remoteBangs.map((e) => MapEntry(e.trigger, e)),
);
final localBangMap = await db.bangDao
.getBangList(groups: [group])
.get()
.then(
(bangs) => Map.fromEntries(bangs.map((e) => MapEntry(e.trigger, e))),
);
final remoteBangTriggers = remoteBangMap.keys.toSet();
final localBangTriggers = localBangMap.keys.toSet();
final removedBangs = localBangTriggers.difference(remoteBangTriggers);
final addedBangs = remoteBangTriggers
.difference(localBangTriggers)
.map((e) => remoteBangMap[e]!);
final changedBangs = remoteBangTriggers
.intersection(localBangTriggers)
.where((e) => remoteBangMap[e] != localBangMap[e])
.map((e) => remoteBangMap[e]!);
await db.transaction(() async {
await deleteBangs(group, removedBangs);
await insertBangs(addedBangs);
await replaceBangs(changedBangs);
await upsertLastSyncOfGroup(group, syncTime);
});
}
}
@@ -0,0 +1,13 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/bangs/data/database/database.dart' as i1;
mixin $SyncDaoMixin on i0.DatabaseAccessor<i1.BangDatabase> {
SyncDaoManager get managers => SyncDaoManager(this);
}
class SyncDaoManager {
final $SyncDaoMixin _db;
SyncDaoManager(this._db);
}
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:drift/internal/versioned_schema.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:flutter/foundation.dart';
import 'package:weblibre/features/bangs/data/database/daos/bang.dart';
import 'package:weblibre/features/bangs/data/database/daos/sync.dart';
import 'package:weblibre/features/bangs/data/database/database.drift.dart';
import 'package:weblibre/features/bangs/data/database/database.steps.dart';
import 'package:weblibre/features/bangs/data/database/definitions.drift.dart';
import 'package:weblibre/features/search/domain/fts_tokenizer.dart';
@DriftDatabase(include: {'definitions.drift'}, daos: [BangDao, SyncDao])
class BangDatabase extends $BangDatabase with PrefixQueryBuilderMixin {
@override
final int schemaVersion = 5;
@override
final int ftsTokenLimit = 6;
@override
final int ftsMinTokenLength = 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
// Following the advice from https://drift.simonbinder.eu/Migrations/api/#general-tips
await customStatement('PRAGMA foreign_keys = OFF');
await transaction(
() => VersionedSchema.runMigrationSteps(
migrator: m,
from: from,
to: to,
steps: _upgrade,
),
);
if (kDebugMode) {
final wrongForeignKeys = await customSelect(
'PRAGMA foreign_key_check',
).get();
assert(
wrongForeignKeys.isEmpty,
'${wrongForeignKeys.map((e) => e.data)}',
);
}
await customStatement('PRAGMA foreign_keys = ON');
},
beforeOpen: (details) async {
if (kDebugMode) {
// This check pulls in a fair amount of code that's not needed
// anywhere else, so we recommend only doing it in debug builds.
await validateDatabaseSchema();
}
if (details.hadUpgrade && details.versionBefore != null) {
await customStatement('PRAGMA foreign_keys = OFF');
if (details.versionBefore! < 3) {
await bang.deleteWhere((t) => t.group.equals(3));
await bangTriggers.deleteWhere((t) => t.group.equals(3));
await bangSync.deleteWhere((t) => t.group.equals(3));
await bangFrequency.deleteWhere((t) => t.group.equals(3));
await bangHistory.deleteWhere((t) => t.group.equals(3));
} else if (details.versionBefore! < 5) {
await bang.deleteWhere((t) => t.group.equals(1));
await bangTriggers.deleteWhere((t) => t.group.equals(1));
await bangSync.deleteWhere((t) => t.group.equals(1));
await bangFrequency.deleteWhere((t) => t.group.equals(1));
await bangHistory.deleteWhere((t) => t.group.equals(1));
await (bang.update()..where((t) => t.group.isBiggerThanValue(0)))
.write(
BangCompanion.custom(group: bang.group - const Constant(1)),
);
await (bangTriggers.update()
..where((t) => t.group.isBiggerThanValue(0)))
.write(
BangTriggersCompanion.custom(
group: bangTriggers.group - const Constant(1),
),
);
await (bangSync.update()..where((t) => t.group.isBiggerThanValue(0)))
.write(
BangSyncCompanion.custom(
group: bangSync.group - const Constant(1),
),
);
await (bangFrequency.update()
..where((t) => t.group.isBiggerThanValue(0)))
.write(
BangFrequencyCompanion.custom(
group: bangFrequency.group - const Constant(1),
),
);
await (bangHistory.update()
..where((t) => t.group.isBiggerThanValue(0)))
.write(
BangHistoryCompanion.custom(
group: bangHistory.group - const Constant(1),
),
);
}
}
await customStatement('PRAGMA foreign_keys = ON');
},
);
BangDatabase(super.e);
static final _upgrade = migrationSteps(
from1To2: (m, schema) async {
//Too many changes, we switch to a new database
},
from2To3: (m, schema) async {
await m.addColumn(schema.bang, schema.bang.searxngApi);
},
from3To4: (m, schema) async {
await m.alterTable(TableMigration(schema.bangHistory));
},
from4To5: (m, schema) async {
await m.addColumn(schema.bang, schema.bang.snapDomain);
},
);
}
@@ -0,0 +1,200 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/bangs/data/database/definitions.drift.dart'
as i1;
import 'package:weblibre/features/bangs/data/database/daos/bang.dart' as i2;
import 'package:weblibre/features/bangs/data/database/database.dart' as i3;
import 'package:weblibre/features/bangs/data/database/daos/sync.dart' as i4;
import 'package:drift/internal/modular.dart' as i5;
import 'package:sqlite3/common.dart' as i6;
abstract class $BangDatabase extends i0.GeneratedDatabase {
$BangDatabase(i0.QueryExecutor e) : super(e);
$BangDatabaseManager get managers => $BangDatabaseManager(this);
late final i1.BangTable bang = i1.BangTable(this);
late final i1.BangTriggers bangTriggers = i1.BangTriggers(this);
late final i1.BangSync bangSync = i1.BangSync(this);
late final i1.BangFrequency bangFrequency = i1.BangFrequency(this);
late final i1.BangHistory bangHistory = i1.BangHistory(this);
late final i1.BangFts bangFts = i1.BangFts(this);
late final i1.BangTriggersFts bangTriggersFts = i1.BangTriggersFts(this);
late final i1.BangDataView bangDataView = i1.BangDataView(this);
late final i2.BangDao bangDao = i2.BangDao(this as i3.BangDatabase);
late final i4.SyncDao syncDao = i4.SyncDao(this as i3.BangDatabase);
i1.DefinitionsDrift get definitionsDrift => i5.ReadDatabaseContainer(
this,
).accessor<i1.DefinitionsDrift>(i1.DefinitionsDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@override
List<i0.DatabaseSchemaEntity> get allSchemaEntities => [
bang,
bangTriggers,
i1.idxBangTriggersLookup,
i1.bangTriggersAfterInsert,
i1.bangTriggersAfterUpdate,
bangSync,
bangFrequency,
bangHistory,
bangFts,
bangTriggersFts,
bangDataView,
i1.bangAfterInsert,
i1.bangAfterDelete,
i1.bangAfterUpdate,
i1.bangTriggersAfterInsertFts,
i1.bangTriggersAfterDeleteFts,
i1.bangTriggersAfterUpdateFts,
];
@override
i0.StreamQueryUpdateRules
get streamUpdateRules => const i0.StreamQueryUpdateRules([
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('bang_triggers', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.insert,
),
result: [i0.TableUpdate('bang_triggers', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.update,
),
result: [
i0.TableUpdate('bang_triggers', kind: i0.UpdateKind.delete),
i0.TableUpdate('bang_triggers', kind: i0.UpdateKind.insert),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('bang_frequency', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('bang_history', kind: i0.UpdateKind.delete)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.insert,
),
result: [i0.TableUpdate('bang_fts', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('bang_fts', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang',
limitUpdateKind: i0.UpdateKind.update,
),
result: [i0.TableUpdate('bang_fts', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang_triggers',
limitUpdateKind: i0.UpdateKind.insert,
),
result: [i0.TableUpdate('bang_triggers_fts', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang_triggers',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('bang_triggers_fts', kind: i0.UpdateKind.insert)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'bang_triggers',
limitUpdateKind: i0.UpdateKind.update,
),
result: [i0.TableUpdate('bang_triggers_fts', kind: i0.UpdateKind.insert)],
),
]);
}
class $BangDatabaseManager {
final $BangDatabase _db;
$BangDatabaseManager(this._db);
i1.$BangTableTableManager get bang =>
i1.$BangTableTableManager(_db, _db.bang);
i1.$BangTriggersTableManager get bangTriggers =>
i1.$BangTriggersTableManager(_db, _db.bangTriggers);
i1.$BangSyncTableManager get bangSync =>
i1.$BangSyncTableManager(_db, _db.bangSync);
i1.$BangFrequencyTableManager get bangFrequency =>
i1.$BangFrequencyTableManager(_db, _db.bangFrequency);
i1.$BangHistoryTableManager get bangHistory =>
i1.$BangHistoryTableManager(_db, _db.bangHistory);
i1.$BangFtsTableManager get bangFts =>
i1.$BangFtsTableManager(_db, _db.bangFts);
i1.$BangTriggersFtsTableManager get bangTriggersFts =>
i1.$BangTriggersFtsTableManager(_db, _db.bangTriggersFts);
}
extension DefineFunctions on i6.CommonDatabase {
void defineFunctions({
required String Function(int, String?) lexoRankNext,
required String Function(int, String?) lexoRankPrevious,
required String Function(String?, String?) lexoRankReorderAfter,
required String Function(String?, String?) lexoRankReorderBefore,
}) {
createFunction(
functionName: 'lexo_rank_next',
argumentCount: const i6.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as int;
final arg1 = args[1] as String?;
return lexoRankNext(arg0, arg1);
},
);
createFunction(
functionName: 'lexo_rank_previous',
argumentCount: const i6.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as int;
final arg1 = args[1] as String?;
return lexoRankPrevious(arg0, arg1);
},
);
createFunction(
functionName: 'lexo_rank_reorder_after',
argumentCount: const i6.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as String?;
final arg1 = args[1] as String?;
return lexoRankReorderAfter(arg0, arg1);
},
);
createFunction(
functionName: 'lexo_rank_reorder_before',
argumentCount: const i6.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as String?;
final arg1 = args[1] as String?;
return lexoRankReorderBefore(arg0, arg1);
},
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,295 @@
import 'package:weblibre/features/bangs/data/database/drift/converters/bang_format.dart';
import 'package:weblibre/features/bangs/data/database/drift/converters/trigger_list.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/search_history_entry.dart';
CREATE TABLE bang (
"trigger" TEXT NOT NULL,
"group" ENUM(BangGroup) NOT NULL,
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()`,
additional_triggers TEXT MAPPED BY `const TriggerListConverter()`,
searxng_api BOOL NOT NULL DEFAULT FALSE,
snap_domain TEXT,
PRIMARY KEY ("trigger", "group")
) WITH Bang;
CREATE TABLE bang_triggers (
"trigger" TEXT NOT NULL,
"group" ENUM(BangGroup) NOT NULL,
additional_trigger TEXT NOT NULL,
PRIMARY KEY ("trigger", "group", additional_trigger),
FOREIGN KEY ("trigger", "group") REFERENCES bang ("trigger", "group") ON DELETE CASCADE
);
CREATE INDEX idx_bang_triggers_lookup ON bang_triggers (additional_trigger, "group");
-- Trigger to populate bang_triggers when inserting a new bang
CREATE TRIGGER bang_triggers_after_insert AFTER INSERT ON bang
WHEN new.additional_triggers IS NOT NULL
BEGIN
INSERT INTO bang_triggers("trigger", "group", additional_trigger)
SELECT
new."trigger",
new."group",
json_each.value
FROM json_each(new.additional_triggers);
END;
-- Trigger to update bang_triggers when updating a bang
CREATE TRIGGER bang_triggers_after_update AFTER UPDATE ON bang BEGIN
-- Delete old additional triggers
DELETE FROM bang_triggers
WHERE "trigger" = old."trigger" AND "group" = old."group";
-- Insert new additional triggers if they exist
INSERT INTO bang_triggers("trigger", "group", additional_trigger)
SELECT
new."trigger",
new."group",
json_each.value
FROM json_each(new.additional_triggers)
WHERE new.additional_triggers IS NOT NULL;
END;
CREATE TABLE bang_sync (
"group" ENUM(BangGroup) PRIMARY KEY NOT NULL,
last_sync DATETIME NOT NULL
);
CREATE TABLE bang_frequency (
"trigger" TEXT NOT NULL,
"group" ENUM(BangGroup) NOT NULL,
frequency INTEGER NOT NULL,
last_used DATETIME NOT NULL,
PRIMARY KEY ("trigger", "group"),
FOREIGN KEY ("trigger", "group") REFERENCES bang ("trigger", "group") ON DELETE CASCADE
);
CREATE TABLE bang_history (
search_query TEXT UNIQUE NOT NULL,
"trigger" TEXT NOT NULL,
"group" ENUM(BangGroup) NOT NULL,
search_date DATETIME NOT NULL,
FOREIGN KEY ("trigger", "group") REFERENCES bang ("trigger", "group") ON DELETE CASCADE
);
CREATE VIRTUAL TABLE bang_fts
USING fts5(
trigger,
website_name,
content=bang,
prefix='2 3'
);
CREATE VIRTUAL TABLE bang_triggers_fts
USING fts5(
additional_trigger,
content=bang_triggers,
prefix='2 3'
);
CREATE VIEW bang_data_view WITH BangData AS
SELECT
b.*,
bf.frequency,
bf.last_used
FROM
bang b
LEFT JOIN
bang_frequency bf ON b."trigger" = bf."trigger" AND b."group" = bf."group";
-- 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;
CREATE TRIGGER bang_triggers_after_insert_fts AFTER INSERT ON bang_triggers BEGIN
INSERT INTO
bang_triggers_fts(rowid, additional_trigger)
VALUES (new.rowid, new.additional_trigger);
END;
CREATE TRIGGER bang_triggers_after_delete_fts AFTER DELETE ON bang_triggers BEGIN
INSERT INTO
bang_triggers_fts(bang_triggers_fts, rowid, additional_trigger)
VALUES('delete', old.rowid, old.additional_trigger);
END;
CREATE TRIGGER bang_triggers_after_update_fts AFTER UPDATE ON bang_triggers BEGIN
INSERT INTO
bang_triggers_fts(bang_triggers_fts, rowid, additional_trigger)
VALUES('delete', old.rowid, old.additional_trigger);
INSERT INTO
bang_triggers_fts(rowid, additional_trigger)
VALUES (new.rowid, new.additional_trigger);
END;
optimizeBangFtsIndex:
INSERT INTO bang_fts(bang_fts) VALUES ('optimize');
optimizeTriggerFtsIndex:
INSERT INTO bang_triggers_fts(bang_triggers_fts) VALUES ('optimize');
queryBangs WITH BangData:
WITH weights AS (
SELECT
10.0 AS "trigger",
8.0 AS additional_trigger,
5.0 AS website_name
),
bang_results AS (
SELECT
b.*,
bf.frequency,
bf.last_used,
bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank
FROM
bang_fts(:query) fts
INNER JOIN
bang b ON b.rowid = fts.rowid
LEFT JOIN
bang_frequency bf ON b."trigger" = bf."trigger" AND b."group" = bf."group"
CROSS JOIN weights
),
trigger_results AS (
SELECT
b.*,
bf.frequency,
bf.last_used,
bm25(bang_triggers_fts, weights.additional_trigger) AS weighted_rank
FROM
bang_triggers_fts(:query) tfts
INNER JOIN
bang_triggers bt ON bt.rowid = tfts.rowid
INNER JOIN
bang b ON b."trigger" = bt."trigger" AND b."group" = bt."group"
LEFT JOIN
bang_frequency bf ON b."trigger" = bf."trigger" AND b."group" = bf."group"
CROSS JOIN weights
),
combined_results AS (
SELECT * FROM bang_results
UNION ALL
SELECT * FROM trigger_results
)
SELECT
*,
MIN(weighted_rank) AS weighted_rank
FROM combined_results
GROUP BY "trigger", "group"
ORDER BY
weighted_rank ASC,
frequency NULLS LAST;
queryBangsBasic WITH BangData:
WITH weights AS (
SELECT
10.0 AS "trigger",
8.0 AS additional_trigger,
5.0 AS website_name
),
bang_results AS (
SELECT
b.*,
bf.frequency,
bf.last_used,
bm25(bang_fts, weights."trigger", weights.website_name) AS weighted_rank
FROM
bang_fts fts
INNER JOIN
bang b ON b.rowid = fts.rowid
LEFT JOIN
bang_frequency bf ON b."trigger" = bf."trigger" AND b."group" = bf."group"
CROSS JOIN weights
WHERE
fts."trigger" LIKE :query OR
fts.website_name LIKE :query
),
trigger_results AS (
SELECT
b.*,
bf.frequency,
bf.last_used,
bm25(bang_triggers_fts, weights.additional_trigger) AS weighted_rank
FROM
bang_triggers_fts tfts
INNER JOIN
bang_triggers bt ON bt.rowid = tfts.rowid
INNER JOIN
bang b ON b."trigger" = bt."trigger" AND b."group" = bt."group"
LEFT JOIN
bang_frequency bf ON b."trigger" = bf."trigger" AND b."group" = bf."group"
CROSS JOIN weights
WHERE
tfts.additional_trigger LIKE :query
),
combined_results AS (
SELECT * FROM bang_results
UNION ALL
SELECT * FROM trigger_results
)
SELECT
*,
MIN(weighted_rank) AS weighted_rank
FROM combined_results
GROUP BY "trigger", "group"
ORDER BY
weighted_rank ASC,
frequency NULLS LAST;
categoriesJson:
WITH categories AS (
SELECT
b.category,
json_group_array(
DISTINCT b.sub_category
ORDER BY b.sub_category
) AS sub_categories
FROM
bang b
WHERE
b.category IS NOT NULL AND
b.sub_category IS NOT NULL
GROUP BY b.category
ORDER BY b.category
)
SELECT
json_group_object(
c.category,
json(c.sub_categories)
) AS categories_json
FROM categories c;
searchHistoryEntries WITH SearchHistoryEntry:
SELECT *
FROM bang_history
ORDER BY search_date DESC
LIMIT :limit;
evictHistoryEntries:
DELETE FROM bang_history
WHERE rowid IN (
SELECT rowid
FROM bang_history
ORDER BY search_date DESC
LIMIT -1 OFFSET :limit
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
class BangFormatConverter extends TypeConverter<Set<BangFormat>?, String?> {
const BangFormatConverter();
@override
Set<BangFormat>? fromSql(String? fromDb) {
if (fromDb == null) {
return null;
}
return Bang.decodeFormat(jsonDecode(fromDb) as List);
}
@override
String? toSql(Set<BangFormat>? value) {
if (value == null) {
return null;
}
return jsonEncode(Bang.encodeFormat(value));
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:nullability/nullability.dart';
class TriggerListConverter extends TypeConverter<Set<String>?, String?> {
const TriggerListConverter();
@override
Set<String>? fromSql(String? fromDb) {
return fromDb.mapNotNull(
(value) => (jsonDecode(value) as List).cast<String>().toSet(),
);
}
@override
String? toSql(Set<String>? value) {
return value.mapNotNull((value) => jsonEncode(value.toList()));
}
}
@@ -0,0 +1,196 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package: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';
import 'package:nullability/nullability.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/bangs/data/database/definitions.drift.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
part 'bang.g.dart';
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,
///When the bang is invoked with no query, open the snap domain (ad) instead of any path given in the template
@JsonValue('open_snap_domain')
openSnapDomain,
}
@JsonSerializable()
@CopyWith()
class Bang with FastEquatable implements Insertable<Bang> {
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<BangFormat>? format;
///Additional triggers that invoke this bang
@JsonKey(name: 'ts')
final Set<String>? additionalTriggers;
///Additional triggers that invoke this bang
@JsonKey(name: 'ad')
final String? snapDomain;
@JsonKey(defaultValue: false)
final bool searxngApi;
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 getDefaultUrl() {
return getTemplateUrl('');
}
Uri getTemplateUrl(String? query) {
final queryEmpty = query.isEmpty;
if (queryEmpty && format?.contains(BangFormat.openSnapDomain) == true) {
if (snapDomain.isNotEmpty) {
return Uri.parse(snapDomain!);
}
}
final url = (!queryEmpty)
? 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);
}
if (queryEmpty && format?.contains(BangFormat.openBasePath) == true) {
template = template.base;
}
return template;
}
static Set<BangFormat> decodeFormat(Iterable input) {
return input.map((e) => $enumDecode(_$BangFormatEnumMap, e)).toSet();
}
static List<String> encodeFormat(Iterable<BangFormat> format) {
return format.map((e) => _$BangFormatEnumMap[e]!).toList();
}
Bang({
required this.websiteName,
required this.domain,
required this.trigger,
required this.urlTemplate,
required this.searxngApi,
this.group,
this.category,
this.subCategory,
this.format,
this.additionalTriggers,
this.snapDomain,
});
factory Bang.fromJson(Map<String, dynamic> json) => _$BangFromJson(json);
Map<String, dynamic> toJson() => _$BangToJson(this);
@override
List<Object?> get hashParameters => [
group,
websiteName,
domain,
trigger,
urlTemplate,
category,
subCategory,
format,
additionalTriggers,
snapDomain,
searxngApi,
];
@override
Map<String, Expression<Object>> 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),
additionalTriggers: Value.absentIfNull(additionalTriggers),
snapDomain: Value.absentIfNull(snapDomain),
).toColumns(nullToAbsent);
}
}
@@ -0,0 +1,214 @@
// 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 searxngApi(bool searxngApi);
Bang group(BangGroup? group);
Bang category(String? category);
Bang subCategory(String? subCategory);
Bang format(Set<BangFormat>? format);
Bang additionalTriggers(Set<String>? additionalTriggers);
Bang snapDomain(String? snapDomain);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `Bang(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// Bang(...).copyWith(id: 12, name: "My name")
/// ```
Bang call({
String websiteName,
String domain,
String trigger,
String urlTemplate,
bool searxngApi,
BangGroup? group,
String? category,
String? subCategory,
Set<BangFormat>? format,
Set<String>? additionalTriggers,
String? snapDomain,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfBang.copyWith(...)` or call `instanceOfBang.copyWith.fieldName(value)` for a single field.
class _$BangCWProxyImpl implements _$BangCWProxy {
const _$BangCWProxyImpl(this._value);
final Bang _value;
@override
Bang websiteName(String websiteName) => call(websiteName: websiteName);
@override
Bang domain(String domain) => call(domain: domain);
@override
Bang trigger(String trigger) => call(trigger: trigger);
@override
Bang urlTemplate(String urlTemplate) => call(urlTemplate: urlTemplate);
@override
Bang searxngApi(bool searxngApi) => call(searxngApi: searxngApi);
@override
Bang group(BangGroup? group) => call(group: group);
@override
Bang category(String? category) => call(category: category);
@override
Bang subCategory(String? subCategory) => call(subCategory: subCategory);
@override
Bang format(Set<BangFormat>? format) => call(format: format);
@override
Bang additionalTriggers(Set<String>? additionalTriggers) =>
call(additionalTriggers: additionalTriggers);
@override
Bang snapDomain(String? snapDomain) => call(snapDomain: snapDomain);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `Bang(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```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? searxngApi = const $CopyWithPlaceholder(),
Object? group = const $CopyWithPlaceholder(),
Object? category = const $CopyWithPlaceholder(),
Object? subCategory = const $CopyWithPlaceholder(),
Object? format = const $CopyWithPlaceholder(),
Object? additionalTriggers = const $CopyWithPlaceholder(),
Object? snapDomain = 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,
searxngApi:
searxngApi == const $CopyWithPlaceholder() || searxngApi == null
? _value.searxngApi
// ignore: cast_nullable_to_non_nullable
: searxngApi as bool,
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<BangFormat>?,
additionalTriggers: additionalTriggers == const $CopyWithPlaceholder()
? _value.additionalTriggers
// ignore: cast_nullable_to_non_nullable
: additionalTriggers as Set<String>?,
snapDomain: snapDomain == const $CopyWithPlaceholder()
? _value.snapDomain
// ignore: cast_nullable_to_non_nullable
: snapDomain as String?,
);
}
}
extension $BangCopyWith on Bang {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfBang.copyWith(...)` or `instanceOfBang.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$BangCWProxy get copyWith => _$BangCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Bang _$BangFromJson(Map<String, dynamic> json) => Bang(
websiteName: json['s'] as String,
domain: json['d'] as String,
trigger: json['t'] as String,
urlTemplate: json['u'] as String,
searxngApi: json['searxngApi'] as bool? ?? false,
category: json['c'] as String?,
subCategory: json['sc'] as String?,
format: (json['fmt'] as List<dynamic>?)
?.map((e) => $enumDecode(_$BangFormatEnumMap, e))
.toSet(),
additionalTriggers: (json['ts'] as List<dynamic>?)
?.map((e) => e as String)
.toSet(),
snapDomain: json['ad'] as String?,
);
Map<String, dynamic> _$BangToJson(Bang instance) => <String, dynamic>{
'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(),
'ts': instance.additionalTriggers?.toList(),
'ad': instance.snapDomain,
'searxngApi': instance.searxngApi,
};
const _$BangFormatEnumMap = {
BangFormat.openBasePath: 'open_base_path',
BangFormat.urlEncodePlaceholder: 'url_encode_placeholder',
BangFormat.urlEncodeSpaceToPlus: 'url_encode_space_to_plus',
BangFormat.openSnapDomain: 'open_snap_domain',
};
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart';
part 'bang_data.g.dart';
@CopyWith(constructor: '_copyWith')
class BangData extends Bang {
final int frequency;
final DateTime? lastUsed;
final BrowserIcon? icon;
@override
BangGroup get group => super.group!;
BangData({
required super.websiteName,
required super.domain,
required super.trigger,
required super.urlTemplate,
required super.group,
required super.searxngApi,
super.category,
super.subCategory,
super.format,
super.additionalTriggers,
super.snapDomain,
int? frequency,
this.lastUsed,
this.icon,
}) : frequency = frequency ?? 0;
//For some reasons including super.group breaks generation of copywith, so we have this one for now
BangData._copyWith({
required super.websiteName,
required super.domain,
required super.trigger,
required super.urlTemplate,
required super.searxngApi,
super.category,
super.subCategory,
super.format,
super.additionalTriggers,
super.snapDomain,
int? frequency,
this.lastUsed,
this.icon,
}) : frequency = frequency ?? 0;
BangKey toKey() => BangKey(group: group, trigger: trigger);
@override
List<Object?> get hashParameters => [
...super.hashParameters,
frequency,
lastUsed,
icon,
];
}
@@ -0,0 +1,195 @@
// 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 searxngApi(bool searxngApi);
BangData category(String? category);
BangData subCategory(String? subCategory);
BangData format(Set<BangFormat>? format);
BangData additionalTriggers(Set<String>? additionalTriggers);
BangData snapDomain(String? snapDomain);
BangData frequency(int? frequency);
BangData lastUsed(DateTime? lastUsed);
BangData icon(BrowserIcon? icon);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `BangData(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// BangData(...).copyWith(id: 12, name: "My name")
/// ```
BangData call({
String websiteName,
String domain,
String trigger,
String urlTemplate,
bool searxngApi,
String? category,
String? subCategory,
Set<BangFormat>? format,
Set<String>? additionalTriggers,
String? snapDomain,
int? frequency,
DateTime? lastUsed,
BrowserIcon? icon,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfBangData.copyWith(...)` or call `instanceOfBangData.copyWith.fieldName(value)` for a single field.
class _$BangDataCWProxyImpl implements _$BangDataCWProxy {
const _$BangDataCWProxyImpl(this._value);
final BangData _value;
@override
BangData websiteName(String websiteName) => call(websiteName: websiteName);
@override
BangData domain(String domain) => call(domain: domain);
@override
BangData trigger(String trigger) => call(trigger: trigger);
@override
BangData urlTemplate(String urlTemplate) => call(urlTemplate: urlTemplate);
@override
BangData searxngApi(bool searxngApi) => call(searxngApi: searxngApi);
@override
BangData category(String? category) => call(category: category);
@override
BangData subCategory(String? subCategory) => call(subCategory: subCategory);
@override
BangData format(Set<BangFormat>? format) => call(format: format);
@override
BangData additionalTriggers(Set<String>? additionalTriggers) =>
call(additionalTriggers: additionalTriggers);
@override
BangData snapDomain(String? snapDomain) => call(snapDomain: snapDomain);
@override
BangData frequency(int? frequency) => call(frequency: frequency);
@override
BangData lastUsed(DateTime? lastUsed) => call(lastUsed: lastUsed);
@override
BangData icon(BrowserIcon? icon) => call(icon: icon);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `BangData(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```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? searxngApi = const $CopyWithPlaceholder(),
Object? category = const $CopyWithPlaceholder(),
Object? subCategory = const $CopyWithPlaceholder(),
Object? format = const $CopyWithPlaceholder(),
Object? additionalTriggers = const $CopyWithPlaceholder(),
Object? snapDomain = const $CopyWithPlaceholder(),
Object? frequency = const $CopyWithPlaceholder(),
Object? lastUsed = const $CopyWithPlaceholder(),
Object? icon = const $CopyWithPlaceholder(),
}) {
return BangData._copyWith(
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,
searxngApi:
searxngApi == const $CopyWithPlaceholder() || searxngApi == null
? _value.searxngApi
// ignore: cast_nullable_to_non_nullable
: searxngApi as bool,
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<BangFormat>?,
additionalTriggers: additionalTriggers == const $CopyWithPlaceholder()
? _value.additionalTriggers
// ignore: cast_nullable_to_non_nullable
: additionalTriggers as Set<String>?,
snapDomain: snapDomain == const $CopyWithPlaceholder()
? _value.snapDomain
// ignore: cast_nullable_to_non_nullable
: snapDomain as String?,
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?,
icon: icon == const $CopyWithPlaceholder()
? _value.icon
// ignore: cast_nullable_to_non_nullable
: icon as BrowserIcon?,
);
}
}
extension $BangDataCopyWith on BangData {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfBangData.copyWith(...)` or `instanceOfBangData.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$BangDataCWProxy get copyWith => _$BangDataCWProxyImpl(this);
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
enum BangGroup {
general(
remote:
'https://raw.githubusercontent.com/FaFre/bangs/main/data/bangs.json',
bundled: 'assets/bangs/bangs.json',
),
kagi(
remote:
'https://raw.githubusercontent.com/FaFre/bangs/main/data/kagi_bangs.json',
bundled: 'assets/bangs/kagi_bangs.json',
),
user(remote: null, bundled: null);
final String? bundled;
final String? remote;
const BangGroup({required this.bundled, required this.remote});
}
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:json_annotation/json_annotation.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
class BangKey {
final String trigger;
final BangGroup group;
const BangKey({required this.group, required this.trigger});
@override
String toString() {
return '${group.name}::$trigger';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BangKey &&
runtimeType == other.runtimeType &&
trigger == other.trigger &&
group == other.group;
@override
int get hashCode => Object.hash(trigger, group);
static BangKey? tryFromString(String key) {
try {
var [group, trigger] = key.split('::');
//Migrate to schema v5
if (group == 'assistant') {
group = BangGroup.kagi.name;
}
return BangKey(
group: BangGroup.values.firstWhere((g) => g.name == group),
trigger: trigger,
);
} catch (e, s) {
logger.w(
'Failed to parse BangKey from string: "$key"',
error: e,
stackTrace: s,
);
return null;
}
}
}
class BangKeyConverter implements JsonConverter<BangKey?, String?> {
const BangKeyConverter();
@override
BangKey? fromJson(String? json) {
return json.mapNotNull((json) => BangKey.tryFromString(json));
}
@override
String? toJson(BangKey? object) {
return object?.toString();
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
class SearchHistoryEntry with FastEquatable {
final String searchQuery;
final String trigger;
final DateTime searchDate;
SearchHistoryEntry({
required this.searchQuery,
required this.trigger,
required this.searchDate,
});
@override
List<Object?> get hashParameters => [searchQuery, trigger, searchDate];
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
import 'package:weblibre/core/database_registry.dart';
import 'package:weblibre/core/filesystem.dart';
import 'package:weblibre/features/bangs/data/database/database.dart';
part 'providers.g.dart';
@Riverpod(keepAlive: true)
BangDatabase bangDatabase(Ref ref) {
final db = BangDatabase(
LazyDatabase(() async {
final file = File(p.join(filesystem.profileDatabasesDir.path, 'bang.db'));
// Also work around limitations on old Android versions
if (Platform.isAndroid) {
await applyWorkaroundToOpenSqlite3OnOldAndroidVersions();
}
return NativeDatabase.createInBackground(file);
}),
);
DatabaseRegistry.instance.register('bang', db);
ref.onDispose(() async {
await db.close();
});
return db;
}
@@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(bangDatabase)
final bangDatabaseProvider = BangDatabaseProvider._();
final class BangDatabaseProvider
extends $FunctionalProvider<BangDatabase, BangDatabase, BangDatabase>
with $Provider<BangDatabase> {
BangDatabaseProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bangDatabaseProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangDatabaseHash();
@$internal
@override
$ProviderElement<BangDatabase> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
BangDatabase create(Ref ref) {
return bangDatabase(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(BangDatabase value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<BangDatabase>(value),
);
}
}
String _$bangDatabaseHash() => r'0369d508def140a32c08c0551cefed57b1ca4b26';
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:exceptions/exceptions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/http_error_handler.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
part 'data_source.g.dart';
@Riverpod(keepAlive: true)
class BangDataSourceService extends _$BangDataSourceService {
@override
void build() {}
Future<Result<List<Bang>>> fetchRemoteBangs(Uri url, BangGroup group) {
return Result.fromAsync(() async {
return await compute((args) async {
final client = http.Client();
try {
final url = Uri.parse(args[0]);
final response = await client
.get(url)
.timeout(const Duration(seconds: 30));
return jsonDecode(utf8.decode(response.bodyBytes)) as List;
} finally {
client.close();
}
}, [url.toString()]).then(
(json) => json.map((e) {
final bang = Bang.fromJson(e as Map<String, dynamic>);
return bang.copyWith.group(group);
}).toList(),
);
}, exceptionHandler: handleHttpError);
}
Future<DateTime> getBundledBangDate(String path) async {
final content = await rootBundle.loadString(path);
return DateTime.parse(content.trim()).toLocal();
}
Future<Result<List<Bang>>> getBundledBangs(String path, BangGroup? group) {
return Result.fromAsync(() async {
final content = await rootBundle.loadString(path);
final json = jsonDecode(content) as List;
return json.map((e) {
var bang = Bang.fromJson(e as Map<String, dynamic>);
if (group != null) {
bang = bang.copyWith.group(group);
}
return bang;
}).toList();
});
}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'data_source.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BangDataSourceService)
final bangDataSourceServiceProvider = BangDataSourceServiceProvider._();
final class BangDataSourceServiceProvider
extends $NotifierProvider<BangDataSourceService, void> {
BangDataSourceServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bangDataSourceServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangDataSourceServiceHash();
@$internal
@override
BangDataSourceService create() => BangDataSourceService();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$bangDataSourceServiceHash() =>
r'b1bd96bbd834de0f71af86d7f791d367d0a837be';
abstract class _$BangDataSourceService extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/data/models/search_history_entry.dart';
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
import 'package:weblibre/features/bangs/domain/repositories/sync.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'bangs.g.dart';
@Riverpod(keepAlive: true)
Stream<BangData?> defaultSearchBangData(Ref ref) {
final key = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.defaultSearchProvider,
),
);
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchBang(key);
}
@Riverpod()
Stream<BangData?> bangData(Ref ref, BangKey key) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchBang(key);
}
@Riverpod()
Stream<Map<String, List<String>>> bangCategories(Ref ref) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchCategories();
}
@Riverpod()
Stream<List<BangData>> bangList(
Ref ref, {
List<String>? triggers,
List<BangGroup>? groups,
String? domain,
({String category, String? subCategory})? categoryFilter,
bool? orderMostFrequentFirst,
}) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchBangs(
triggers: triggers,
groups: groups,
domain: domain,
categoryFilter: categoryFilter,
orderMostFrequentFirst: orderMostFrequentFirst,
);
}
@Riverpod()
Stream<List<BangData>> frequentBangList(Ref ref) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchFrequentBangs();
}
@Riverpod()
Stream<List<SearchHistoryEntry>> searchHistory(Ref ref) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
final maxSearchHistoryEntries = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.maxSearchHistoryEntries,
),
);
return repository.watchSearchHistory(limit: maxSearchHistoryEntries);
}
@Riverpod()
Stream<DateTime?> lastSyncOfGroup(Ref ref, BangGroup group) {
final repository = ref.watch(bangSyncRepositoryProvider.notifier);
return repository.watchLastSyncOfGroup(group);
}
@Riverpod()
Stream<int> bangCountOfGroup(Ref ref, BangGroup group) {
final repository = ref.watch(bangDataRepositoryProvider.notifier);
return repository.watchBangCount(group);
}
@@ -0,0 +1,496 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bangs.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(defaultSearchBangData)
final defaultSearchBangDataProvider = DefaultSearchBangDataProvider._();
final class DefaultSearchBangDataProvider
extends
$FunctionalProvider<AsyncValue<BangData?>, BangData?, Stream<BangData?>>
with $FutureModifier<BangData?>, $StreamProvider<BangData?> {
DefaultSearchBangDataProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'defaultSearchBangDataProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$defaultSearchBangDataHash();
@$internal
@override
$StreamProviderElement<BangData?> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<BangData?> create(Ref ref) {
return defaultSearchBangData(ref);
}
}
String _$defaultSearchBangDataHash() =>
r'5f43b8989219cf3cb2f5ca65df351b6cb100427f';
@ProviderFor(bangData)
final bangDataProvider = BangDataFamily._();
final class BangDataProvider
extends
$FunctionalProvider<AsyncValue<BangData?>, BangData?, Stream<BangData?>>
with $FutureModifier<BangData?>, $StreamProvider<BangData?> {
BangDataProvider._({
required BangDataFamily super.from,
required BangKey super.argument,
}) : super(
retry: null,
name: r'bangDataProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangDataHash();
@override
String toString() {
return r'bangDataProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<BangData?> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<BangData?> create(Ref ref) {
final argument = this.argument as BangKey;
return bangData(ref, argument);
}
@override
bool operator ==(Object other) {
return other is BangDataProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$bangDataHash() => r'bd9f5ec8b29aab74620a9b5a4246cb7e3b2fd377';
final class BangDataFamily extends $Family
with $FunctionalFamilyOverride<Stream<BangData?>, BangKey> {
BangDataFamily._()
: super(
retry: null,
name: r'bangDataProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
BangDataProvider call(BangKey key) =>
BangDataProvider._(argument: key, from: this);
@override
String toString() => r'bangDataProvider';
}
@ProviderFor(bangCategories)
final bangCategoriesProvider = BangCategoriesProvider._();
final class BangCategoriesProvider
extends
$FunctionalProvider<
AsyncValue<Map<String, List<String>>>,
Map<String, List<String>>,
Stream<Map<String, List<String>>>
>
with
$FutureModifier<Map<String, List<String>>>,
$StreamProvider<Map<String, List<String>>> {
BangCategoriesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bangCategoriesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangCategoriesHash();
@$internal
@override
$StreamProviderElement<Map<String, List<String>>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<Map<String, List<String>>> create(Ref ref) {
return bangCategories(ref);
}
}
String _$bangCategoriesHash() => r'947fcfd2dffcc7f585c6ed7379d319f4fe72293a';
@ProviderFor(bangList)
final bangListProvider = BangListFamily._();
final class BangListProvider
extends
$FunctionalProvider<
AsyncValue<List<BangData>>,
List<BangData>,
Stream<List<BangData>>
>
with $FutureModifier<List<BangData>>, $StreamProvider<List<BangData>> {
BangListProvider._({
required BangListFamily super.from,
required ({
List<String>? triggers,
List<BangGroup>? groups,
String? domain,
({String category, String? subCategory})? categoryFilter,
bool? orderMostFrequentFirst,
})
super.argument,
}) : super(
retry: null,
name: r'bangListProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangListHash();
@override
String toString() {
return r'bangListProvider'
''
'$argument';
}
@$internal
@override
$StreamProviderElement<List<BangData>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<BangData>> create(Ref ref) {
final argument =
this.argument
as ({
List<String>? triggers,
List<BangGroup>? groups,
String? domain,
({String category, String? subCategory})? categoryFilter,
bool? orderMostFrequentFirst,
});
return bangList(
ref,
triggers: argument.triggers,
groups: argument.groups,
domain: argument.domain,
categoryFilter: argument.categoryFilter,
orderMostFrequentFirst: argument.orderMostFrequentFirst,
);
}
@override
bool operator ==(Object other) {
return other is BangListProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$bangListHash() => r'd1e0bb9fa4f523ce516e075c0d149bf7803ebb2b';
final class BangListFamily extends $Family
with
$FunctionalFamilyOverride<
Stream<List<BangData>>,
({
List<String>? triggers,
List<BangGroup>? groups,
String? domain,
({String category, String? subCategory})? categoryFilter,
bool? orderMostFrequentFirst,
})
> {
BangListFamily._()
: super(
retry: null,
name: r'bangListProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
BangListProvider call({
List<String>? triggers,
List<BangGroup>? groups,
String? domain,
({String category, String? subCategory})? categoryFilter,
bool? orderMostFrequentFirst,
}) => BangListProvider._(
argument: (
triggers: triggers,
groups: groups,
domain: domain,
categoryFilter: categoryFilter,
orderMostFrequentFirst: orderMostFrequentFirst,
),
from: this,
);
@override
String toString() => r'bangListProvider';
}
@ProviderFor(frequentBangList)
final frequentBangListProvider = FrequentBangListProvider._();
final class FrequentBangListProvider
extends
$FunctionalProvider<
AsyncValue<List<BangData>>,
List<BangData>,
Stream<List<BangData>>
>
with $FutureModifier<List<BangData>>, $StreamProvider<List<BangData>> {
FrequentBangListProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'frequentBangListProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$frequentBangListHash();
@$internal
@override
$StreamProviderElement<List<BangData>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<BangData>> create(Ref ref) {
return frequentBangList(ref);
}
}
String _$frequentBangListHash() => r'2c1ecb7e9416772fc1c32d01d767e1eb4f865975';
@ProviderFor(searchHistory)
final searchHistoryProvider = SearchHistoryProvider._();
final class SearchHistoryProvider
extends
$FunctionalProvider<
AsyncValue<List<SearchHistoryEntry>>,
List<SearchHistoryEntry>,
Stream<List<SearchHistoryEntry>>
>
with
$FutureModifier<List<SearchHistoryEntry>>,
$StreamProvider<List<SearchHistoryEntry>> {
SearchHistoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'searchHistoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$searchHistoryHash();
@$internal
@override
$StreamProviderElement<List<SearchHistoryEntry>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<SearchHistoryEntry>> create(Ref ref) {
return searchHistory(ref);
}
}
String _$searchHistoryHash() => r'5f9508a6b286bfcd1b641bd429de46ad052a3dfe';
@ProviderFor(lastSyncOfGroup)
final lastSyncOfGroupProvider = LastSyncOfGroupFamily._();
final class LastSyncOfGroupProvider
extends
$FunctionalProvider<AsyncValue<DateTime?>, DateTime?, Stream<DateTime?>>
with $FutureModifier<DateTime?>, $StreamProvider<DateTime?> {
LastSyncOfGroupProvider._({
required LastSyncOfGroupFamily super.from,
required BangGroup super.argument,
}) : super(
retry: null,
name: r'lastSyncOfGroupProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$lastSyncOfGroupHash();
@override
String toString() {
return r'lastSyncOfGroupProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<DateTime?> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<DateTime?> create(Ref ref) {
final argument = this.argument as BangGroup;
return lastSyncOfGroup(ref, argument);
}
@override
bool operator ==(Object other) {
return other is LastSyncOfGroupProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$lastSyncOfGroupHash() => r'23d07f3132ba9bb35a31f74e3a69698d31d4c569';
final class LastSyncOfGroupFamily extends $Family
with $FunctionalFamilyOverride<Stream<DateTime?>, BangGroup> {
LastSyncOfGroupFamily._()
: super(
retry: null,
name: r'lastSyncOfGroupProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
LastSyncOfGroupProvider call(BangGroup group) =>
LastSyncOfGroupProvider._(argument: group, from: this);
@override
String toString() => r'lastSyncOfGroupProvider';
}
@ProviderFor(bangCountOfGroup)
final bangCountOfGroupProvider = BangCountOfGroupFamily._();
final class BangCountOfGroupProvider
extends $FunctionalProvider<AsyncValue<int>, int, Stream<int>>
with $FutureModifier<int>, $StreamProvider<int> {
BangCountOfGroupProvider._({
required BangCountOfGroupFamily super.from,
required BangGroup super.argument,
}) : super(
retry: null,
name: r'bangCountOfGroupProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangCountOfGroupHash();
@override
String toString() {
return r'bangCountOfGroupProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<int> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<int> create(Ref ref) {
final argument = this.argument as BangGroup;
return bangCountOfGroup(ref, argument);
}
@override
bool operator ==(Object other) {
return other is BangCountOfGroupProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$bangCountOfGroupHash() => r'211ffcd7f49b637a7953f913dc5eafda344423f3';
final class BangCountOfGroupFamily extends $Family
with $FunctionalFamilyOverride<Stream<int>, BangGroup> {
BangCountOfGroupFamily._()
: super(
retry: null,
name: r'bangCountOfGroupProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
BangCountOfGroupProvider call(BangGroup group) =>
BangCountOfGroupProvider._(argument: group, from: this);
@override
String toString() => r'bangCountOfGroupProvider';
}
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/providers.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'search.g.dart';
@Riverpod()
class BangSearch extends _$BangSearch {
late StreamController<List<BangData>> _streamController;
Future<Uri> triggerBangSearch(BangData bang, String searchQuery) async {
final bangDataNotifier = ref.read(bangDataRepositoryProvider.notifier);
final settings = ref.read(generalSettingsWithDefaultsProvider);
await bangDataNotifier.increaseFrequency(bang.toKey());
await bangDataNotifier.addSearchEntry(
bang.group,
bang.trigger,
searchQuery,
maxEntryCount: settings.maxSearchHistoryEntries,
);
return bang.getTemplateUrl(searchQuery);
}
Future<void> search(String input) async {
if (input.isNotEmpty) {
await ref.read(bangDatabaseProvider).bangDao.queryBangs(input).get().then(
(value) {
if (!_streamController.isClosed) {
_streamController.add(value);
}
},
);
}
}
@override
Stream<List<BangData>> build() {
_streamController = StreamController();
// Emit initial empty list so UI doesn't show loading state
_streamController.add([]);
ref.onDispose(() async {
await _streamController.close();
});
return _streamController.stream;
}
}
@Riverpod()
class SeamlessBang extends _$SeamlessBang {
bool _hasSearch = false;
void search(String input) {
if (input.isNotEmpty) {
if (!_hasSearch) {
_hasSearch = true;
ref.invalidateSelf();
}
//Don't block
unawaited(ref.read(bangSearchProvider.notifier).search(input));
} else if (_hasSearch) {
_hasSearch = false;
ref.invalidateSelf();
}
}
@override
AsyncValue<List<BangData>> build() {
return _hasSearch
? ref.watch(bangSearchProvider)
: ref.watch(frequentBangListProvider);
}
}
@@ -0,0 +1,111 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BangSearch)
final bangSearchProvider = BangSearchProvider._();
final class BangSearchProvider
extends $StreamNotifierProvider<BangSearch, List<BangData>> {
BangSearchProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bangSearchProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangSearchHash();
@$internal
@override
BangSearch create() => BangSearch();
}
String _$bangSearchHash() => r'feed24edfe703b0697f4a855be9c7359c456b0f2';
abstract class _$BangSearch extends $StreamNotifier<List<BangData>> {
Stream<List<BangData>> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<List<BangData>>, List<BangData>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<BangData>>, List<BangData>>,
AsyncValue<List<BangData>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(SeamlessBang)
final seamlessBangProvider = SeamlessBangProvider._();
final class SeamlessBangProvider
extends $NotifierProvider<SeamlessBang, AsyncValue<List<BangData>>> {
SeamlessBangProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'seamlessBangProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$seamlessBangHash();
@$internal
@override
SeamlessBang create() => SeamlessBang();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<List<BangData>> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<List<BangData>>>(value),
);
}
}
String _$seamlessBangHash() => r'8bd7a2cbe4c302ae08f85167290666a7437f8b9b';
abstract class _$SeamlessBang extends $Notifier<AsyncValue<List<BangData>>> {
AsyncValue<List<BangData>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<AsyncValue<List<BangData>>, AsyncValue<List<BangData>>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<List<BangData>>,
AsyncValue<List<BangData>>
>,
AsyncValue<List<BangData>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,167 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/data/models/search_history_entry.dart';
import 'package:weblibre/features/bangs/data/providers.dart';
part 'data.g.dart';
@Riverpod(keepAlive: true)
class BangDataRepository extends _$BangDataRepository {
@override
void build() {}
Stream<BangData?> watchBang(BangKey? key) {
if (key != null) {
return ref
.read(bangDatabaseProvider)
.bangDao
.getBangData(key.group, key.trigger)
.watchSingleOrNull();
} else {
return Stream.value(null);
}
}
Stream<Map<String, List<String>>> watchCategories() {
return ref
.read(bangDatabaseProvider)
.definitionsDrift
.categoriesJson()
.watchSingle()
.map((json) {
final decoded = jsonDecode(json) as Map<String, dynamic>;
return decoded.map(
(key, value) => MapEntry(key, (value as List<dynamic>).cast()),
);
});
}
Stream<int> watchBangCount(BangGroup group) {
return ref
.read(bangDatabaseProvider)
.bangDao
.getBangCount(groups: [group])
.watchSingle();
}
Stream<List<BangData>> watchBangs({
Iterable<String>? triggers,
Iterable<BangGroup>? groups,
String? domain,
({String category, String? subCategory})? categoryFilter,
bool? orderMostFrequentFirst,
}) {
return ref
.read(bangDatabaseProvider)
.bangDao
.getBangDataList(
triggers: triggers,
groups: groups,
domain: domain,
category: categoryFilter?.category,
subCategory: categoryFilter?.subCategory,
orderMostFrequentFirst: orderMostFrequentFirst,
)
.watch();
}
Stream<List<BangData>> watchFrequentBangs({Iterable<BangGroup>? groups}) {
return ref
.read(bangDatabaseProvider)
.bangDao
.getFrequentBangDataList(groups: groups)
.watch();
}
Stream<List<SearchHistoryEntry>> watchSearchHistory({required int limit}) {
return ref
.read(bangDatabaseProvider)
.definitionsDrift
.searchHistoryEntries(limit: limit)
.watch();
}
Future<void> increaseFrequency(BangKey key) {
return ref.read(bangDatabaseProvider).bangDao.increaseBangFrequency(key);
}
Future<void> addSearchEntry(
BangGroup group,
String trigger,
String searchQuery, {
required int maxEntryCount,
}) async {
// Skip capturing history if maxEntryCount is 0
if (maxEntryCount <= 0) {
return;
}
final db = ref.read(bangDatabaseProvider);
//Pack in a transaction to bundle rebuilds of watch() queries
return db.transaction(() async {
await db.bangDao.addSearchEntry(group, trigger, searchQuery);
await db.definitionsDrift.evictHistoryEntries(limit: maxEntryCount);
});
}
Future<void> removeSearchEntry(String searchQuery) {
return ref
.read(bangDatabaseProvider)
.bangDao
.removeSearchEntry(searchQuery);
}
Future<int> resetFrequencies() {
return ref.read(bangDatabaseProvider).bangFrequency.deleteAll();
}
Future<int> resetFrequency(String trigger) {
return ref
.read(bangDatabaseProvider)
.bangFrequency
.deleteWhere((t) => t.trigger.equals(trigger));
}
Future<BangData?> getBang(BangKey key) {
return ref
.read(bangDatabaseProvider)
.bangDao
.getBangData(key.group, key.trigger)
.getSingleOrNull();
}
Future<void> upsertBang(Bang bang) {
return ref.read(bangDatabaseProvider).bangDao.upsertBang(bang);
}
Future<void> deleteBang(BangKey key) {
return ref.read(bangDatabaseProvider).syncDao.deleteBangs(key.group, [
key.trigger,
]);
}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'data.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BangDataRepository)
final bangDataRepositoryProvider = BangDataRepositoryProvider._();
final class BangDataRepositoryProvider
extends $NotifierProvider<BangDataRepository, void> {
BangDataRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bangDataRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangDataRepositoryHash();
@$internal
@override
BangDataRepository create() => BangDataRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$bangDataRepositoryHash() =>
r'c562ef10d75ca6dee13805f84d2491cf2a89aaac';
abstract class _$BangDataRepository extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:exceptions/exceptions.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/bangs/data/database/database.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/providers.dart';
import 'package:weblibre/features/bangs/data/services/data_source.dart';
part 'sync.g.dart';
@Riverpod(keepAlive: true)
class BangSyncRepository extends _$BangSyncRepository {
static Future<Result<void>> _fetchAndSyncRemote({
required BangDataSourceService sourceService,
required BangDatabase db,
required Uri url,
required BangGroup group,
required Duration? syncInterval,
}) async {
if (syncInterval != null) {
final lastSync = await db.syncDao
.getLastSyncOfGroup(group)
.getSingleOrNull();
if (lastSync != null &&
DateTime.now().difference(lastSync) < syncInterval) {
return Result.success(null);
}
}
final result = await sourceService.fetchRemoteBangs(url, group);
return result.flatMapAsync((remoteBangs) async {
await db.syncDao.syncBangs(
group: group,
remoteBangs: remoteBangs,
syncTime: DateTime.now(),
);
await db.definitionsDrift.optimizeBangFtsIndex();
await db.definitionsDrift.optimizeTriggerFtsIndex();
});
}
static Future<Result<void>> _fetchAndSyncBundled({
required BangDataSourceService sourceService,
required BangDatabase db,
required BangGroup group,
}) async {
if (group.bundled == null) {
return Result.failure(
const ErrorMessage(source: 'BangSync', message: 'Not bundled'),
);
}
if (group.remote == null) {
return Result.failure(
const ErrorMessage(source: 'BangSync', message: 'No remote source'),
);
}
final lastSync = await db.syncDao
.getLastSyncOfGroup(group)
.getSingleOrNull();
final sourceDate = await sourceService.getBundledBangDate(
'assets/bangs/last_sync.txt',
);
if (lastSync != null &&
(sourceDate == lastSync ||
sourceDate.difference(lastSync).isNegative)) {
return Result.success(null);
}
final result = await sourceService.getBundledBangs(group.bundled!, group);
return result.flatMapAsync((remoteBangs) async {
await db.syncDao.syncBangs(
group: group,
remoteBangs: remoteBangs,
syncTime: sourceDate,
);
await db.definitionsDrift.optimizeBangFtsIndex();
await db.definitionsDrift.optimizeTriggerFtsIndex();
});
}
Future<Result<void>> syncRemoteBangGroup(
BangGroup group,
Duration? syncInterval,
) async {
try {
return Result.success(
await ref
.read(bangDatabaseProvider)
.computeWithDatabase(
connect: BangDatabase.new,
computation: (db) async {
final ref = ProviderContainer();
final result = await _fetchAndSyncRemote(
sourceService: ref.read(
bangDataSourceServiceProvider.notifier,
),
db: db,
url: Uri.parse(group.remote!),
group: group,
syncInterval: syncInterval,
);
//Throw if necessary
return result.value;
},
),
);
} catch (e) {
return Result.failure(
ErrorMessage(
message: "Failed to sync Bangs (${group.name})",
source: 'BangSync',
details: e,
),
);
}
}
Future<Result<void>> syncBundledBangGroup(BangGroup group) async {
try {
final db = ref.read(bangDatabaseProvider);
final result = await _fetchAndSyncBundled(
sourceService: ref.read(bangDataSourceServiceProvider.notifier),
db: db,
group: group,
);
//Throw if necessary
return result;
} catch (e) {
return Result.failure(
ErrorMessage(
message: "Failed to sync Bangs (${group.name})",
source: 'BangSync',
details: e,
),
);
}
}
Stream<DateTime?> watchLastSyncOfGroup(BangGroup group) {
return ref
.read(bangDatabaseProvider)
.syncDao
.getLastSyncOfGroup(group)
.watchSingleOrNull();
}
Future<Map<BangGroup, Result<void>>> syncBundledBangGroups({
Set<BangGroup>? groups,
}) async {
//Default to all sources
groups ??= BangGroup.values.where((e) => e.bundled != null).toSet();
//Run isolated operations
final futures = groups.map(
(source) => syncBundledBangGroup(
source,
).then((result) => MapEntry(source, result)),
);
return Map.fromEntries(await Future.wait(futures));
}
@override
void build() {}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sync.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(BangSyncRepository)
final bangSyncRepositoryProvider = BangSyncRepositoryProvider._();
final class BangSyncRepositoryProvider
extends $NotifierProvider<BangSyncRepository, void> {
BangSyncRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'bangSyncRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$bangSyncRepositoryHash();
@$internal
@override
BangSyncRepository create() => BangSyncRepository();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$bangSyncRepositoryHash() =>
r'1347dcbd03f6a1a4fa3bbbae5d9302098d260c50';
abstract class _$BangSyncRepository extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/bangs/data/providers.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'search_history_cleanup.g.dart';
/// Service that listens to maxSearchHistoryEntries setting changes
/// and cleans up search history when the limit is reduced.
@Riverpod(keepAlive: true)
class SearchHistoryCleanupService extends _$SearchHistoryCleanupService {
@override
void build() {
ref.listen(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.maxSearchHistoryEntries,
),
(previous, next) async {
// Only cleanup when limit is reduced (including to 0)
if (previous != null && next < previous) {
final db = ref.read(bangDatabaseProvider);
await db.definitionsDrift.evictHistoryEntries(limit: next);
}
},
);
}
}
@@ -0,0 +1,73 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_history_cleanup.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Service that listens to maxSearchHistoryEntries setting changes
/// and cleans up search history when the limit is reduced.
@ProviderFor(SearchHistoryCleanupService)
final searchHistoryCleanupServiceProvider =
SearchHistoryCleanupServiceProvider._();
/// Service that listens to maxSearchHistoryEntries setting changes
/// and cleans up search history when the limit is reduced.
final class SearchHistoryCleanupServiceProvider
extends $NotifierProvider<SearchHistoryCleanupService, void> {
/// Service that listens to maxSearchHistoryEntries setting changes
/// and cleans up search history when the limit is reduced.
SearchHistoryCleanupServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'searchHistoryCleanupServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$searchHistoryCleanupServiceHash();
@$internal
@override
SearchHistoryCleanupService create() => SearchHistoryCleanupService();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$searchHistoryCleanupServiceHash() =>
r'4e84ab50fa2ad55c615bb63a667c95383715570b';
/// Service that listens to maxSearchHistoryEntries setting changes
/// and cleans up search history when the limit is reduced.
abstract class _$SearchHistoryCleanupService extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
/// Dialog to confirm bang deletion.
/// Returns true if user confirms deletion, false if cancelled, null if dismissed.
Future<bool?> showDeleteBangDialog(BuildContext context) {
return showDialog<bool?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
icon: const Icon(Icons.warning),
title: const Text('Delete Bang'),
content: const Text('Are you sure you want to delete this Bang?'),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context, false);
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context, true);
},
child: const Text('Delete'),
),
],
);
},
);
}
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class BangCategoriesScreen extends HookConsumerWidget {
const BangCategoriesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final categoriesAsync = ref.watch(bangCategoriesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Bang Categories'),
actions: [
IconButton(
onPressed: () async {
final trigger = await const BangSearchRoute().push<BangKey?>(
context,
);
if (trigger != null) {
ref
.read(selectedBangTriggerProvider().notifier)
.setTrigger(trigger);
}
},
icon: const Icon(Icons.search),
),
],
),
body: SafeArea(
child: categoriesAsync.when(
skipLoadingOnReload: true,
data: (categories) {
return FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return SingleChildScrollView(
controller: controller,
child: HookBuilder(
builder: (context) {
final expanded = useState(<String>{});
return ExpansionPanelList(
expansionCallback: (index, expand) {
final key = categories.keys.elementAt(index);
if (!expanded.value.contains(key)) {
expanded.value = {...expanded.value, key};
} else {
expanded.value = {...expanded.value}..remove(key);
}
},
children: categories.entries
.map(
(category) => ExpansionPanel(
canTapOnHeader: true,
isExpanded: expanded.value.contains(
category.key,
),
headerBuilder: (context, isExpanded) =>
ListTile(title: Text(category.key)),
body: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: category.value
.map(
(subCategory) => ListTile(
title: Text(subCategory),
onTap: () async {
await BangSubCategoryRoute(
category: category.key,
subCategory: subCategory,
).push(context);
},
),
)
.toList(),
),
),
),
)
.toList(),
);
},
),
);
},
);
},
error: (error, stackTrace) => Center(
child: FailureWidget(
title: 'Failed to load Bang Categories',
exception: error,
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/presentation/widgets/bang_details.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class BangCategoryScreen extends HookConsumerWidget {
final String? category;
final String? subCategory;
const BangCategoryScreen({this.category, this.subCategory, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bangsAsync = ref.watch(
bangListProvider(
categoryFilter: category.mapNotNull(
(category) => (category: category, subCategory: subCategory),
),
),
);
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar.medium(title: Text('$category: $subCategory')),
bangsAsync.when(
skipLoadingOnReload: true,
data: (bangs) {
return SliverList.builder(
itemCount: bangs.length,
itemBuilder: (context, index) {
final bang = bangs[index];
return BangDetails(
bang,
onTap: () {
ref
.read(selectedBangTriggerProvider().notifier)
.setTrigger(bang.toKey());
final settings = ref.read(
generalSettingsWithDefaultsProvider,
);
SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.effectiveDefaultCreateTabType,
).go(context);
},
);
},
);
},
error: (error, stackTrace) => SliverToBoxAdapter(
child: Center(
child: FailureWidget(
title: 'Failed to load Bangs',
exception: error,
),
),
),
loading: () => const SliverToBoxAdapter(
child: Center(child: CircularProgressIndicator()),
),
),
],
),
);
}
}
@@ -0,0 +1,346 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.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';
import 'package:nullability/nullability.dart';
import 'package:weblibre/features/bangs/data/models/bang.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
import 'package:weblibre/features/bangs/presentation/dialogs/delete_bang_dialog.dart';
import 'package:weblibre/utils/form_validators.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class EditBangScreen extends HookConsumerWidget {
final Bang? initialBang;
const EditBangScreen({super.key, required this.initialBang});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final categories = ref.watch(
bangCategoriesProvider.select((value) => value.value),
);
final nameTextController = useTextEditingController(
text: initialBang?.websiteName,
);
final triggerTextController = useTextEditingController(
text: initialBang?.trigger,
);
final urlTextController = useTextEditingController(
text: initialBang?.urlTemplate,
);
final category = useState(initialBang?.category);
final subCategory = useState(initialBang?.subCategory);
final formatFlags = useState(initialBang?.format);
return Scaffold(
appBar: AppBar(
title: Text(initialBang == null ? 'New Bang' : 'Edit Bang'),
actions: [
IconButton(
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
final existingBang = await ref
.read(bangDataRepositoryProvider.notifier)
.getBang(
BangKey(
group: BangGroup.user,
trigger: triggerTextController.text,
),
);
if ((initialBang == null && existingBang != null) ||
(initialBang != null &&
existingBang != null &&
existingBang.trigger != initialBang!.trigger)) {
if (context.mounted) {
ui_helper.showErrorMessage(
context,
'A Bang with Trigger "${triggerTextController.text}" does already exist',
);
}
return;
}
final uri = parseValidatedUrl(
urlTextController.text,
eagerParsing: false,
onlyHttpProtocol: true,
);
if (uri == null) {
return;
}
final bang = Bang(
group: BangGroup.user,
trigger: triggerTextController.text,
websiteName: nameTextController.text,
domain: uri.host,
urlTemplate: urlTextController.text,
searxngApi: false,
category: category.value,
subCategory: subCategory.value,
additionalTriggers: initialBang?.additionalTriggers,
snapDomain: initialBang?.snapDomain,
format: formatFlags.value.isNotEmpty
? formatFlags.value
: null,
);
if (initialBang != null &&
initialBang!.trigger != bang.trigger) {
await ref
.read(bangDataRepositoryProvider.notifier)
.deleteBang(
BangKey(
group: BangGroup.user,
trigger: initialBang!.trigger,
),
);
}
await ref
.read(bangDataRepositoryProvider.notifier)
.upsertBang(bang);
if (context.mounted) {
context.pop();
}
}
},
icon: const Icon(Icons.check),
),
],
),
body: SafeArea(
child: Form(
key: formKey,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: ListView(
children: [
TextFormField(
controller: nameTextController,
decoration: const InputDecoration(
label: Text('Name'),
helper: Text(
'The name of the website associated with the bang',
),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: validateRequired,
),
const SizedBox(height: 16),
TextFormField(
controller: triggerTextController,
decoration: const InputDecoration(
label: Text('Trigger'),
helper: Text(
'The specific trigger word or phrase used to invoke the bang.',
),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: validateRequired,
),
const SizedBox(height: 16),
TextFormField(
controller: urlTextController,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
label: Text('URL'),
helper: Text(
"The URL template to use when the bang is invoked, where `{{{s}}}` is replaced by the user's query.",
),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
validator: (value) {
if (value?.contains('{{{s}}}') != true) {
return 'Must contain the query placeholder {{{s}}}';
}
return validateUrl(
value,
eagerParsing: false,
onlyHttpProtocol: true,
);
},
),
const SizedBox(height: 24),
DropdownMenuFormField(
key: ValueKey(EquatableValue([category.value, categories])),
enableFilter: true,
requestFocusOnTap: true,
label: const Text('Category'),
expandedInsets: EdgeInsets.zero,
initialSelection: category.value,
dropdownMenuEntries: [
...?categories?.keys.map(
(e) => DropdownMenuEntry(value: e, label: e),
),
],
onSelected: (value) {
if (category.value != value) {
category.value = value;
subCategory.value = null;
}
},
),
const SizedBox(height: 16),
DropdownMenuFormField(
key: ValueKey(
EquatableValue([subCategory.value, categories]),
),
enableFilter: true,
requestFocusOnTap: true,
label: const Text('Sub Category'),
expandedInsets: EdgeInsets.zero,
initialSelection: subCategory.value,
dropdownMenuEntries: [
...?categories?[category.value]?.map(
(e) => DropdownMenuEntry(value: e, label: e),
),
],
onSelected: (value) {
if (subCategory.value != value) {
subCategory.value = value;
}
},
),
const SizedBox(height: 16),
Text('Flags', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
CheckboxListTile(
value:
formatFlags.value?.contains(BangFormat.openBasePath) ??
false,
title: const Text('Open Base Path'),
subtitle: const Text(
'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)',
),
onChanged: (value) {
if (value != null) {
formatFlags.value =
value
? {
...?formatFlags.value,
BangFormat.openBasePath,
}
: {...?formatFlags.value}
..remove(BangFormat.openBasePath);
}
},
),
CheckboxListTile(
value:
formatFlags.value?.contains(
BangFormat.urlEncodePlaceholder,
) ??
false,
title: const Text('URL Encode Placeholder'),
subtitle: const Text(
'URL encode the search terms. Some sites do not work with this, so it can be disabled by omitting this.',
),
onChanged: (value) {
if (value != null) {
formatFlags.value =
value
? {
...?formatFlags.value,
BangFormat.urlEncodePlaceholder,
}
: {...?formatFlags.value}
..remove(BangFormat.urlEncodePlaceholder);
}
},
),
CheckboxListTile(
value:
formatFlags.value?.contains(
BangFormat.urlEncodeSpaceToPlus,
) ??
false,
title: const Text('URL Encode Space to Plus'),
subtitle: const Text(
'URL encodes spaces as +, instead of %20. Some sites only work correctly with one or the other.',
),
onChanged: (value) {
if (value != null) {
formatFlags.value =
value
? {
...?formatFlags.value,
BangFormat.urlEncodeSpaceToPlus,
}
: {...?formatFlags.value}
..remove(BangFormat.urlEncodeSpaceToPlus);
}
},
),
if (initialBang != null)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
side: BorderSide(
color: Theme.of(context).colorScheme.error,
),
foregroundColor: Theme.of(context).colorScheme.error,
iconColor: Theme.of(context).colorScheme.error,
),
label: const Text('Delete'),
icon: const Icon(Icons.delete),
onPressed: () async {
final result = await showDeleteBangDialog(context);
if (result == true) {
await ref
.read(bangDataRepositoryProvider.notifier)
.deleteBang(
BangKey(
group: BangGroup.user,
trigger: initialBang!.trigger,
),
);
if (context.mounted) {
context.pop();
}
}
},
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
class BangMenuScreen extends HookConsumerWidget {
const BangMenuScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Bangs')),
body: SafeArea(
child: ListView(
children: [
ListTile(
leading: const Icon(MdiIcons.accountAlert),
title: const Text('Manage User Bangs'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await const UserBangsRoute().push(context);
},
),
ListTile(
leading: const Icon(Icons.search),
title: const Text('Search Bangs'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await const BangSearchRoute().push(context);
},
),
ListTile(
leading: const Icon(MdiIcons.fileTree),
title: const Text('Browse Categories'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await const BangCategoriesRoute().push(context);
},
),
],
),
),
);
}
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:fading_scroll/fading_scroll.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';
import 'package:weblibre/features/bangs/domain/providers/search.dart';
import 'package:weblibre/features/bangs/presentation/widgets/bang_details.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class BangSearchScreen extends HookConsumerWidget {
final String? initialSearchText;
const BangSearchScreen({super.key, this.initialSearchText});
@override
Widget build(BuildContext context, WidgetRef ref) {
final resultsAsync = ref.watch(bangSearchProvider);
final incognitoEnabled = ref.watch(incognitoModeEnabledProvider);
final focusNode = useFocusNode();
final textEditingController = useTextEditingController(
text: initialSearchText,
);
useOnListenableChange(textEditingController, () {
unawaited(
ref
.read(bangSearchProvider.notifier)
.search(textEditingController.text),
);
});
return Scaffold(
appBar: AppBar(
title: TextField(
enableIMEPersonalizedLearning: !incognitoEnabled,
focusNode: focusNode,
controller: textEditingController,
autofocus: true,
autocorrect: false,
decoration: const InputDecoration.collapsed(hintText: 'Search'),
),
actions: [
IconButton(
onPressed: () {
if (textEditingController.text.isEmpty) {
context.pop();
} else {
textEditingController.clear();
focusNode.requestFocus();
}
},
icon: const Icon(Icons.clear),
),
],
),
body: SafeArea(
child: resultsAsync.when(
skipLoadingOnReload: true,
data: (bangs) => FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView.builder(
controller: controller,
itemCount: bangs.length,
itemBuilder: (context, index) {
final bang = bangs[index];
return BangDetails(
bang,
onTap: () {
context.pop(bang.toKey());
},
);
},
);
},
),
error: (error, stackTrace) => Center(
child: FailureWidget(title: 'Bang Search failed', exception: error),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
}
}
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
import 'package:weblibre/features/bangs/presentation/widgets/bang_details.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
class UserBangs extends HookConsumerWidget {
static const _userGroupFilter = [BangGroup.user];
const UserBangs({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bangsAsync = ref.watch(bangListProvider(groups: _userGroupFilter));
return Scaffold(
appBar: AppBar(title: const Text('User Bangs')),
body: bangsAsync.when(
skipLoadingOnReload: true,
data: (bangs) {
return ListView.builder(
itemCount: bangs.length,
itemBuilder: (context, index) {
final bang = bangs[index];
return Slidable(
endActionPane: ActionPane(
motion: const ScrollMotion(),
children: [
SlidableAction(
onPressed: (context) async {
await ref
.read(bangDataRepositoryProvider.notifier)
.deleteBang(
BangKey(
group: BangGroup.user,
trigger: bang.trigger,
),
);
},
backgroundColor: Theme.of(
context,
).colorScheme.errorContainer,
foregroundColor: Theme.of(
context,
).colorScheme.onErrorContainer,
icon: Icons.delete,
label: 'Delete',
),
],
),
child: BangDetails(
bang,
onTap: () async {
await EditUserBangRoute(
initialBang: jsonEncode(bang.toJson()),
).push(context);
},
),
);
},
);
},
error: (error, stackTrace) => Center(
child: FailureWidget(title: 'Failed to load Bangs', exception: error),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () async {
await const NewUserBangRoute().push(context);
},
),
);
}
}
@@ -0,0 +1,131 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/widgets/url_icon.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: [
UrlIcon([bangData.getDefaultUrl()], iconSize: 34.0),
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.getDefaultUrl().origin);
final tabMode = TabMode.fromTabType(
ref
.read(generalSettingsWithDefaultsProvider)
.effectiveDefaultCreateTabType,
);
await ref
.read(tabRepositoryProvider.notifier)
.addTab(url: url, tabMode: tabMode, selectTab: true);
if (context.mounted) {
const BrowserRoute().go(context);
}
},
label: Text(bangData.domain),
icon: const Icon(Icons.open_in_new),
),
const SizedBox(width: 8),
Expanded(
child: Tooltip(
message:
'Triggers: ${bangData.trigger}${bangData.additionalTriggers.mapNotNull((triggers) => ', ${triggers.map((trigger) => '!$trigger').join(', ')}') ?? ''}',
child: Text(
'!${bangData.trigger}',
style: theme.textTheme.titleSmall,
textAlign: TextAlign.right,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],
),
),
),
);
}
}