diff --git a/app/lib/features/geckoview/features/top_sites/data/database/daos/seed_state.dart b/app/lib/features/geckoview/features/top_sites/data/database/daos/seed_state.dart new file mode 100644 index 00000000..0bc2c977 --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/data/database/daos/seed_state.dart @@ -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 . + */ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/daos/seed_state.drift.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/definitions.drift.dart'; + +@DriftAccessor() +class TopSiteSeedStateDao extends DatabaseAccessor + with $TopSiteSeedStateDaoMixin { + TopSiteSeedStateDao(super.db); + + Future hasSeed(String seedId) async { + final row = + await (db.topSiteSeedState.select() + ..where((t) => t.seedId.equals(seedId))) + .getSingleOrNull(); + return row != null; + } + + Future markSeedApplied(String seedId) { + return db.topSiteSeedState.insertOne( + TopSiteSeedStateCompanion.insert( + seedId: seedId, + appliedAt: DateTime.now(), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/top_sites/data/database/daos/seed_state.drift.dart b/app/lib/features/geckoview/features/top_sites/data/database/daos/seed_state.drift.dart new file mode 100644 index 00000000..facb8658 --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/data/database/daos/seed_state.drift.dart @@ -0,0 +1,14 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.dart' + as i1; + +mixin $TopSiteSeedStateDaoMixin on i0.DatabaseAccessor { + TopSiteSeedStateDaoManager get managers => TopSiteSeedStateDaoManager(this); +} + +class TopSiteSeedStateDaoManager { + final $TopSiteSeedStateDaoMixin _db; + TopSiteSeedStateDaoManager(this._db); +} diff --git a/app/lib/features/geckoview/features/top_sites/data/database/daos/top_site.dart b/app/lib/features/geckoview/features/top_sites/data/database/daos/top_site.dart new file mode 100644 index 00000000..f61310a0 --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/data/database/daos/top_site.dart @@ -0,0 +1,130 @@ +/* + * 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 . + */ +import 'package:drift/drift.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/daos/top_site.drift.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/definitions.drift.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/entities/stored_top_site_source.dart'; + +@DriftAccessor() +class TopSiteDao extends DatabaseAccessor + with $TopSiteDaoMixin { + TopSiteDao(super.db); + + Selectable selectPersistedTopSites() { + return db.topSite.select()..orderBy([(t) => OrderingTerm.asc(t.orderKey)]); + } + + Future> getPersistedTopSites() { + return selectPersistedTopSites().get(); + } + + Future getPersistedTopSiteById(String id) { + return (db.topSite.select()..where((t) => t.id.equals(id))) + .getSingleOrNull(); + } + + Future getPersistedTopSiteByUrl(Uri url) { + return (db.topSite.select()..where((t) => t.url.equalsValue(url))) + .getSingleOrNull(); + } + + Future countPersistedSites() async { + final count = db.topSite.id.count(); + final query = db.selectOnly(db.topSite)..addColumns([count]); + final result = await query.getSingle(); + return result.read(count)!; + } + + Future insertPinnedSite({ + required String id, + required String title, + required Uri url, + required String orderKey, + }) { + return db.topSite.insertOne( + TopSiteCompanion.insert( + id: id, + title: title, + url: url, + source: StoredTopSiteSource.pinned, + orderKey: orderKey, + createdAt: DateTime.now(), + ), + ); + } + + Future insertSeededSites(List rows) { + return db.batch((batch) { + batch.insertAll(db.topSite, rows); + }); + } + + Future updatePersistedSite( + String id, { + required String title, + required Uri url, + }) { + return (db.topSite.update()..where((t) => t.id.equals(id))).write( + TopSiteCompanion(title: Value(title), url: Value(url)), + ); + } + + Future deletePersistedSite(String id) { + return (db.topSite.delete()..where((t) => t.id.equals(id))).go(); + } + + Future assignOrderKey(String id, {required String orderKey}) { + return (db.topSite.update()..where((t) => t.id.equals(id))).write( + TopSiteCompanion(orderKey: Value(orderKey)), + ); + } + + Future promoteToSource( + String id, { + required StoredTopSiteSource source, + required String title, + required String orderKey, + }) { + return (db.topSite.update()..where((t) => t.id.equals(id))).write( + TopSiteCompanion( + source: Value(source), + title: Value(title), + orderKey: Value(orderKey), + ), + ); + } + + SingleSelectable generateLeadingOrderKey({int bucket = 0}) { + return db.definitionsDrift.leadingOrderKey(bucket: bucket); + } + + SingleSelectable generateTrailingOrderKey({int bucket = 0}) { + return db.definitionsDrift.trailingOrderKey(bucket: bucket); + } + + SingleOrNullSelectable generateOrderKeyAfterSiteId(String id) { + return db.definitionsDrift.orderKeyAfterSite(siteId: id); + } + + SingleSelectable generateOrderKeyBeforeSiteId(String id) { + return db.definitionsDrift.orderKeyBeforeSite(siteId: id); + } +} diff --git a/app/lib/features/geckoview/features/top_sites/data/database/daos/top_site.drift.dart b/app/lib/features/geckoview/features/top_sites/data/database/daos/top_site.drift.dart new file mode 100644 index 00000000..1be4a0c3 --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/data/database/daos/top_site.drift.dart @@ -0,0 +1,14 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.dart' + as i1; + +mixin $TopSiteDaoMixin on i0.DatabaseAccessor { + TopSiteDaoManager get managers => TopSiteDaoManager(this); +} + +class TopSiteDaoManager { + final $TopSiteDaoMixin _db; + TopSiteDaoManager(this._db); +} diff --git a/app/lib/features/geckoview/features/top_sites/data/database/database.dart b/app/lib/features/geckoview/features/top_sites/data/database/database.dart new file mode 100644 index 00000000..b661d9ae --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/data/database/database.dart @@ -0,0 +1,47 @@ +/* + * 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 . + */ +import 'package:drift/drift.dart'; +import 'package:drift_dev/api/migrations_native.dart'; +import 'package:flutter/foundation.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/daos/seed_state.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/daos/top_site.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.drift.dart'; + +@DriftDatabase( + include: {'definitions.drift'}, + daos: [TopSiteDao, TopSiteSeedStateDao], +) +class TopSiteDatabase extends $TopSiteDatabase { + @override + final int schemaVersion = 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + beforeOpen: (details) async { + if (kDebugMode) { + await validateDatabaseSchema(); + } + + await customStatement('PRAGMA foreign_keys = ON;'); + }, + ); + + TopSiteDatabase(super.e); +} diff --git a/app/lib/features/geckoview/features/top_sites/data/database/database.drift.dart b/app/lib/features/geckoview/features/top_sites/data/database/database.drift.dart new file mode 100644 index 00000000..32635089 --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/data/database/database.drift.dart @@ -0,0 +1,92 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/definitions.drift.dart' + as i1; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/daos/top_site.dart' + as i2; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.dart' + as i3; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/daos/seed_state.dart' + as i4; +import 'package:drift/internal/modular.dart' as i5; +import 'package:sqlite3/common.dart' as i6; + +abstract class $TopSiteDatabase extends i0.GeneratedDatabase { + $TopSiteDatabase(i0.QueryExecutor e) : super(e); + $TopSiteDatabaseManager get managers => $TopSiteDatabaseManager(this); + late final i1.TopSite topSite = i1.TopSite(this); + late final i1.TopSiteSeedState topSiteSeedState = i1.TopSiteSeedState(this); + late final i2.TopSiteDao topSiteDao = i2.TopSiteDao( + this as i3.TopSiteDatabase, + ); + late final i4.TopSiteSeedStateDao topSiteSeedStateDao = + i4.TopSiteSeedStateDao(this as i3.TopSiteDatabase); + i1.DefinitionsDrift get definitionsDrift => i5.ReadDatabaseContainer( + this, + ).accessor(i1.DefinitionsDrift.new); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + topSite, + i1.idxTopSiteOrderKey, + topSiteSeedState, + ]; +} + +class $TopSiteDatabaseManager { + final $TopSiteDatabase _db; + $TopSiteDatabaseManager(this._db); + i1.$TopSiteTableManager get topSite => + i1.$TopSiteTableManager(_db, _db.topSite); + i1.$TopSiteSeedStateTableManager get topSiteSeedState => + i1.$TopSiteSeedStateTableManager(_db, _db.topSiteSeedState); +} + +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); + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/top_sites/data/database/definitions.drift b/app/lib/features/geckoview/features/top_sites/data/database/definitions.drift new file mode 100644 index 00000000..ab1ebe9a --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/data/database/definitions.drift @@ -0,0 +1,65 @@ +import 'package:weblibre/data/database/converters/uri.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/entities/stored_top_site_source.dart'; + +CREATE TABLE top_site ( + id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL MAPPED BY `const UriConverter()`, + source ENUM(StoredTopSiteSource) NOT NULL, + order_key TEXT NOT NULL, + created_at DATETIME NOT NULL, + UNIQUE(url) +); + +CREATE INDEX idx_top_site_order_key ON top_site(order_key); + +CREATE TABLE top_site_seed_state ( + seed_id TEXT PRIMARY KEY NOT NULL, + applied_at DATETIME NOT NULL +); + +leadingOrderKey(:bucket AS INTEGER): + SELECT lexo_rank_previous( + :bucket, + ( + SELECT order_key + FROM top_site + ORDER BY order_key + LIMIT 1 + ) + ); + +trailingOrderKey(:bucket AS INTEGER): + SELECT lexo_rank_next( + :bucket, + ( + SELECT order_key + FROM top_site + ORDER BY order_key DESC + LIMIT 1 + ) + ); + +orderKeyAfterSite(:site_id AS TEXT): + WITH ordered_table AS ( + SELECT + id, + order_key, + LEAD(order_key) OVER (ORDER BY order_key) AS next_order_key + FROM top_site + ) + SELECT lexo_rank_reorder_after(order_key, next_order_key) + FROM ordered_table + WHERE id = :site_id; + +orderKeyBeforeSite(:site_id AS TEXT): + WITH ordered_table AS ( + SELECT + id, + order_key, + LAG(order_key) OVER (ORDER BY order_key) AS prev_order_key + FROM top_site + ) + SELECT lexo_rank_reorder_before(order_key, prev_order_key) + FROM ordered_table + WHERE id = :site_id; diff --git a/app/lib/features/geckoview/features/top_sites/data/database/definitions.drift.dart b/app/lib/features/geckoview/features/top_sites/data/database/definitions.drift.dart new file mode 100644 index 00000000..75f8cfb0 --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/data/database/definitions.drift.dart @@ -0,0 +1,983 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/definitions.drift.dart' + as i1; +import 'package:weblibre/features/geckoview/features/top_sites/data/entities/stored_top_site_source.dart' + as i2; +import 'package:weblibre/data/database/converters/uri.dart' as i3; +import 'package:drift/internal/modular.dart' as i4; + +typedef $TopSiteCreateCompanionBuilder = + i1.TopSiteCompanion Function({ + required String id, + required String title, + required Uri url, + required i2.StoredTopSiteSource source, + required String orderKey, + required DateTime createdAt, + i0.Value rowid, + }); +typedef $TopSiteUpdateCompanionBuilder = + i1.TopSiteCompanion Function({ + i0.Value id, + i0.Value title, + i0.Value url, + i0.Value source, + i0.Value orderKey, + i0.Value createdAt, + i0.Value rowid, + }); + +class $TopSiteFilterComposer + extends i0.Composer { + $TopSiteFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnWithTypeConverterFilters get url => + $composableBuilder( + column: $table.url, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnWithTypeConverterFilters< + i2.StoredTopSiteSource, + i2.StoredTopSiteSource, + int + > + get source => $composableBuilder( + column: $table.source, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get orderKey => $composableBuilder( + column: $table.orderKey, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnFilters(column), + ); +} + +class $TopSiteOrderingComposer + extends i0.Composer { + $TopSiteOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get source => $composableBuilder( + column: $table.source, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get orderKey => $composableBuilder( + column: $table.orderKey, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnOrderings(column), + ); +} + +class $TopSiteAnnotationComposer + extends i0.Composer { + $TopSiteAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter get source => + $composableBuilder(column: $table.source, builder: (column) => column); + + i0.GeneratedColumn get orderKey => + $composableBuilder(column: $table.orderKey, builder: (column) => column); + + i0.GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $TopSiteTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.TopSite, + i1.TopSiteData, + i1.$TopSiteFilterComposer, + i1.$TopSiteOrderingComposer, + i1.$TopSiteAnnotationComposer, + $TopSiteCreateCompanionBuilder, + $TopSiteUpdateCompanionBuilder, + ( + i1.TopSiteData, + i0.BaseReferences, + ), + i1.TopSiteData, + i0.PrefetchHooks Function() + > { + $TopSiteTableManager(i0.GeneratedDatabase db, i1.TopSite table) + : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$TopSiteFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$TopSiteOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$TopSiteAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value id = const i0.Value.absent(), + i0.Value title = const i0.Value.absent(), + i0.Value url = const i0.Value.absent(), + i0.Value source = + const i0.Value.absent(), + i0.Value orderKey = const i0.Value.absent(), + i0.Value createdAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.TopSiteCompanion( + id: id, + title: title, + url: url, + source: source, + orderKey: orderKey, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String title, + required Uri url, + required i2.StoredTopSiteSource source, + required String orderKey, + required DateTime createdAt, + i0.Value rowid = const i0.Value.absent(), + }) => i1.TopSiteCompanion.insert( + id: id, + title: title, + url: url, + source: source, + orderKey: orderKey, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $TopSiteProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.TopSite, + i1.TopSiteData, + i1.$TopSiteFilterComposer, + i1.$TopSiteOrderingComposer, + i1.$TopSiteAnnotationComposer, + $TopSiteCreateCompanionBuilder, + $TopSiteUpdateCompanionBuilder, + ( + i1.TopSiteData, + i0.BaseReferences, + ), + i1.TopSiteData, + i0.PrefetchHooks Function() + >; +typedef $TopSiteSeedStateCreateCompanionBuilder = + i1.TopSiteSeedStateCompanion Function({ + required String seedId, + required DateTime appliedAt, + i0.Value rowid, + }); +typedef $TopSiteSeedStateUpdateCompanionBuilder = + i1.TopSiteSeedStateCompanion Function({ + i0.Value seedId, + i0.Value appliedAt, + i0.Value rowid, + }); + +class $TopSiteSeedStateFilterComposer + extends i0.Composer { + $TopSiteSeedStateFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get seedId => $composableBuilder( + column: $table.seedId, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnFilters get appliedAt => $composableBuilder( + column: $table.appliedAt, + builder: (column) => i0.ColumnFilters(column), + ); +} + +class $TopSiteSeedStateOrderingComposer + extends i0.Composer { + $TopSiteSeedStateOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get seedId => $composableBuilder( + column: $table.seedId, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get appliedAt => $composableBuilder( + column: $table.appliedAt, + builder: (column) => i0.ColumnOrderings(column), + ); +} + +class $TopSiteSeedStateAnnotationComposer + extends i0.Composer { + $TopSiteSeedStateAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get seedId => + $composableBuilder(column: $table.seedId, builder: (column) => column); + + i0.GeneratedColumn get appliedAt => + $composableBuilder(column: $table.appliedAt, builder: (column) => column); +} + +class $TopSiteSeedStateTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.TopSiteSeedState, + i1.TopSiteSeedStateData, + i1.$TopSiteSeedStateFilterComposer, + i1.$TopSiteSeedStateOrderingComposer, + i1.$TopSiteSeedStateAnnotationComposer, + $TopSiteSeedStateCreateCompanionBuilder, + $TopSiteSeedStateUpdateCompanionBuilder, + ( + i1.TopSiteSeedStateData, + i0.BaseReferences< + i0.GeneratedDatabase, + i1.TopSiteSeedState, + i1.TopSiteSeedStateData + >, + ), + i1.TopSiteSeedStateData, + i0.PrefetchHooks Function() + > { + $TopSiteSeedStateTableManager( + i0.GeneratedDatabase db, + i1.TopSiteSeedState table, + ) : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$TopSiteSeedStateFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$TopSiteSeedStateOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$TopSiteSeedStateAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value seedId = const i0.Value.absent(), + i0.Value appliedAt = const i0.Value.absent(), + i0.Value rowid = const i0.Value.absent(), + }) => i1.TopSiteSeedStateCompanion( + seedId: seedId, + appliedAt: appliedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String seedId, + required DateTime appliedAt, + i0.Value rowid = const i0.Value.absent(), + }) => i1.TopSiteSeedStateCompanion.insert( + seedId: seedId, + appliedAt: appliedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $TopSiteSeedStateProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.TopSiteSeedState, + i1.TopSiteSeedStateData, + i1.$TopSiteSeedStateFilterComposer, + i1.$TopSiteSeedStateOrderingComposer, + i1.$TopSiteSeedStateAnnotationComposer, + $TopSiteSeedStateCreateCompanionBuilder, + $TopSiteSeedStateUpdateCompanionBuilder, + ( + i1.TopSiteSeedStateData, + i0.BaseReferences< + i0.GeneratedDatabase, + i1.TopSiteSeedState, + i1.TopSiteSeedStateData + >, + ), + i1.TopSiteSeedStateData, + i0.PrefetchHooks Function() + >; + +class TopSite extends i0.Table with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + TopSite(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final i0.GeneratedColumn title = i0.GeneratedColumn( + 'title', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumnWithTypeConverter url = + i0.GeneratedColumn( + 'url', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter(i1.TopSite.$converterurl); + late final i0.GeneratedColumnWithTypeConverter + source = i0.GeneratedColumn( + 'source', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter(i1.TopSite.$convertersource); + late final i0.GeneratedColumn orderKey = i0.GeneratedColumn( + 'order_key', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final i0.GeneratedColumn createdAt = + i0.GeneratedColumn( + 'created_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + id, + title, + url, + source, + orderKey, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'top_site'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {url}, + ]; + @override + i1.TopSiteData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.TopSiteData( + id: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + title: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + url: i1.TopSite.$converterurl.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + ), + source: i1.TopSite.$convertersource.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + ), + orderKey: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}order_key'], + )!, + createdAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + TopSite createAlias(String alias) { + return TopSite(attachedDatabase, alias); + } + + static i0.TypeConverter $converterurl = const i3.UriConverter(); + static i0.JsonTypeConverter2 + $convertersource = const i0.EnumIndexConverter( + i2.StoredTopSiteSource.values, + ); + @override + List get customConstraints => const ['UNIQUE(url)']; + @override + bool get dontWriteConstraints => true; +} + +class TopSiteData extends i0.DataClass + implements i0.Insertable { + final String id; + final String title; + final Uri url; + final i2.StoredTopSiteSource source; + final String orderKey; + final DateTime createdAt; + const TopSiteData({ + required this.id, + required this.title, + required this.url, + required this.source, + required this.orderKey, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = i0.Variable(id); + map['title'] = i0.Variable(title); + { + map['url'] = i0.Variable(i1.TopSite.$converterurl.toSql(url)); + } + { + map['source'] = i0.Variable( + i1.TopSite.$convertersource.toSql(source), + ); + } + map['order_key'] = i0.Variable(orderKey); + map['created_at'] = i0.Variable(createdAt); + return map; + } + + factory TopSiteData.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return TopSiteData( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + url: serializer.fromJson(json['url']), + source: i1.TopSite.$convertersource.fromJson( + serializer.fromJson(json['source']), + ), + orderKey: serializer.fromJson(json['order_key']), + createdAt: serializer.fromJson(json['created_at']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'url': serializer.toJson(url), + 'source': serializer.toJson( + i1.TopSite.$convertersource.toJson(source), + ), + 'order_key': serializer.toJson(orderKey), + 'created_at': serializer.toJson(createdAt), + }; + } + + i1.TopSiteData copyWith({ + String? id, + String? title, + Uri? url, + i2.StoredTopSiteSource? source, + String? orderKey, + DateTime? createdAt, + }) => i1.TopSiteData( + id: id ?? this.id, + title: title ?? this.title, + url: url ?? this.url, + source: source ?? this.source, + orderKey: orderKey ?? this.orderKey, + createdAt: createdAt ?? this.createdAt, + ); + TopSiteData copyWithCompanion(i1.TopSiteCompanion data) { + return TopSiteData( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + url: data.url.present ? data.url.value : this.url, + source: data.source.present ? data.source.value : this.source, + orderKey: data.orderKey.present ? data.orderKey.value : this.orderKey, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('TopSiteData(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('source: $source, ') + ..write('orderKey: $orderKey, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, title, url, source, orderKey, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.TopSiteData && + other.id == this.id && + other.title == this.title && + other.url == this.url && + other.source == this.source && + other.orderKey == this.orderKey && + other.createdAt == this.createdAt); +} + +class TopSiteCompanion extends i0.UpdateCompanion { + final i0.Value id; + final i0.Value title; + final i0.Value url; + final i0.Value source; + final i0.Value orderKey; + final i0.Value createdAt; + final i0.Value rowid; + const TopSiteCompanion({ + this.id = const i0.Value.absent(), + this.title = const i0.Value.absent(), + this.url = const i0.Value.absent(), + this.source = const i0.Value.absent(), + this.orderKey = const i0.Value.absent(), + this.createdAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }); + TopSiteCompanion.insert({ + required String id, + required String title, + required Uri url, + required i2.StoredTopSiteSource source, + required String orderKey, + required DateTime createdAt, + this.rowid = const i0.Value.absent(), + }) : id = i0.Value(id), + title = i0.Value(title), + url = i0.Value(url), + source = i0.Value(source), + orderKey = i0.Value(orderKey), + createdAt = i0.Value(createdAt); + static i0.Insertable custom({ + i0.Expression? id, + i0.Expression? title, + i0.Expression? url, + i0.Expression? source, + i0.Expression? orderKey, + i0.Expression? createdAt, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (url != null) 'url': url, + if (source != null) 'source': source, + if (orderKey != null) 'order_key': orderKey, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + i1.TopSiteCompanion copyWith({ + i0.Value? id, + i0.Value? title, + i0.Value? url, + i0.Value? source, + i0.Value? orderKey, + i0.Value? createdAt, + i0.Value? rowid, + }) { + return i1.TopSiteCompanion( + id: id ?? this.id, + title: title ?? this.title, + url: url ?? this.url, + source: source ?? this.source, + orderKey: orderKey ?? this.orderKey, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (title.present) { + map['title'] = i0.Variable(title.value); + } + if (url.present) { + map['url'] = i0.Variable( + i1.TopSite.$converterurl.toSql(url.value), + ); + } + if (source.present) { + map['source'] = i0.Variable( + i1.TopSite.$convertersource.toSql(source.value), + ); + } + if (orderKey.present) { + map['order_key'] = i0.Variable(orderKey.value); + } + if (createdAt.present) { + map['created_at'] = i0.Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TopSiteCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('url: $url, ') + ..write('source: $source, ') + ..write('orderKey: $orderKey, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +i0.Index get idxTopSiteOrderKey => i0.Index( + 'idx_top_site_order_key', + 'CREATE INDEX idx_top_site_order_key ON top_site (order_key)', +); + +class TopSiteSeedState extends i0.Table + with i0.TableInfo { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + TopSiteSeedState(this.attachedDatabase, [this._alias]); + late final i0.GeneratedColumn seedId = i0.GeneratedColumn( + 'seed_id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final i0.GeneratedColumn appliedAt = + i0.GeneratedColumn( + 'applied_at', + aliasedName, + false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [seedId, appliedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'top_site_seed_state'; + @override + Set get $primaryKey => {seedId}; + @override + i1.TopSiteSeedStateData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.TopSiteSeedStateData( + seedId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}seed_id'], + )!, + appliedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, + data['${effectivePrefix}applied_at'], + )!, + ); + } + + @override + TopSiteSeedState createAlias(String alias) { + return TopSiteSeedState(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class TopSiteSeedStateData extends i0.DataClass + implements i0.Insertable { + final String seedId; + final DateTime appliedAt; + const TopSiteSeedStateData({required this.seedId, required this.appliedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['seed_id'] = i0.Variable(seedId); + map['applied_at'] = i0.Variable(appliedAt); + return map; + } + + factory TopSiteSeedStateData.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return TopSiteSeedStateData( + seedId: serializer.fromJson(json['seed_id']), + appliedAt: serializer.fromJson(json['applied_at']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'seed_id': serializer.toJson(seedId), + 'applied_at': serializer.toJson(appliedAt), + }; + } + + i1.TopSiteSeedStateData copyWith({String? seedId, DateTime? appliedAt}) => + i1.TopSiteSeedStateData( + seedId: seedId ?? this.seedId, + appliedAt: appliedAt ?? this.appliedAt, + ); + TopSiteSeedStateData copyWithCompanion(i1.TopSiteSeedStateCompanion data) { + return TopSiteSeedStateData( + seedId: data.seedId.present ? data.seedId.value : this.seedId, + appliedAt: data.appliedAt.present ? data.appliedAt.value : this.appliedAt, + ); + } + + @override + String toString() { + return (StringBuffer('TopSiteSeedStateData(') + ..write('seedId: $seedId, ') + ..write('appliedAt: $appliedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(seedId, appliedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.TopSiteSeedStateData && + other.seedId == this.seedId && + other.appliedAt == this.appliedAt); +} + +class TopSiteSeedStateCompanion + extends i0.UpdateCompanion { + final i0.Value seedId; + final i0.Value appliedAt; + final i0.Value rowid; + const TopSiteSeedStateCompanion({ + this.seedId = const i0.Value.absent(), + this.appliedAt = const i0.Value.absent(), + this.rowid = const i0.Value.absent(), + }); + TopSiteSeedStateCompanion.insert({ + required String seedId, + required DateTime appliedAt, + this.rowid = const i0.Value.absent(), + }) : seedId = i0.Value(seedId), + appliedAt = i0.Value(appliedAt); + static i0.Insertable custom({ + i0.Expression? seedId, + i0.Expression? appliedAt, + i0.Expression? rowid, + }) { + return i0.RawValuesInsertable({ + if (seedId != null) 'seed_id': seedId, + if (appliedAt != null) 'applied_at': appliedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + i1.TopSiteSeedStateCompanion copyWith({ + i0.Value? seedId, + i0.Value? appliedAt, + i0.Value? rowid, + }) { + return i1.TopSiteSeedStateCompanion( + seedId: seedId ?? this.seedId, + appliedAt: appliedAt ?? this.appliedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (seedId.present) { + map['seed_id'] = i0.Variable(seedId.value); + } + if (appliedAt.present) { + map['applied_at'] = i0.Variable(appliedAt.value); + } + if (rowid.present) { + map['rowid'] = i0.Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TopSiteSeedStateCompanion(') + ..write('seedId: $seedId, ') + ..write('appliedAt: $appliedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class DefinitionsDrift extends i4.ModularAccessor { + DefinitionsDrift(i0.GeneratedDatabase db) : super(db); + i0.Selectable leadingOrderKey({required int bucket}) { + return customSelect( + 'SELECT lexo_rank_previous(?1, (SELECT order_key FROM top_site ORDER BY order_key LIMIT 1)) AS _c0', + variables: [i0.Variable(bucket)], + readsFrom: {topSite}, + ).map((i0.QueryRow row) => row.read('_c0')); + } + + i0.Selectable trailingOrderKey({required int bucket}) { + return customSelect( + 'SELECT lexo_rank_next(?1, (SELECT order_key FROM top_site ORDER BY order_key DESC LIMIT 1)) AS _c0', + variables: [i0.Variable(bucket)], + readsFrom: {topSite}, + ).map((i0.QueryRow row) => row.read('_c0')); + } + + i0.Selectable orderKeyAfterSite({required String siteId}) { + return customSelect( + 'WITH ordered_table AS (SELECT id, order_key, LEAD(order_key)OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS next_order_key FROM top_site) SELECT lexo_rank_reorder_after(order_key, next_order_key) AS _c0 FROM ordered_table WHERE id = ?1', + variables: [i0.Variable(siteId)], + readsFrom: {topSite}, + ).map((i0.QueryRow row) => row.read('_c0')); + } + + i0.Selectable orderKeyBeforeSite({required String siteId}) { + return customSelect( + 'WITH ordered_table AS (SELECT id, order_key, LAG(order_key)OVER (ORDER BY order_key RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS prev_order_key FROM top_site) SELECT lexo_rank_reorder_before(order_key, prev_order_key) AS _c0 FROM ordered_table WHERE id = ?1', + variables: [i0.Variable(siteId)], + readsFrom: {topSite}, + ).map((i0.QueryRow row) => row.read('_c0')); + } + + i1.TopSite get topSite => i4.ReadDatabaseContainer( + attachedDatabase, + ).resultSet('top_site'); +} diff --git a/app/lib/features/geckoview/features/top_sites/data/entities/stored_top_site_source.dart b/app/lib/features/geckoview/features/top_sites/data/entities/stored_top_site_source.dart new file mode 100644 index 00000000..61f65edd --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/data/entities/stored_top_site_source.dart @@ -0,0 +1,20 @@ +/* + * 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 . + */ +enum StoredTopSiteSource { seeded, pinned } diff --git a/app/lib/features/geckoview/features/top_sites/data/providers.dart b/app/lib/features/geckoview/features/top_sites/data/providers.dart new file mode 100644 index 00000000..29bb9209 --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/data/providers.dart @@ -0,0 +1,60 @@ +/* + * 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 . + */ +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/filesystem.dart'; +import 'package:weblibre/data/database/functions/lexo_rank_functions.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.dart'; + +part 'providers.g.dart'; + +@Riverpod(keepAlive: true) +TopSiteDatabase topSiteDatabase(Ref ref) { + final db = TopSiteDatabase( + LazyDatabase(() async { + final file = File( + p.join(filesystem.profileDatabasesDir.path, 'top_site.db'), + ); + + if (Platform.isAndroid) { + await applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); + } + + return NativeDatabase.createInBackground( + file, + setup: (database) { + registerLexorankFunctions(database); + }, + ); + }), + ); + + ref.onDispose(() async { + await db.close(); + }); + + return db; +} diff --git a/app/lib/features/geckoview/features/top_sites/data/providers.g.dart b/app/lib/features/geckoview/features/top_sites/data/providers.g.dart new file mode 100644 index 00000000..c3fc666e --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/data/providers.g.dart @@ -0,0 +1,52 @@ +// 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(topSiteDatabase) +final topSiteDatabaseProvider = TopSiteDatabaseProvider._(); + +final class TopSiteDatabaseProvider + extends + $FunctionalProvider + with $Provider { + TopSiteDatabaseProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'topSiteDatabaseProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$topSiteDatabaseHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + TopSiteDatabase create(Ref ref) { + return topSiteDatabase(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(TopSiteDatabase value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$topSiteDatabaseHash() => r'6371e1784bd1272bdfc656caaa56e786e7503c78'; diff --git a/app/lib/features/geckoview/features/top_sites/domain/entities/top_site_item.dart b/app/lib/features/geckoview/features/top_sites/domain/entities/top_site_item.dart new file mode 100644 index 00000000..64627176 --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/domain/entities/top_site_item.dart @@ -0,0 +1,67 @@ +/* + * 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 . + */ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_source.dart'; + +class TopSiteItem with FastEquatable { + final String? id; + final String title; + final Uri url; + final TopSiteSource source; + final String? orderKey; + final DateTime? createdAt; + final String? previewImageUrl; + final double? historyScore; + final int? historyPlaceId; + + TopSiteItem({ + this.id, + required this.title, + required this.url, + required this.source, + this.orderKey, + this.createdAt, + this.previewImageUrl, + this.historyScore, + this.historyPlaceId, + }); + + bool get isPersisted => + source == TopSiteSource.seeded || source == TopSiteSource.pinned; + + bool get isReorderable => isPersisted; + + bool get isEditable => isPersisted; + + bool get isRemovable => isPersisted; + + @override + List get hashParameters => [ + id, + title, + url, + source, + orderKey, + createdAt, + previewImageUrl, + historyScore, + historyPlaceId, + ]; +} diff --git a/app/lib/features/geckoview/features/top_sites/domain/entities/top_site_source.dart b/app/lib/features/geckoview/features/top_sites/domain/entities/top_site_source.dart new file mode 100644 index 00000000..974a6bdf --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/domain/entities/top_site_source.dart @@ -0,0 +1,20 @@ +/* + * 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 . + */ +enum TopSiteSource { seeded, pinned, history } diff --git a/app/lib/features/geckoview/features/top_sites/domain/providers.dart b/app/lib/features/geckoview/features/top_sites/domain/providers.dart new file mode 100644 index 00000000..12486b03 --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/domain/providers.dart @@ -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 . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_item.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart'; + +part 'providers.g.dart'; + +@Riverpod(keepAlive: true) +List<({String title, Uri url})> topSiteDefaultSeeds(Ref ref) { + return [ + (title: 'Wikipedia', url: Uri.parse('https://wikipedia.org')), + (title: 'OpenStreetMap', url: Uri.parse('https://www.openstreetmap.org')), + (title: 'Project Gutenberg', url: Uri.parse('https://www.gutenberg.org/')), + ]; +} + +@Riverpod() +Stream> topSiteList(Ref ref, {int limit = 8}) { + return ref + .watch(topSiteRepositoryProvider.notifier) + .watchTopSites(limit: limit); +} + +@Riverpod() +Stream> persistedTopSiteList(Ref ref) { + return ref.watch(topSiteRepositoryProvider.notifier).watchPersistedTopSites(); +} diff --git a/app/lib/features/geckoview/features/top_sites/domain/providers.g.dart b/app/lib/features/geckoview/features/top_sites/domain/providers.g.dart new file mode 100644 index 00000000..dced47f5 --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/domain/providers.g.dart @@ -0,0 +1,179 @@ +// 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(topSiteDefaultSeeds) +final topSiteDefaultSeedsProvider = TopSiteDefaultSeedsProvider._(); + +final class TopSiteDefaultSeedsProvider + extends + $FunctionalProvider< + List<({String title, Uri url})>, + List<({String title, Uri url})>, + List<({String title, Uri url})> + > + with $Provider> { + TopSiteDefaultSeedsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'topSiteDefaultSeedsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$topSiteDefaultSeedsHash(); + + @$internal + @override + $ProviderElement> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + List<({String title, Uri url})> create(Ref ref) { + return topSiteDefaultSeeds(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List<({String title, Uri url})> value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>( + value, + ), + ); + } +} + +String _$topSiteDefaultSeedsHash() => + r'd15156e1ebe1896a11dc2d983db9fb90a2222380'; + +@ProviderFor(topSiteList) +final topSiteListProvider = TopSiteListFamily._(); + +final class TopSiteListProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + TopSiteListProvider._({ + required TopSiteListFamily super.from, + required int super.argument, + }) : super( + retry: null, + name: r'topSiteListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$topSiteListHash(); + + @override + String toString() { + return r'topSiteListProvider' + '' + '($argument)'; + } + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + final argument = this.argument as int; + return topSiteList(ref, limit: argument); + } + + @override + bool operator ==(Object other) { + return other is TopSiteListProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$topSiteListHash() => r'c9cb226f7a4368b907b85230ef2e8a4e0c73a199'; + +final class TopSiteListFamily extends $Family + with $FunctionalFamilyOverride>, int> { + TopSiteListFamily._() + : super( + retry: null, + name: r'topSiteListProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + TopSiteListProvider call({int limit = 8}) => + TopSiteListProvider._(argument: limit, from: this); + + @override + String toString() => r'topSiteListProvider'; +} + +@ProviderFor(persistedTopSiteList) +final persistedTopSiteListProvider = PersistedTopSiteListProvider._(); + +final class PersistedTopSiteListProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + PersistedTopSiteListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'persistedTopSiteListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$persistedTopSiteListHash(); + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + return persistedTopSiteList(ref); + } +} + +String _$persistedTopSiteListHash() => + r'f0f7f087dccc0cea8498811ea2ad2af5e6d3fbb8'; diff --git a/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart b/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart new file mode 100644 index 00000000..28acbf33 --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart @@ -0,0 +1,265 @@ +/* + * 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 . + */ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/core/uuid.dart'; +import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/definitions.drift.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/entities/stored_top_site_source.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/providers.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_item.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_source.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/domain/providers.dart'; + +part 'top_site_repository.g.dart'; + +@Riverpod(keepAlive: true) +class TopSiteRepository extends _$TopSiteRepository { + Future ensureSeeded() async { + final db = ref.read(topSiteDatabaseProvider); + final seeds = ref.read(topSiteDefaultSeedsProvider); + + await db.transaction(() async { + final alreadySeeded = await db.topSiteSeedStateDao.hasSeed( + 'initial-defaults-v1', + ); + if (alreadySeeded) return; + + final now = DateTime.now(); + + for (var i = 0; i < seeds.length; i++) { + final orderKey = await db.topSiteDao + .generateTrailingOrderKey() + .getSingle(); + // Insert one-by-one so trailing key advances + await db.topSite.insertOne( + TopSiteCompanion.insert( + id: uuid.v7(), + title: seeds[i].title, + url: seeds[i].url, + source: StoredTopSiteSource.seeded, + orderKey: orderKey, + createdAt: now, + ), + ); + } + + await db.topSiteSeedStateDao.markSeedApplied('initial-defaults-v1'); + }); + } + + Future> getTopSites({int limit = 8}) async { + final persisted = await _getPersistedItems(); + + if (persisted.length >= limit) { + return persisted.take(limit).toList(); + } + + final remaining = limit - persisted.length; + final historyItems = await _getHistoryItems( + limit: remaining, + excludeUrls: persisted.map((s) => s.url.toString()).toSet(), + ); + + return [...persisted, ...historyItems]; + } + + Stream> watchTopSites({int limit = 8}) { + final db = ref.read(topSiteDatabaseProvider); + return db.topSiteDao.selectPersistedTopSites().watch().asyncMap(( + persistedRows, + ) async { + final persistedItems = persistedRows.map(_mapPersistedRow).toList(); + + if (persistedItems.length >= limit) { + return persistedItems.take(limit).toList(); + } + + final remaining = limit - persistedItems.length; + final historyItems = await _getHistoryItems( + limit: remaining, + excludeUrls: persistedItems.map((s) => s.url.toString()).toSet(), + ); + + return [...persistedItems, ...historyItems]; + }); + } + + Future> getPersistedTopSites() { + return _getPersistedItems(); + } + + Stream> watchPersistedTopSites() { + final db = ref.read(topSiteDatabaseProvider); + return db.topSiteDao.selectPersistedTopSites().watch().map( + (rows) => rows.map(_mapPersistedRow).toList(), + ); + } + + Future addPinnedSite({ + required String title, + required Uri url, + }) async { + final db = ref.read(topSiteDatabaseProvider); + + // Check if URL already exists as a persisted site + final existing = await db.topSiteDao.getPersistedTopSiteByUrl(url); + if (existing != null) { + final leadingKey = await db.topSiteDao + .generateLeadingOrderKey() + .getSingle(); + await db.topSiteDao.promoteToSource( + existing.id, + source: StoredTopSiteSource.pinned, + title: title, + orderKey: leadingKey, + ); + return existing.id; + } + + final id = uuid.v7(); + final orderKey = await db.topSiteDao.generateLeadingOrderKey().getSingle(); + await db.topSiteDao.insertPinnedSite( + id: id, + title: title, + url: url, + orderKey: orderKey, + ); + return id; + } + + Future updatePersistedSite({ + required String id, + required String title, + required Uri url, + }) { + return ref + .read(topSiteDatabaseProvider) + .topSiteDao + .updatePersistedSite(id, title: title, url: url); + } + + Future removePersistedSite(String id) { + return ref.read(topSiteDatabaseProvider).topSiteDao.deletePersistedSite(id); + } + + Future assignOrderKey(String id, String orderKey) { + return ref + .read(topSiteDatabaseProvider) + .topSiteDao + .assignOrderKey(id, orderKey: orderKey); + } + + Future getLeadingOrderKey() { + return ref + .read(topSiteDatabaseProvider) + .topSiteDao + .generateLeadingOrderKey() + .getSingle(); + } + + Future getTrailingOrderKey() { + return ref + .read(topSiteDatabaseProvider) + .topSiteDao + .generateTrailingOrderKey() + .getSingle(); + } + + Future getOrderKeyAfterSite(String id) { + return ref + .read(topSiteDatabaseProvider) + .topSiteDao + .generateOrderKeyAfterSiteId(id) + .getSingleOrNull(); + } + + Future getOrderKeyBeforeSite(String id) { + return ref + .read(topSiteDatabaseProvider) + .topSiteDao + .generateOrderKeyBeforeSiteId(id) + .getSingle(); + } + + Future> _getPersistedItems() async { + final rows = await ref + .read(topSiteDatabaseProvider) + .topSiteDao + .getPersistedTopSites(); + return rows.map(_mapPersistedRow).toList(); + } + + Future> _getHistoryItems({ + required int limit, + required Set excludeUrls, + }) async { + final highlights = await ref + .read(historyRepositoryProvider.notifier) + .getHistoryHighlights(limit: limit + excludeUrls.length); + + final items = []; + for (final h in highlights) { + if (items.length >= limit) break; + + final uri = Uri.tryParse(h.url); + if (uri == null) continue; + + if (excludeUrls.contains(uri.toString())) continue; + + final title = (h.title?.trim().isNotEmpty == true) + ? h.title!.trim() + : uri.host; + + items.add( + TopSiteItem( + title: title, + url: uri, + source: TopSiteSource.history, + previewImageUrl: h.previewImageUrl, + historyScore: h.score, + historyPlaceId: h.placeId, + ), + ); + } + + return items; + } + + TopSiteItem _mapPersistedRow(TopSiteData row) { + return TopSiteItem( + id: row.id, + title: row.title, + url: row.url, + source: row.source == StoredTopSiteSource.seeded + ? TopSiteSource.seeded + : TopSiteSource.pinned, + orderKey: row.orderKey, + createdAt: row.createdAt, + ); + } + + @override + void build() { + unawaited(ensureSeeded()); + } +} diff --git a/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart b/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart new file mode 100644 index 00000000..c4e009ca --- /dev/null +++ b/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'top_site_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(TopSiteRepository) +final topSiteRepositoryProvider = TopSiteRepositoryProvider._(); + +final class TopSiteRepositoryProvider + extends $NotifierProvider { + TopSiteRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'topSiteRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$topSiteRepositoryHash(); + + @$internal + @override + TopSiteRepository create() => TopSiteRepository(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$topSiteRepositoryHash() => r'b70c50a60411cbafb5367cd303d822c2eeb11add'; + +abstract class _$TopSiteRepository extends $Notifier { + void build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/app/lib/presentation/widgets/url_list_tile.dart b/app/lib/presentation/widgets/url_list_tile.dart index 12c1837c..5f0bf7c6 100644 --- a/app/lib/presentation/widgets/url_list_tile.dart +++ b/app/lib/presentation/widgets/url_list_tile.dart @@ -72,9 +72,7 @@ class UrlListTile extends StatelessWidget { child: Row( children: [ leading ?? - RepaintBoundary( - child: UrlIcon([uri], iconSize: iconSize), - ), + RepaintBoundary(child: UrlIcon([uri], iconSize: iconSize)), const SizedBox(width: 14.0), Expanded( child: Column( diff --git a/app/test/drift/top_sites/generated/drift_schema_v1.json b/app/test/drift/top_sites/generated/drift_schema_v1.json new file mode 100644 index 00000000..76c64126 --- /dev/null +++ b/app/test/drift/top_sites/generated/drift_schema_v1.json @@ -0,0 +1,181 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "top_site", + "was_declared_in_moor": true, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const UriConverter()", + "dart_type_name": "Uri" + } + }, + { + "name": "source", + "getter_name": "source", + "moor_type": "int", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(StoredTopSiteSource.values)", + "dart_type_name": "StoredTopSiteSource" + } + }, + { + "name": "order_key", + "getter_name": "orderKey", + "moor_type": "string", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [ + "UNIQUE(url)" + ], + "unique_keys": [ + [ + "url" + ] + ] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "index", + "data": { + "on": 0, + "name": "idx_top_site_order_key", + "sql": "CREATE INDEX idx_top_site_order_key ON top_site(order_key);", + "unique": false, + "columns": [] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "top_site_seed_state", + "was_declared_in_moor": true, + "columns": [ + { + "name": "seed_id", + "getter_name": "seedId", + "moor_type": "string", + "nullable": false, + "customConstraints": "PRIMARY KEY NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "primary-key" + ] + }, + { + "name": "applied_at", + "getter_name": "appliedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": "NOT NULL", + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + } + ], + "fixed_sql": [ + { + "name": "top_site", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"top_site\" (\"id\" TEXT PRIMARY KEY NOT NULL, \"title\" TEXT NOT NULL, \"url\" TEXT NOT NULL, \"source\" INTEGER NOT NULL, \"order_key\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, UNIQUE(url));" + } + ] + }, + { + "name": "idx_top_site_order_key", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_top_site_order_key ON top_site (order_key)" + } + ] + }, + { + "name": "top_site_seed_state", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"top_site_seed_state\" (\"seed_id\" TEXT PRIMARY KEY NOT NULL, \"applied_at\" INTEGER NOT NULL);" + } + ] + } + ] +} \ No newline at end of file diff --git a/app/test/drift/top_sites/generated/schema.dart b/app/test/drift/top_sites/generated/schema.dart new file mode 100644 index 00000000..8869e40e --- /dev/null +++ b/app/test/drift/top_sites/generated/schema.dart @@ -0,0 +1,21 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; +import 'package:drift/internal/migrations.dart'; +import 'schema_v1.dart' as v1; + +class GeneratedHelper implements SchemaInstantiationHelper { + @override + GeneratedDatabase databaseForVersion(QueryExecutor db, int version) { + switch (version) { + case 1: + return v1.DatabaseAtV1(db); + default: + throw MissingSchemaException(version, versions); + } + } + + static const versions = const [1]; +} diff --git a/app/test/drift/top_sites/generated/schema_v1.dart b/app/test/drift/top_sites/generated/schema_v1.dart new file mode 100644 index 00000000..aa33e35f --- /dev/null +++ b/app/test/drift/top_sites/generated/schema_v1.dart @@ -0,0 +1,159 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class TopSite extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TopSite(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn orderKey = GeneratedColumn( + 'order_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + id, + title, + url, + source, + orderKey, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'top_site'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {url}, + ]; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + TopSite createAlias(String alias) { + return TopSite(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['UNIQUE(url)']; + @override + bool get dontWriteConstraints => true; +} + +class TopSiteSeedState extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TopSiteSeedState(this.attachedDatabase, [this._alias]); + late final GeneratedColumn seedId = GeneratedColumn( + 'seed_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL', + ); + late final GeneratedColumn appliedAt = GeneratedColumn( + 'applied_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [seedId, appliedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'top_site_seed_state'; + @override + Set get $primaryKey => {seedId}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + TopSiteSeedState createAlias(String alias) { + return TopSiteSeedState(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class DatabaseAtV1 extends GeneratedDatabase { + DatabaseAtV1(QueryExecutor e) : super(e); + late final TopSite topSite = TopSite(this); + late final Index idxTopSiteOrderKey = Index( + 'idx_top_site_order_key', + 'CREATE INDEX idx_top_site_order_key ON top_site (order_key)', + ); + late final TopSiteSeedState topSiteSeedState = TopSiteSeedState(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + topSite, + idxTopSiteOrderKey, + topSiteSeedState, + ]; + @override + int get schemaVersion => 1; +} diff --git a/app/test/drift/top_sites/migration_test.dart b/app/test/drift/top_sites/migration_test.dart new file mode 100644 index 00000000..c9131d6c --- /dev/null +++ b/app/test/drift/top_sites/migration_test.dart @@ -0,0 +1,61 @@ +/* + * 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 . + */ + +// dart format width=80 +// ignore_for_file: unused_local_variable, unused_import +import 'package:drift/drift.dart'; +import 'package:drift_dev/api/migrations_native.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/data/database/database.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'generated/schema.dart'; + +import 'generated/schema_v1.dart' as v1; + +void main() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + late SchemaVerifier verifier; + + setUpAll(() { + verifier = SchemaVerifier(GeneratedHelper()); + }); + + group('simple database migrations', () { + const versions = GeneratedHelper.versions; + for (final (i, fromVersion) in versions.indexed) { + group('from $fromVersion', () { + for (final toVersion in versions.skip(i + 1)) { + test('to $toVersion', () async { + final schema = await verifier.schemaAt(fromVersion); + final db = TopSiteDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, toVersion); + await db.close(); + }); + } + }); + } + }); + + test('v1 schema creation works', () async { + final schema = await verifier.schemaAt(1); + final db = TopSiteDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, 1); + await db.close(); + }); +}