added popular sites module

This commit is contained in:
Fabian Freund
2026-06-29 08:55:43 +02:00
parent da7712bc80
commit a1ed66b392
24 changed files with 1441 additions and 5 deletions
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart';
import 'package:weblibre/features/popular_sites/data/database/daos/site.drift.dart';
import 'package:weblibre/features/popular_sites/data/database/database.dart';
import 'package:weblibre/features/popular_sites/data/database/definitions.drift.dart';
@DriftAccessor()
class SiteDao extends DatabaseAccessor<SitesDatabase> with $SiteDaoMixin {
SiteDao(super.db);
/// Popular-domain prefix completions for the omnibar, ordered by Tranco
/// popularity (densely re-ranked at build time, so `rank` is a contiguous
/// 1..N order). [prefix] is matched case-insensitively against the start of
/// the registrable domain.
///
/// LIKE metacharacters (`%`, `_`, and the `\` escape char itself) in the
/// user input are escaped so they match literally rather than acting as
/// wildcards — see the `ESCAPE '\'` clause on `searchSitesByPrefix`.
Selectable<Site> searchByPrefix(String prefix, {int limit = 8}) {
final escaped = prefix
.trim()
.toLowerCase()
.replaceAll(r'\', r'\\')
.replaceAll('%', r'\%')
.replaceAll('_', r'\_');
return db.definitionsDrift.searchSitesByPrefix(
pattern: '$escaped%',
limit: limit,
);
}
}
@@ -0,0 +1,14 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/popular_sites/data/database/database.dart'
as i1;
mixin $SiteDaoMixin on i0.DatabaseAccessor<i1.SitesDatabase> {
SiteDaoManager get managers => SiteDaoManager(this);
}
class SiteDaoManager {
final $SiteDaoMixin _db;
SiteDaoManager(this._db);
}
@@ -0,0 +1,41 @@
/*
* 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_dev/api/migrations_native.dart';
import 'package:flutter/foundation.dart';
import 'package:weblibre/features/popular_sites/data/database/daos/site.dart';
import 'package:weblibre/features/popular_sites/data/database/database.drift.dart';
@DriftDatabase(include: {'definitions.drift'}, daos: [SiteDao])
class SitesDatabase extends $SitesDatabase {
@override
final int schemaVersion = 1;
@override
MigrationStrategy get migration => MigrationStrategy(
beforeOpen: (details) async {
if (kDebugMode) {
await validateDatabaseSchema();
}
},
);
SitesDatabase(super.e);
}
@@ -0,0 +1,122 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/popular_sites/data/database/definitions.drift.dart'
as i1;
import 'package:weblibre/features/popular_sites/data/database/daos/site.dart'
as i2;
import 'package:weblibre/features/popular_sites/data/database/database.dart'
as i3;
import 'package:drift/internal/modular.dart' as i4;
import 'package:sqlite3/common.dart' as i5;
abstract class $SitesDatabase extends i0.GeneratedDatabase {
$SitesDatabase(i0.QueryExecutor e) : super(e);
$SitesDatabaseManager get managers => $SitesDatabaseManager(this);
late final i1.Sites sites = i1.Sites(this);
late final i2.SiteDao siteDao = i2.SiteDao(this as i3.SitesDatabase);
i1.DefinitionsDrift get definitionsDrift => i4.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 => [sites];
}
class $SitesDatabaseManager {
final $SitesDatabase _db;
$SitesDatabaseManager(this._db);
i1.$SitesTableManager get sites => i1.$SitesTableManager(_db, _db.sites);
}
extension DefineFunctions on i5.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,
required int Function() generateContentHash,
required bool Function(String?) urlIndexable,
required String Function(String?) urlCanonical,
required String Function(String?) urlHost,
required String Function(String?) urlPath,
}) {
createFunction(
functionName: 'lexo_rank_next',
argumentCount: const i5.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 i5.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 i5.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 i5.AllowedArgumentCount(2),
function: (args) {
final arg0 = args[0] as String?;
final arg1 = args[1] as String?;
return lexoRankReorderBefore(arg0, arg1);
},
);
createFunction(
functionName: 'generate_content_hash',
argumentCount: const i5.AllowedArgumentCount(0),
function: (args) {
return generateContentHash();
},
);
createFunction(
functionName: 'url_indexable',
argumentCount: const i5.AllowedArgumentCount(1),
function: (args) {
final arg0 = args[0] as String?;
return urlIndexable(arg0);
},
);
createFunction(
functionName: 'url_canonical',
argumentCount: const i5.AllowedArgumentCount(1),
function: (args) {
final arg0 = args[0] as String?;
return urlCanonical(arg0);
},
);
createFunction(
functionName: 'url_host',
argumentCount: const i5.AllowedArgumentCount(1),
function: (args) {
final arg0 = args[0] as String?;
return urlHost(arg0);
},
);
createFunction(
functionName: 'url_path',
argumentCount: const i5.AllowedArgumentCount(1),
function: (args) {
final arg0 = args[0] as String?;
return urlPath(arg0);
},
);
}
}
@@ -0,0 +1,13 @@
CREATE TABLE sites (
domain TEXT NOT NULL PRIMARY KEY,
rank INTEGER NOT NULL
);
-- Prefix completion ordered by popularity. The caller escapes LIKE
-- metacharacters in the user input and appends '%', so `ESCAPE '\'` makes any
-- literal % / _ the user typed match literally instead of as wildcards.
searchSitesByPrefix:
SELECT * FROM sites
WHERE domain LIKE :pattern ESCAPE '\'
ORDER BY rank ASC
LIMIT :limit;
@@ -0,0 +1,334 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:weblibre/features/popular_sites/data/database/definitions.drift.dart'
as i1;
import 'package:drift/internal/modular.dart' as i2;
typedef $SitesCreateCompanionBuilder =
i1.SitesCompanion Function({
required String domain,
required int rank,
i0.Value<int> rowid,
});
typedef $SitesUpdateCompanionBuilder =
i1.SitesCompanion Function({
i0.Value<String> domain,
i0.Value<int> rank,
i0.Value<int> rowid,
});
class $SitesFilterComposer extends i0.Composer<i0.GeneratedDatabase, i1.Sites> {
$SitesFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get domain => $composableBuilder(
column: $table.domain,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<int> get rank => $composableBuilder(
column: $table.rank,
builder: (column) => i0.ColumnFilters(column),
);
}
class $SitesOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.Sites> {
$SitesOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get domain => $composableBuilder(
column: $table.domain,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get rank => $composableBuilder(
column: $table.rank,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $SitesAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.Sites> {
$SitesAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get domain =>
$composableBuilder(column: $table.domain, builder: (column) => column);
i0.GeneratedColumn<int> get rank =>
$composableBuilder(column: $table.rank, builder: (column) => column);
}
class $SitesTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.Sites,
i1.Site,
i1.$SitesFilterComposer,
i1.$SitesOrderingComposer,
i1.$SitesAnnotationComposer,
$SitesCreateCompanionBuilder,
$SitesUpdateCompanionBuilder,
(i1.Site, i0.BaseReferences<i0.GeneratedDatabase, i1.Sites, i1.Site>),
i1.Site,
i0.PrefetchHooks Function()
> {
$SitesTableManager(i0.GeneratedDatabase db, i1.Sites table)
: super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$SitesFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$SitesOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
i1.$SitesAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
i0.Value<String> domain = const i0.Value.absent(),
i0.Value<int> rank = const i0.Value.absent(),
i0.Value<int> rowid = const i0.Value.absent(),
}) => i1.SitesCompanion(domain: domain, rank: rank, rowid: rowid),
createCompanionCallback:
({
required String domain,
required int rank,
i0.Value<int> rowid = const i0.Value.absent(),
}) => i1.SitesCompanion.insert(
domain: domain,
rank: rank,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $SitesProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.Sites,
i1.Site,
i1.$SitesFilterComposer,
i1.$SitesOrderingComposer,
i1.$SitesAnnotationComposer,
$SitesCreateCompanionBuilder,
$SitesUpdateCompanionBuilder,
(i1.Site, i0.BaseReferences<i0.GeneratedDatabase, i1.Sites, i1.Site>),
i1.Site,
i0.PrefetchHooks Function()
>;
class Sites extends i0.Table with i0.TableInfo<Sites, i1.Site> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
Sites(this.attachedDatabase, [this._alias]);
late final i0.GeneratedColumn<String> domain = i0.GeneratedColumn<String>(
'domain',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL PRIMARY KEY',
);
late final i0.GeneratedColumn<int> rank = i0.GeneratedColumn<int>(
'rank',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL',
);
@override
List<i0.GeneratedColumn> get $columns => [domain, rank];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'sites';
@override
Set<i0.GeneratedColumn> get $primaryKey => {domain};
@override
i1.Site map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.Site(
domain: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}domain'],
)!,
rank: attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}rank'],
)!,
);
}
@override
Sites createAlias(String alias) {
return Sites(attachedDatabase, alias);
}
@override
bool get dontWriteConstraints => true;
}
class Site extends i0.DataClass implements i0.Insertable<i1.Site> {
final String domain;
final int rank;
const Site({required this.domain, required this.rank});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['domain'] = i0.Variable<String>(domain);
map['rank'] = i0.Variable<int>(rank);
return map;
}
factory Site.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return Site(
domain: serializer.fromJson<String>(json['domain']),
rank: serializer.fromJson<int>(json['rank']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'domain': serializer.toJson<String>(domain),
'rank': serializer.toJson<int>(rank),
};
}
i1.Site copyWith({String? domain, int? rank}) =>
i1.Site(domain: domain ?? this.domain, rank: rank ?? this.rank);
Site copyWithCompanion(i1.SitesCompanion data) {
return Site(
domain: data.domain.present ? data.domain.value : this.domain,
rank: data.rank.present ? data.rank.value : this.rank,
);
}
@override
String toString() {
return (StringBuffer('Site(')
..write('domain: $domain, ')
..write('rank: $rank')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(domain, rank);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.Site &&
other.domain == this.domain &&
other.rank == this.rank);
}
class SitesCompanion extends i0.UpdateCompanion<i1.Site> {
final i0.Value<String> domain;
final i0.Value<int> rank;
final i0.Value<int> rowid;
const SitesCompanion({
this.domain = const i0.Value.absent(),
this.rank = const i0.Value.absent(),
this.rowid = const i0.Value.absent(),
});
SitesCompanion.insert({
required String domain,
required int rank,
this.rowid = const i0.Value.absent(),
}) : domain = i0.Value(domain),
rank = i0.Value(rank);
static i0.Insertable<i1.Site> custom({
i0.Expression<String>? domain,
i0.Expression<int>? rank,
i0.Expression<int>? rowid,
}) {
return i0.RawValuesInsertable({
if (domain != null) 'domain': domain,
if (rank != null) 'rank': rank,
if (rowid != null) 'rowid': rowid,
});
}
i1.SitesCompanion copyWith({
i0.Value<String>? domain,
i0.Value<int>? rank,
i0.Value<int>? rowid,
}) {
return i1.SitesCompanion(
domain: domain ?? this.domain,
rank: rank ?? this.rank,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (domain.present) {
map['domain'] = i0.Variable<String>(domain.value);
}
if (rank.present) {
map['rank'] = i0.Variable<int>(rank.value);
}
if (rowid.present) {
map['rowid'] = i0.Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('SitesCompanion(')
..write('domain: $domain, ')
..write('rank: $rank, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class DefinitionsDrift extends i2.ModularAccessor {
DefinitionsDrift(i0.GeneratedDatabase db) : super(db);
i0.Selectable<i1.Site> searchSitesByPrefix({
required String pattern,
required int limit,
}) {
return customSelect(
'SELECT * FROM sites WHERE domain LIKE ?1 ESCAPE \'\\\' ORDER BY rank ASC LIMIT ?2',
variables: [i0.Variable<String>(pattern), i0.Variable<int>(limit)],
readsFrom: {sites},
).asyncMap(sites.mapFromRow);
}
i1.Sites get sites =>
i2.ReadDatabaseContainer(attachedDatabase).resultSet<i1.Sites>('sites');
}