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()));
}
}