2026-10-11 09:33:25 +03:00
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
|
|
|
import 'package:drift/drift.dart';
|
|
|
|
|
import 'package:drift/native.dart';
|
|
|
|
|
import 'package:path/path.dart' as p;
|
|
|
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
import '../mews/document.dart' show MewsEntry;
|
2026-10-11 22:07:25 +03:00
|
|
|
import '../protocols/tofu.dart' show TofuPin;
|
2026-10-11 13:12:48 +03:00
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
part 'database.g.dart';
|
|
|
|
|
|
|
|
|
|
/// Visited pages, newest first; the most recent row is the app's start page.
|
2026-10-11 13:12:48 +03:00
|
|
|
/// The same page can appear many times, so the index carries the lookups that
|
|
|
|
|
/// group or remove by address.
|
|
|
|
|
@TableIndex(name: 'history_items_url', columns: {#url})
|
2026-10-11 09:33:25 +03:00
|
|
|
class HistoryItems extends Table {
|
|
|
|
|
IntColumn get id => integer().autoIncrement()();
|
|
|
|
|
TextColumn get url => text()();
|
|
|
|
|
TextColumn get title => text().nullable()();
|
|
|
|
|
DateTimeColumn get visitedAt => dateTime().withDefault(currentDateAndTime)();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
/// A bookmarked page, keyed by its address so it can only be held once.
|
2026-10-11 09:33:25 +03:00
|
|
|
class Bookmarks extends Table {
|
|
|
|
|
TextColumn get url => text()();
|
|
|
|
|
TextColumn get title => text().nullable()();
|
|
|
|
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
2026-10-11 13:12:48 +03:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Set<Column> get primaryKey => {url};
|
2026-10-11 09:33:25 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
/// A subscribed page (spec 6). Its posts live in [FeedEntries].
|
2026-10-11 09:33:25 +03:00
|
|
|
class Subscriptions extends Table {
|
|
|
|
|
TextColumn get url => text()();
|
|
|
|
|
TextColumn get title => text().nullable()();
|
2026-10-11 13:12:48 +03:00
|
|
|
|
|
|
|
|
/// The page's Atom feed, resolved absolute, when it declares one (spec 6.2).
|
|
|
|
|
TextColumn get atomHref => text().nullable()();
|
2026-10-11 09:33:25 +03:00
|
|
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
|
|
|
|
DateTimeColumn get lastCheckedAt => dateTime().nullable()();
|
2026-10-11 13:12:48 +03:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Set<Column> get primaryKey => {url};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One post found on a subscribed page, through its dated links (spec 6.1) or
|
|
|
|
|
/// its Atom feed (spec 6.2). An entry's URL identifies it for its lifetime, so
|
|
|
|
|
/// (siteUrl, url) is its identity and finding it again never clears [readAt].
|
|
|
|
|
class FeedEntries extends Table {
|
|
|
|
|
TextColumn get siteUrl => text()();
|
|
|
|
|
TextColumn get url => text()();
|
|
|
|
|
TextColumn get title => text()();
|
|
|
|
|
DateTimeColumn get date => dateTime()();
|
|
|
|
|
DateTimeColumn get foundAt => dateTime().withDefault(currentDateAndTime)();
|
|
|
|
|
DateTimeColumn get readAt => dateTime().nullable()();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Set<Column> get primaryKey => {siteUrl, url};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// What a tag is attached to.
|
|
|
|
|
enum TagKind { bookmark, subscription }
|
|
|
|
|
|
|
|
|
|
/// Tags the reader puts on a bookmark or a subscribed site. A tag exists only
|
|
|
|
|
/// while something carries it, so there is no separate table of tags to keep
|
|
|
|
|
/// in step. Comparison is case-sensitive.
|
|
|
|
|
class ItemTags extends Table {
|
|
|
|
|
TextColumn get kind => textEnum<TagKind>()();
|
|
|
|
|
TextColumn get url => text()();
|
|
|
|
|
TextColumn get tag => text()();
|
|
|
|
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Set<Column> get primaryKey => {kind, url, tag};
|
2026-10-11 09:33:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Single-row table (id 0) holding the reader's presentation choices.
|
|
|
|
|
class ReaderSettings extends Table {
|
|
|
|
|
IntColumn get id => integer().withDefault(const Constant(0))();
|
|
|
|
|
TextColumn get font => text().withDefault(const Constant('Atkinson Hyperlegible'))();
|
|
|
|
|
RealColumn get fontScale => real().withDefault(const Constant(1.0))();
|
|
|
|
|
TextColumn get themeMode => text().withDefault(const Constant('system'))();
|
|
|
|
|
TextColumn get variant => text().withDefault(const Constant('site'))();
|
2026-10-11 14:54:43 +03:00
|
|
|
TextColumn get language => text().withDefault(const Constant('system'))();
|
2026-10-11 09:33:25 +03:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Set<Column> get primaryKey => {id};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The fetched source of a page, kept so the reader can reopen it offline.
|
|
|
|
|
class CachedPages extends Table {
|
|
|
|
|
TextColumn get url => text()();
|
|
|
|
|
TextColumn get title => text().nullable()();
|
|
|
|
|
TextColumn get source => text()();
|
|
|
|
|
TextColumn get etag => text().nullable()();
|
|
|
|
|
TextColumn get lastModified => text().nullable()();
|
|
|
|
|
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Set<Column> get primaryKey => {url};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:07:25 +03:00
|
|
|
/// A Gemini host the reader's first contact with it pinned, together with the
|
|
|
|
|
/// certificate that was accepted (see lib/protocols/tofu.dart). The pin is
|
|
|
|
|
/// the trust: a later visit compares against it, and a change is accepted
|
|
|
|
|
/// only when the pin has expired or the reader chose to trust the new one.
|
|
|
|
|
class KnownHosts extends Table {
|
|
|
|
|
TextColumn get host => text()();
|
|
|
|
|
IntColumn get port => integer()();
|
|
|
|
|
TextColumn get fingerprint => text()();
|
|
|
|
|
DateTimeColumn get expires => dateTime()();
|
|
|
|
|
DateTimeColumn get pinnedAt => dateTime().withDefault(currentDateAndTime)();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Set<Column> get primaryKey => {host, port};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A client certificate the reader holds, for wearing as an identity on sites
|
|
|
|
|
/// that ask for one. The certificate is public record; its private key lives
|
|
|
|
|
/// in a file of the app's own storage, never in this database.
|
|
|
|
|
class ClientCertificates extends Table {
|
|
|
|
|
IntColumn get id => integer().autoIncrement()();
|
|
|
|
|
TextColumn get name => text()();
|
|
|
|
|
TextColumn get certPem => text()();
|
|
|
|
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:58:02 +03:00
|
|
|
/// What a fresh install has already been given, so it is given once: what
|
|
|
|
|
/// the reader removes afterwards stays removed.
|
|
|
|
|
class FirstStart extends Table {
|
|
|
|
|
IntColumn get id => integer().withDefault(const Constant(0))();
|
|
|
|
|
BoolColumn get directorySeeded => boolean().withDefault(const Constant(false))();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Set<Column> get primaryKey => {id};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:07:25 +03:00
|
|
|
/// Which certificate the reader presents to one host: a binding the reader
|
|
|
|
|
/// made on a certificate request, worn only there.
|
|
|
|
|
class SiteCertificates extends Table {
|
|
|
|
|
TextColumn get host => text()();
|
|
|
|
|
IntColumn get port => integer()();
|
|
|
|
|
IntColumn get certificateId => integer()();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Set<Column> get primaryKey => {host, port};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 10:37:32 +03:00
|
|
|
/// A stretch of text the reader marked on a page and kept, together with the
|
|
|
|
|
/// page it came from. The author is carried only when the page's metadata
|
|
|
|
|
/// said one, and the site's title when the reader's subscriptions know the
|
|
|
|
|
/// site the page belongs to. The column is named [Quotations.quote] because
|
|
|
|
|
/// `text` is the drift builder the declaration would otherwise call.
|
|
|
|
|
class Quotations extends Table {
|
|
|
|
|
IntColumn get id => integer().autoIncrement()();
|
|
|
|
|
TextColumn get pageUrl => text()();
|
|
|
|
|
TextColumn get pageTitle => text().nullable()();
|
|
|
|
|
TextColumn get author => text().nullable()();
|
|
|
|
|
TextColumn get siteTitle => text().nullable()();
|
|
|
|
|
TextColumn get quote => text()();
|
|
|
|
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
@DriftDatabase(tables: [
|
|
|
|
|
HistoryItems,
|
|
|
|
|
Bookmarks,
|
|
|
|
|
Subscriptions,
|
|
|
|
|
FeedEntries,
|
|
|
|
|
ItemTags,
|
|
|
|
|
ReaderSettings,
|
|
|
|
|
CachedPages,
|
2026-10-11 22:07:25 +03:00
|
|
|
KnownHosts,
|
|
|
|
|
ClientCertificates,
|
|
|
|
|
SiteCertificates,
|
2026-10-11 22:58:02 +03:00
|
|
|
FirstStart,
|
2026-10-12 10:37:32 +03:00
|
|
|
Quotations,
|
2026-10-11 13:12:48 +03:00
|
|
|
])
|
2026-10-11 09:33:25 +03:00
|
|
|
class MewsDatabase extends _$MewsDatabase {
|
|
|
|
|
MewsDatabase() : super(_open());
|
|
|
|
|
|
|
|
|
|
/// For tests.
|
|
|
|
|
MewsDatabase.test(super.executor);
|
|
|
|
|
|
|
|
|
|
@override
|
2026-10-12 10:37:32 +03:00
|
|
|
int get schemaVersion => 8;
|
2026-10-11 13:12:48 +03:00
|
|
|
|
|
|
|
|
@override
|
2026-10-11 14:54:43 +03:00
|
|
|
MigrationStrategy get migration => MigrationStrategy(
|
|
|
|
|
onCreate: (m) => m.createAll(),
|
|
|
|
|
onUpgrade: (m, from, to) async {
|
|
|
|
|
// Version 1 predates the entry store and the library tables' real
|
|
|
|
|
// primary keys. Nothing of it is worth carrying, so it is recreated;
|
|
|
|
|
// later versions only gain columns and keep what the reader has.
|
|
|
|
|
if (from < 2) {
|
|
|
|
|
for (final entity in allSchemaEntities.toList().reversed) {
|
|
|
|
|
await m.drop(entity);
|
|
|
|
|
}
|
|
|
|
|
await m.createAll();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (from < 3) {
|
|
|
|
|
await m.addColumn(readerSettings, readerSettings.language);
|
|
|
|
|
}
|
2026-10-11 22:07:25 +03:00
|
|
|
if (from < 4) {
|
|
|
|
|
await m.createTable(knownHosts);
|
|
|
|
|
}
|
|
|
|
|
if (from < 5) {
|
|
|
|
|
await m.createTable(clientCertificates);
|
|
|
|
|
await m.createTable(siteCertificates);
|
|
|
|
|
}
|
2026-10-11 22:58:02 +03:00
|
|
|
if (from < 6) {
|
|
|
|
|
await m.createTable(firstStart);
|
|
|
|
|
}
|
2026-10-12 10:37:32 +03:00
|
|
|
if (from < 7) {
|
|
|
|
|
await m.createTable(quotations);
|
|
|
|
|
}
|
|
|
|
|
if (from < 8) {
|
|
|
|
|
await m.addColumn(quotations, quotations.siteTitle);
|
|
|
|
|
}
|
2026-10-11 14:54:43 +03:00
|
|
|
},
|
|
|
|
|
);
|
2026-10-11 09:33:25 +03:00
|
|
|
|
|
|
|
|
static QueryExecutor _open() => LazyDatabase(() async {
|
|
|
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
|
|
|
return NativeDatabase.createInBackground(File(p.join(dir.path, 'mews.sqlite')));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// History -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
Future<void> recordVisit(String url, String? title) =>
|
|
|
|
|
(into(historyItems).insert(HistoryItemsCompanion.insert(url: url, title: Value(title))));
|
|
|
|
|
|
|
|
|
|
Future<String?> lastVisitedUrl() async {
|
|
|
|
|
final query = (select(historyItems)
|
|
|
|
|
..orderBy([
|
|
|
|
|
(r) => OrderingTerm.desc(r.visitedAt),
|
|
|
|
|
// Tiebreak pages recorded within the same clock tick.
|
|
|
|
|
(r) => OrderingTerm.desc(r.id),
|
|
|
|
|
])
|
|
|
|
|
..limit(1));
|
|
|
|
|
final row = await query.getSingleOrNull();
|
|
|
|
|
return row?.url;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:05:07 +03:00
|
|
|
/// Row counts for the hub, so it can say what each page holds without
|
2026-10-11 13:12:48 +03:00
|
|
|
/// reading every row. History counts addresses, not visits.
|
|
|
|
|
Future<int> countHistory() =>
|
|
|
|
|
_countOf(historyItems, historyItems.url, distinct: true);
|
|
|
|
|
Future<int> countBookmarks() => _countOf(bookmarks, bookmarks.url);
|
|
|
|
|
Future<int> countSubscriptions() => _countOf(subscriptions, subscriptions.url);
|
|
|
|
|
|
|
|
|
|
Future<int> _countOf<T extends Object>(
|
|
|
|
|
ResultSetImplementation<HasResultSet, dynamic> table,
|
|
|
|
|
GeneratedColumn<T> column,
|
|
|
|
|
{bool distinct = false}) async {
|
|
|
|
|
final total = column.count(distinct: distinct);
|
2026-10-11 13:05:07 +03:00
|
|
|
final query = selectOnly(table)..addColumns([total]);
|
|
|
|
|
return (await query.getSingle()).read(total) ?? 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:25:57 +03:00
|
|
|
/// The most recently visited pages, newest first, one row per address so a
|
|
|
|
|
/// page visited often is not listed over and over. [query] narrows by title
|
|
|
|
|
/// or address.
|
|
|
|
|
Future<List<VisitedPage>> recentPages({String? query, int limit = 200}) async {
|
|
|
|
|
// Group on the highest row id rather than the latest timestamp: visits
|
|
|
|
|
// recorded within the same clock tick share a timestamp, and sqlite then
|
|
|
|
|
// picks any one of their rows for the plain columns below. Ids always
|
|
|
|
|
// climb, so the newest visit is the one with the largest id.
|
|
|
|
|
final lastVisit = historyItems.id.max();
|
|
|
|
|
final statement = selectOnly(historyItems)
|
|
|
|
|
..addColumns([
|
|
|
|
|
historyItems.url,
|
|
|
|
|
historyItems.title,
|
|
|
|
|
historyItems.visitedAt,
|
|
|
|
|
lastVisit,
|
|
|
|
|
])
|
|
|
|
|
..groupBy([historyItems.url])
|
|
|
|
|
..orderBy([OrderingTerm.desc(lastVisit)])
|
|
|
|
|
..limit(limit);
|
|
|
|
|
final filter = _search([historyItems.url, historyItems.title], query);
|
|
|
|
|
if (filter != null) statement.where(filter);
|
|
|
|
|
|
|
|
|
|
return (await statement.get()).map((row) {
|
|
|
|
|
return VisitedPage(
|
|
|
|
|
// Selecting a plain column alongside max() gives the value from the
|
|
|
|
|
// row that matched it, so these describe the latest visit.
|
|
|
|
|
url: row.read(historyItems.url)!,
|
|
|
|
|
title: row.read(historyItems.title),
|
|
|
|
|
visitedAt: row.read(historyItems.visitedAt)!,
|
|
|
|
|
);
|
|
|
|
|
}).toList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Removes every visit to one page.
|
|
|
|
|
Future<void> forgetPage(String url) =>
|
|
|
|
|
(delete(historyItems)..where((r) => r.url.equals(url))).go();
|
|
|
|
|
|
|
|
|
|
Future<void> clearHistory() => delete(historyItems).go();
|
|
|
|
|
|
|
|
|
|
/// Narrows a list to rows where any of [columns] contains [query]. Returns
|
|
|
|
|
/// null when there is nothing to search for, so the caller adds no clause.
|
|
|
|
|
Expression<bool>? _search(
|
|
|
|
|
List<GeneratedColumn<String>> columns, String? query) {
|
|
|
|
|
final needle = query?.trim();
|
|
|
|
|
if (needle == null || needle.isEmpty) return null;
|
|
|
|
|
// The reader is typing text, not a pattern, so LIKE's wildcards are
|
|
|
|
|
// escaped and matched literally.
|
|
|
|
|
final pattern =
|
|
|
|
|
'%${needle.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%';
|
|
|
|
|
return columns
|
|
|
|
|
.map((column) => column.like(pattern, escapeChar: '\\'))
|
|
|
|
|
.reduce((a, b) => a | b);
|
2026-10-11 11:48:24 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:25:57 +03:00
|
|
|
/// Bookmarks in the order they were added, newest first. [query] narrows by
|
|
|
|
|
/// title or address.
|
2026-10-11 13:32:00 +03:00
|
|
|
Future<List<Bookmark>> bookmarksByDate({String? query, String? tag}) async {
|
2026-10-11 13:25:57 +03:00
|
|
|
final statement = (select(bookmarks)
|
2026-10-11 11:48:24 +03:00
|
|
|
..orderBy([
|
|
|
|
|
(r) => OrderingTerm.desc(r.createdAt),
|
2026-10-11 13:12:48 +03:00
|
|
|
// Tiebreak bookmarks added within the same clock tick.
|
|
|
|
|
(r) => OrderingTerm.asc(r.url),
|
2026-10-11 11:48:24 +03:00
|
|
|
]));
|
2026-10-11 13:25:57 +03:00
|
|
|
final filter = _search([bookmarks.url, bookmarks.title], query);
|
|
|
|
|
if (filter != null) statement.where((_) => filter);
|
2026-10-11 13:32:00 +03:00
|
|
|
if (tag != null && tag.isNotEmpty) {
|
|
|
|
|
statement.where((r) => r.url.isInQuery(_taggedUrls(TagKind.bookmark, tag)));
|
|
|
|
|
}
|
2026-10-11 13:25:57 +03:00
|
|
|
return statement.get();
|
2026-10-11 11:48:24 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
// Bookmarks -----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
Future<List<Bookmark>> allBookmarks() => select(bookmarks).get();
|
|
|
|
|
|
|
|
|
|
Future<void> addBookmark(String url, String? title) => into(bookmarks).insert(
|
|
|
|
|
BookmarksCompanion.insert(url: url, title: Value(title)),
|
|
|
|
|
mode: InsertMode.insertOrIgnore);
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
/// Removes a bookmark and whatever the reader tagged it with.
|
|
|
|
|
Future<void> removeBookmark(String url) => transaction(() async {
|
|
|
|
|
await (delete(bookmarks)..where((r) => r.url.equals(url))).go();
|
|
|
|
|
await _removeTagsOf(TagKind.bookmark, url);
|
|
|
|
|
});
|
2026-10-11 09:33:25 +03:00
|
|
|
|
2026-10-11 22:58:02 +03:00
|
|
|
/// Gives a fresh install the project's directory of sites as a bookmark,
|
|
|
|
|
/// so the reader starts somewhere. It happens once: removing it is the
|
|
|
|
|
/// reader's, and later starts never bring it back.
|
|
|
|
|
Future<void> seedDirectoryBookmark(String url, String title) async {
|
|
|
|
|
await transaction(() async {
|
|
|
|
|
if (await directorySeeded()) return;
|
|
|
|
|
await addBookmark(url, title);
|
|
|
|
|
await into(firstStart).insert(
|
|
|
|
|
FirstStartCompanion.insert(
|
|
|
|
|
id: const Value(0),
|
|
|
|
|
directorySeeded: const Value(true),
|
|
|
|
|
),
|
|
|
|
|
mode: InsertMode.insertOrReplace);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether the directory bookmark has been given to this install.
|
|
|
|
|
Future<bool> directorySeeded() async {
|
|
|
|
|
final row = await (select(firstStart)..where((r) => r.id.equals(0))).getSingleOrNull();
|
|
|
|
|
return row?.directorySeeded ?? false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
// Subscriptions ---------------------------------------------------------
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
Future<void> addSubscription(String url, String? title, String? atomHref) =>
|
|
|
|
|
into(subscriptions).insert(
|
2026-10-11 09:33:25 +03:00
|
|
|
SubscriptionsCompanion.insert(
|
2026-10-11 13:12:48 +03:00
|
|
|
url: url, title: Value(title), atomHref: Value(atomHref)),
|
2026-10-11 09:33:25 +03:00
|
|
|
mode: InsertMode.insertOrIgnore);
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
/// Removes a subscription along with its posts and tags, so nothing is left
|
|
|
|
|
/// pointing at a site the reader has left.
|
|
|
|
|
Future<void> removeSubscription(String url) => transaction(() async {
|
|
|
|
|
await (delete(subscriptions)..where((r) => r.url.equals(url))).go();
|
|
|
|
|
await (delete(feedEntries)..where((r) => r.siteUrl.equals(url))).go();
|
|
|
|
|
await _removeTagsOf(TagKind.subscription, url);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-11 13:25:57 +03:00
|
|
|
/// Subscribed sites, newest first. [query] narrows by title or address.
|
2026-10-11 13:32:00 +03:00
|
|
|
Future<List<Subscription>> allSubscriptions({String? query, String? tag}) async {
|
2026-10-11 13:25:57 +03:00
|
|
|
final statement = (select(subscriptions)
|
2026-10-11 13:12:48 +03:00
|
|
|
..orderBy([
|
|
|
|
|
(r) => OrderingTerm.desc(r.createdAt),
|
|
|
|
|
(r) => OrderingTerm.asc(r.url),
|
|
|
|
|
]));
|
2026-10-11 13:25:57 +03:00
|
|
|
final filter = _search([subscriptions.url, subscriptions.title], query);
|
|
|
|
|
if (filter != null) statement.where((_) => filter);
|
2026-10-11 13:32:00 +03:00
|
|
|
if (tag != null && tag.isNotEmpty) {
|
|
|
|
|
statement
|
|
|
|
|
.where((r) => r.url.isInQuery(_taggedUrls(TagKind.subscription, tag)));
|
|
|
|
|
}
|
2026-10-11 13:25:57 +03:00
|
|
|
return statement.get();
|
2026-10-11 13:12:48 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Records that a site was checked, whether or not anything was found.
|
|
|
|
|
Future<void> touchSubscription(String url, DateTime checkedAt) =>
|
|
|
|
|
(update(subscriptions)..where((r) => r.url.equals(url)))
|
|
|
|
|
.write(SubscriptionsCompanion(lastCheckedAt: Value(checkedAt)));
|
|
|
|
|
|
|
|
|
|
/// Updates what is known about a site. Each field is left alone when it is
|
|
|
|
|
/// not given, so a refresh cannot blank a stored title.
|
|
|
|
|
Future<void> describeSubscription(String url,
|
|
|
|
|
{String? title, String? atomHref}) =>
|
|
|
|
|
(update(subscriptions)..where((r) => r.url.equals(url))).write(
|
|
|
|
|
SubscriptionsCompanion(
|
|
|
|
|
title: title == null ? const Value.absent() : Value(title),
|
|
|
|
|
atomHref: atomHref == null ? const Value.absent() : Value(atomHref),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Posts -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Stores the posts of [siteUrl] that are not held yet and returns them, so
|
|
|
|
|
/// the caller can say what is new. An entry already held keeps its read
|
|
|
|
|
/// mark. Pass [read] to store them already read, which is how a new
|
|
|
|
|
/// subscription takes on a site's back catalogue without flagging it.
|
|
|
|
|
Future<List<MewsEntry>> addEntries(String siteUrl, List<MewsEntry> entries,
|
|
|
|
|
{bool read = false}) async {
|
|
|
|
|
if (entries.isEmpty) return const [];
|
|
|
|
|
final urls = entries.map((e) => e.url).toList();
|
|
|
|
|
final held = await (select(feedEntries)
|
|
|
|
|
..where((r) => r.siteUrl.equals(siteUrl) & r.url.isIn(urls)))
|
|
|
|
|
.get();
|
|
|
|
|
final known = held.map((r) => r.url).toSet();
|
|
|
|
|
final fresh = entries.where((e) => !known.contains(e.url)).toList();
|
|
|
|
|
if (fresh.isEmpty) return const [];
|
|
|
|
|
await batch((b) => b.insertAll(
|
|
|
|
|
feedEntries,
|
|
|
|
|
[
|
|
|
|
|
for (final entry in fresh)
|
|
|
|
|
FeedEntriesCompanion.insert(
|
|
|
|
|
siteUrl: siteUrl,
|
|
|
|
|
url: entry.url,
|
|
|
|
|
title: entry.title,
|
|
|
|
|
date: entry.date,
|
|
|
|
|
readAt: Value(read ? DateTime.now() : null),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
mode: InsertMode.insertOrIgnore,
|
|
|
|
|
));
|
|
|
|
|
return fresh;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Marks a post read. The address identifies a post for its lifetime, so
|
|
|
|
|
/// the site is only needed when two sites happen to list the same post.
|
|
|
|
|
Future<void> markEntryRead(String url, {String? siteUrl}) {
|
|
|
|
|
final statement = update(feedEntries)..where((r) => r.url.equals(url));
|
|
|
|
|
if (siteUrl != null) statement.where((r) => r.siteUrl.equals(siteUrl));
|
|
|
|
|
return statement.write(FeedEntriesCompanion(readAt: Value(DateTime.now())));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> markEntryUnread(String url, {String? siteUrl}) {
|
|
|
|
|
final statement = update(feedEntries)..where((r) => r.url.equals(url));
|
|
|
|
|
if (siteUrl != null) statement.where((r) => r.siteUrl.equals(siteUrl));
|
|
|
|
|
return statement.write(const FeedEntriesCompanion(readAt: Value(null)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:20:00 +03:00
|
|
|
/// Every stored post with the site it came from, for the posts page.
|
|
|
|
|
Future<List<RiverEntry>> riverEntries({
|
|
|
|
|
EntrySort sort = EntrySort.date,
|
|
|
|
|
bool unreadOnly = false,
|
|
|
|
|
int limit = 200,
|
|
|
|
|
}) async {
|
|
|
|
|
final site = alias(subscriptions, 'site');
|
|
|
|
|
final query = select(feedEntries).join([
|
|
|
|
|
leftOuterJoin(site, site.url.equalsExp(feedEntries.siteUrl)),
|
|
|
|
|
]);
|
|
|
|
|
if (unreadOnly) query.where(feedEntries.readAt.isNull());
|
|
|
|
|
|
|
|
|
|
// A site without a title sorts under its address, so the order never
|
|
|
|
|
// depends on a null.
|
|
|
|
|
final sourceName = coalesce([site.title, feedEntries.siteUrl]);
|
|
|
|
|
query.orderBy(switch (sort) {
|
|
|
|
|
EntrySort.date => [
|
|
|
|
|
OrderingTerm.desc(feedEntries.date),
|
|
|
|
|
OrderingTerm.asc(feedEntries.url),
|
|
|
|
|
],
|
|
|
|
|
EntrySort.source => [
|
|
|
|
|
OrderingTerm.asc(sourceName),
|
|
|
|
|
OrderingTerm.desc(feedEntries.date),
|
|
|
|
|
],
|
|
|
|
|
EntrySort.unread => [
|
|
|
|
|
// Unread first, whichever way the database orders nulls.
|
|
|
|
|
OrderingTerm.desc(feedEntries.readAt.isNull()),
|
|
|
|
|
OrderingTerm.desc(feedEntries.date),
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
query.limit(limit);
|
|
|
|
|
|
|
|
|
|
return (await query.get()).map((row) {
|
|
|
|
|
final entry = row.readTable(feedEntries);
|
|
|
|
|
return RiverEntry(
|
|
|
|
|
siteUrl: entry.siteUrl,
|
|
|
|
|
siteTitle: row.readTableOrNull(site)?.title,
|
|
|
|
|
url: entry.url,
|
|
|
|
|
title: entry.title,
|
|
|
|
|
date: entry.date,
|
|
|
|
|
readAt: entry.readAt,
|
|
|
|
|
);
|
|
|
|
|
}).toList();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
/// How many posts are unread, for one site or across all of them.
|
|
|
|
|
Future<int> unreadCount({String? siteUrl}) async {
|
|
|
|
|
final total = feedEntries.url.count();
|
|
|
|
|
final query = selectOnly(feedEntries)
|
|
|
|
|
..addColumns([total])
|
|
|
|
|
..where(feedEntries.readAt.isNull());
|
|
|
|
|
if (siteUrl != null) query.where(feedEntries.siteUrl.equals(siteUrl));
|
|
|
|
|
return (await query.getSingle()).read(total) ?? 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The unread count per site, so a list of sites costs one query.
|
|
|
|
|
Future<Map<String, int>> unreadCounts() async {
|
|
|
|
|
final total = feedEntries.url.count();
|
|
|
|
|
final query = selectOnly(feedEntries)
|
|
|
|
|
..addColumns([feedEntries.siteUrl, total])
|
|
|
|
|
..where(feedEntries.readAt.isNull())
|
|
|
|
|
..groupBy([feedEntries.siteUrl]);
|
|
|
|
|
return {
|
|
|
|
|
for (final row in await query.get())
|
|
|
|
|
row.read(feedEntries.siteUrl)!: row.read(total) ?? 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tags ---------------------------------------------------------------------
|
|
|
|
|
|
2026-10-11 13:32:00 +03:00
|
|
|
/// The addresses of one kind carrying [tag], for filtering a library page.
|
|
|
|
|
JoinedSelectStatement<ItemTags, ItemTag> _taggedUrls(
|
|
|
|
|
TagKind kind, String tag) {
|
|
|
|
|
return selectOnly(itemTags)
|
|
|
|
|
..addColumns([itemTags.url])
|
|
|
|
|
..where(itemTags.kind.equalsValue(kind) & itemTags.tag.equals(tag));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Every tag in use with how many bookmarks and sites carry it. A tag exists
|
|
|
|
|
/// only while something carries it, so this is the whole list of tags.
|
|
|
|
|
Future<Map<String, TagUse>> tagCounts() async {
|
|
|
|
|
final total = itemTags.url.count();
|
|
|
|
|
final query = selectOnly(itemTags)
|
|
|
|
|
..addColumns([itemTags.tag, itemTags.kind, total])
|
|
|
|
|
..groupBy([itemTags.tag, itemTags.kind])
|
|
|
|
|
..orderBy([OrderingTerm.asc(itemTags.tag)]);
|
|
|
|
|
|
|
|
|
|
final counts = <String, TagUse>{};
|
|
|
|
|
for (final row in await query.get()) {
|
|
|
|
|
final tag = row.read(itemTags.tag)!;
|
|
|
|
|
final kind = itemTags.kind.converter.fromSql(row.read(itemTags.kind)!);
|
|
|
|
|
final count = row.read(total) ?? 0;
|
|
|
|
|
final held = counts[tag] ?? const TagUse(bookmarks: 0, sites: 0);
|
|
|
|
|
counts[tag] = switch (kind) {
|
|
|
|
|
TagKind.bookmark => TagUse(bookmarks: count, sites: held.sites),
|
|
|
|
|
TagKind.subscription =>
|
|
|
|
|
TagUse(bookmarks: held.bookmarks, sites: count),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return counts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The tags on several items at once, so a list of rows costs one query.
|
|
|
|
|
Future<Map<String, List<String>>> tagsOfMany(
|
|
|
|
|
TagKind kind, List<String> urls) async {
|
|
|
|
|
if (urls.isEmpty) return const {};
|
|
|
|
|
final rows = await (select(itemTags)
|
|
|
|
|
..where((r) => r.kind.equalsValue(kind) & r.url.isIn(urls))
|
|
|
|
|
..orderBy([
|
|
|
|
|
(r) => OrderingTerm.asc(r.createdAt),
|
|
|
|
|
(r) => OrderingTerm.asc(r.tag),
|
|
|
|
|
]))
|
|
|
|
|
.get();
|
|
|
|
|
final tags = <String, List<String>>{};
|
|
|
|
|
for (final row in rows) {
|
|
|
|
|
(tags[row.url] ??= []).add(row.tag);
|
|
|
|
|
}
|
|
|
|
|
return tags;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Removes a tag from everything carrying it.
|
|
|
|
|
Future<void> deleteTag(String tag) =>
|
|
|
|
|
(delete(itemTags)..where((r) => r.tag.equals(tag))).go();
|
|
|
|
|
|
|
|
|
|
/// What the reader calls one bookmark or site, for titling its tags page.
|
|
|
|
|
Future<String?> itemTitle(TagKind kind, String url) async {
|
|
|
|
|
switch (kind) {
|
|
|
|
|
case TagKind.bookmark:
|
|
|
|
|
final row = await (select(bookmarks)..where((r) => r.url.equals(url)))
|
|
|
|
|
.getSingleOrNull();
|
|
|
|
|
return row?.title;
|
|
|
|
|
case TagKind.subscription:
|
|
|
|
|
final row =
|
|
|
|
|
await (select(subscriptions)..where((r) => r.url.equals(url)))
|
|
|
|
|
.getSingleOrNull();
|
|
|
|
|
return row?.title;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
/// The tags on one item, in the order they were added.
|
|
|
|
|
Future<List<String>> tagsOf(TagKind kind, String url) async {
|
|
|
|
|
final query = (select(itemTags)
|
|
|
|
|
..where((r) => r.kind.equalsValue(kind) & r.url.equals(url))
|
|
|
|
|
..orderBy([
|
|
|
|
|
(r) => OrderingTerm.asc(r.createdAt),
|
|
|
|
|
(r) => OrderingTerm.asc(r.tag),
|
|
|
|
|
]));
|
|
|
|
|
return (await query.get()).map((row) => row.tag).toList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> addTag(TagKind kind, String url, String tag) =>
|
|
|
|
|
into(itemTags).insert(
|
|
|
|
|
ItemTagsCompanion.insert(kind: kind, url: url, tag: tag),
|
|
|
|
|
mode: InsertMode.insertOrIgnore);
|
2026-10-11 09:33:25 +03:00
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
Future<void> removeTag(TagKind kind, String url, String tag) =>
|
|
|
|
|
(delete(itemTags)
|
|
|
|
|
..where((r) =>
|
|
|
|
|
r.kind.equalsValue(kind) &
|
|
|
|
|
r.url.equals(url) &
|
|
|
|
|
r.tag.equals(tag)))
|
|
|
|
|
.go();
|
2026-10-11 09:33:25 +03:00
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
Future<void> _removeTagsOf(TagKind kind, String url) =>
|
|
|
|
|
(delete(itemTags)
|
|
|
|
|
..where((r) => r.kind.equalsValue(kind) & r.url.equals(url)))
|
|
|
|
|
.go();
|
2026-10-11 09:33:25 +03:00
|
|
|
|
|
|
|
|
// Settings ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
Future<ReaderSetting?> readSettings() async {
|
|
|
|
|
final query = select(readerSettings)..where((r) => r.id.equals(0));
|
|
|
|
|
return query.getSingleOrNull();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> saveSettings({
|
|
|
|
|
required String font,
|
|
|
|
|
required double fontScale,
|
|
|
|
|
required String themeMode,
|
|
|
|
|
required String variant,
|
2026-10-11 14:54:43 +03:00
|
|
|
required String language,
|
2026-10-11 09:33:25 +03:00
|
|
|
}) =>
|
|
|
|
|
into(readerSettings).insert(
|
|
|
|
|
ReaderSettingsCompanion.insert(
|
|
|
|
|
id: const Value(0),
|
|
|
|
|
font: Value(font),
|
|
|
|
|
fontScale: Value(fontScale),
|
|
|
|
|
themeMode: Value(themeMode),
|
|
|
|
|
variant: Value(variant),
|
2026-10-11 14:54:43 +03:00
|
|
|
language: Value(language),
|
2026-10-11 09:33:25 +03:00
|
|
|
),
|
|
|
|
|
mode: InsertMode.insertOrReplace);
|
|
|
|
|
|
|
|
|
|
// Cache --------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
Future<CachedPage?> cachedPage(String url) async =>
|
|
|
|
|
(select(cachedPages)..where((r) => r.url.equals(url))).getSingleOrNull();
|
|
|
|
|
|
|
|
|
|
Future<void> saveCachedPage(CachedPagesCompanion entry) =>
|
|
|
|
|
into(cachedPages).insert(entry, mode: InsertMode.insertOrReplace);
|
2026-10-11 22:07:25 +03:00
|
|
|
|
|
|
|
|
// Known hosts ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Every pinned host, keyed `host:port`, for the fetch that checks each of
|
|
|
|
|
/// them, redirect targets included. The table holds only hosts the reader
|
|
|
|
|
/// has actually visited, so it is small enough to hand over whole.
|
|
|
|
|
Future<Map<String, TofuPin>> allPins() async {
|
|
|
|
|
final rows = await select(knownHosts).get();
|
|
|
|
|
return {
|
|
|
|
|
for (final row in rows)
|
|
|
|
|
'${row.host}:${row.port}':
|
|
|
|
|
TofuPin(fingerprint: row.fingerprint, expires: row.expires),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Pins a host's certificate: the first time the host was seen, a renewal
|
|
|
|
|
/// the old pin had expired for, or the reader choosing to trust a change.
|
|
|
|
|
Future<void> savePin(String host, int port, TofuPin pin) =>
|
|
|
|
|
into(knownHosts).insert(
|
|
|
|
|
KnownHostsCompanion.insert(
|
|
|
|
|
host: host,
|
|
|
|
|
port: port,
|
|
|
|
|
fingerprint: pin.fingerprint,
|
|
|
|
|
expires: pin.expires,
|
|
|
|
|
),
|
|
|
|
|
mode: InsertMode.insertOrReplace);
|
|
|
|
|
|
|
|
|
|
// Identities ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Every certificate the reader holds, oldest first.
|
|
|
|
|
Future<List<ClientCertificate>> allClientCertificates() =>
|
|
|
|
|
(select(clientCertificates)..orderBy([
|
|
|
|
|
(r) => OrderingTerm.asc(r.createdAt),
|
|
|
|
|
(r) => OrderingTerm.asc(r.id),
|
|
|
|
|
]))
|
|
|
|
|
.get();
|
|
|
|
|
|
|
|
|
|
Future<ClientCertificate?> clientCertificate(int id) =>
|
|
|
|
|
(select(clientCertificates)..where((r) => r.id.equals(id))).getSingleOrNull();
|
|
|
|
|
|
|
|
|
|
Future<int> addClientCertificate(String name, String certPem) =>
|
|
|
|
|
into(clientCertificates)
|
|
|
|
|
.insert(ClientCertificatesCompanion.insert(name: name, certPem: certPem));
|
|
|
|
|
|
|
|
|
|
Future<void> renameClientCertificate(int id, String name) =>
|
|
|
|
|
(update(clientCertificates)..where((r) => r.id.equals(id)))
|
|
|
|
|
.write(ClientCertificatesCompanion(name: Value(name)));
|
|
|
|
|
|
|
|
|
|
/// Removes a certificate together with the bindings that wear it, so no
|
|
|
|
|
/// site is left asking for a certificate that no longer exists.
|
|
|
|
|
Future<void> deleteClientCertificate(int id) => transaction(() async {
|
|
|
|
|
await (delete(siteCertificates)..where((r) => r.certificateId.equals(id))).go();
|
|
|
|
|
await (delete(clientCertificates)..where((r) => r.id.equals(id))).go();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// The certificate bound to one host, or null when the reader has not
|
|
|
|
|
/// chosen one for it.
|
|
|
|
|
Future<int?> bindingFor(String host, int port) async {
|
|
|
|
|
final row = await (select(siteCertificates)
|
|
|
|
|
..where((r) => r.host.equals(host) & r.port.equals(port)))
|
|
|
|
|
.getSingleOrNull();
|
|
|
|
|
return row?.certificateId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The hosts a certificate is presented to, so the page can say where an
|
|
|
|
|
/// identity is worn.
|
|
|
|
|
Future<List<String>> bindingsOf(int certificateId) async {
|
|
|
|
|
final rows = await (select(siteCertificates)
|
|
|
|
|
..where((r) => r.certificateId.equals(certificateId))
|
|
|
|
|
..orderBy([
|
|
|
|
|
(r) => OrderingTerm.asc(r.host),
|
|
|
|
|
(r) => OrderingTerm.asc(r.port),
|
|
|
|
|
]))
|
|
|
|
|
.get();
|
|
|
|
|
return [for (final row in rows) '${row.host}:${row.port}'];
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 10:37:32 +03:00
|
|
|
// Quotations --------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Keeps a marked stretch of text together with the page it came from,
|
|
|
|
|
/// and with the site's title when the reader's subscriptions know the site
|
|
|
|
|
/// the page belongs to: the site whose posts listed the page, or the page
|
|
|
|
|
/// itself when it is the subscribed one.
|
|
|
|
|
Future<void> addQuotation({
|
|
|
|
|
required String quote,
|
|
|
|
|
required String pageUrl,
|
|
|
|
|
String? pageTitle,
|
|
|
|
|
String? author,
|
|
|
|
|
}) async {
|
|
|
|
|
into(quotations).insert(QuotationsCompanion.insert(
|
|
|
|
|
quote: quote,
|
|
|
|
|
pageUrl: pageUrl,
|
|
|
|
|
pageTitle: Value(pageTitle),
|
|
|
|
|
author: Value(author),
|
|
|
|
|
siteTitle: Value(await siteTitleOf(pageUrl)),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The title of the site [pageUrl] belongs to, or null when the reader's
|
|
|
|
|
/// subscriptions do not know it. A page the site lists as a post takes the
|
|
|
|
|
/// subscribing site's title; a subscribed page is its own site.
|
|
|
|
|
Future<String?> siteTitleOf(String pageUrl) async {
|
|
|
|
|
final site = alias(subscriptions, 'site');
|
|
|
|
|
final throughPosts = await ((select(feedEntries).join([
|
|
|
|
|
leftOuterJoin(site, site.url.equalsExp(feedEntries.siteUrl)),
|
|
|
|
|
]))
|
|
|
|
|
..where(feedEntries.url.equals(pageUrl)))
|
|
|
|
|
.get();
|
|
|
|
|
if (throughPosts.isNotEmpty) {
|
|
|
|
|
return throughPosts.first.readTableOrNull(site)?.title;
|
|
|
|
|
}
|
|
|
|
|
final own =
|
|
|
|
|
await (select(subscriptions)..where((r) => r.url.equals(pageUrl)))
|
|
|
|
|
.getSingleOrNull();
|
|
|
|
|
return own?.title;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> removeQuotation(int id) =>
|
|
|
|
|
(delete(quotations)..where((q) => q.id.equals(id))).go();
|
|
|
|
|
|
|
|
|
|
/// One quotation, so an action address can act on it.
|
|
|
|
|
Future<Quotation?> quotationById(int id) =>
|
|
|
|
|
(select(quotations)..where((q) => q.id.equals(id))).getSingleOrNull();
|
|
|
|
|
|
|
|
|
|
/// Every kept quotation, for the quotations page. [query] narrows by the
|
|
|
|
|
/// quoted text, the page's title, its author or its address.
|
|
|
|
|
Future<List<Quotation>> quotationsList({
|
|
|
|
|
QuotationSort sort = QuotationSort.date,
|
|
|
|
|
String? query,
|
|
|
|
|
}) async {
|
|
|
|
|
final statement = select(quotations)
|
|
|
|
|
..orderBy(switch (sort) {
|
|
|
|
|
QuotationSort.date => [
|
|
|
|
|
// Tiebreak quotations saved within the same clock tick.
|
|
|
|
|
(r) => OrderingTerm.desc(r.createdAt),
|
|
|
|
|
(r) => OrderingTerm.asc(r.id),
|
|
|
|
|
],
|
|
|
|
|
QuotationSort.page => [
|
|
|
|
|
// A page without a title sorts under its address, so the order
|
|
|
|
|
// never depends on a null.
|
|
|
|
|
(r) => OrderingTerm.asc(coalesce([r.pageTitle, r.pageUrl])),
|
|
|
|
|
(r) => OrderingTerm.desc(r.createdAt),
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
final filter = _search(
|
|
|
|
|
[quotations.quote, quotations.pageTitle, quotations.author, quotations.pageUrl],
|
|
|
|
|
query);
|
|
|
|
|
if (filter != null) statement.where((_) => filter);
|
|
|
|
|
return statement.get();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// How many quotations are held, for the hub.
|
|
|
|
|
Future<int> countQuotations() async {
|
|
|
|
|
final total = quotations.id.count();
|
|
|
|
|
final query = selectOnly(quotations)..addColumns([total]);
|
|
|
|
|
return (await query.getSingle()).read(total) ?? 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:07:25 +03:00
|
|
|
/// Binds a certificate to a host, replacing whatever was bound before.
|
|
|
|
|
Future<void> bindCertificate(String host, int port, int certificateId) =>
|
|
|
|
|
into(siteCertificates).insert(
|
|
|
|
|
SiteCertificatesCompanion.insert(
|
|
|
|
|
host: host,
|
|
|
|
|
port: port,
|
|
|
|
|
certificateId: certificateId,
|
|
|
|
|
),
|
|
|
|
|
mode: InsertMode.insertOrReplace);
|
2026-10-11 09:33:25 +03:00
|
|
|
}
|
2026-10-11 13:20:00 +03:00
|
|
|
|
|
|
|
|
/// How the posts page is ordered.
|
|
|
|
|
enum EntrySort {
|
|
|
|
|
date,
|
|
|
|
|
source,
|
|
|
|
|
unread;
|
|
|
|
|
|
|
|
|
|
/// Reads the order from an address, falling back to the default rather than
|
|
|
|
|
/// refusing a value the reader may have typed.
|
|
|
|
|
static EntrySort parse(String? name) => switch (name) {
|
|
|
|
|
'source' => EntrySort.source,
|
|
|
|
|
'unread' => EntrySort.unread,
|
|
|
|
|
_ => EntrySort.date,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 10:37:32 +03:00
|
|
|
/// How the quotations page is ordered.
|
|
|
|
|
enum QuotationSort {
|
|
|
|
|
date,
|
|
|
|
|
page;
|
|
|
|
|
|
|
|
|
|
/// Reads the order from an address, falling back to the default rather than
|
|
|
|
|
/// refusing a value the reader may have typed.
|
|
|
|
|
static QuotationSort parse(String? name) =>
|
|
|
|
|
name == 'page' ? QuotationSort.page : QuotationSort.date;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:32:00 +03:00
|
|
|
/// How widely one tag is used.
|
|
|
|
|
class TagUse {
|
|
|
|
|
const TagUse({required this.bookmarks, required this.sites});
|
|
|
|
|
|
|
|
|
|
final int bookmarks;
|
|
|
|
|
final int sites;
|
|
|
|
|
|
|
|
|
|
int get total => bookmarks + sites;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:25:57 +03:00
|
|
|
/// One page the reader visited, collapsed to its latest visit.
|
|
|
|
|
class VisitedPage {
|
|
|
|
|
const VisitedPage({
|
|
|
|
|
required this.url,
|
|
|
|
|
required this.title,
|
|
|
|
|
required this.visitedAt,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final String url;
|
|
|
|
|
final String? title;
|
|
|
|
|
final DateTime visitedAt;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:20:00 +03:00
|
|
|
/// One post together with the site that published it.
|
|
|
|
|
class RiverEntry {
|
|
|
|
|
const RiverEntry({
|
|
|
|
|
required this.siteUrl,
|
|
|
|
|
required this.siteTitle,
|
|
|
|
|
required this.url,
|
|
|
|
|
required this.title,
|
|
|
|
|
required this.date,
|
|
|
|
|
required this.readAt,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final String siteUrl;
|
|
|
|
|
final String? siteTitle;
|
|
|
|
|
final String url;
|
|
|
|
|
final String title;
|
|
|
|
|
final DateTime date;
|
|
|
|
|
final DateTime? readAt;
|
|
|
|
|
|
|
|
|
|
bool get unread => readAt == null;
|
|
|
|
|
|
|
|
|
|
/// The site as the reader knows it: its title, or its address until one is
|
|
|
|
|
/// recorded.
|
|
|
|
|
String get siteName => siteTitle ?? siteUrl;
|
|
|
|
|
}
|