the-mews/app/lib/database/database.dart

520 lines
19 KiB
Dart

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';
import '../mews/document.dart' show MewsEntry;
part 'database.g.dart';
/// Visited pages, newest first; the most recent row is the app's start page.
/// 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})
class HistoryItems extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get url => text()();
TextColumn get title => text().nullable()();
DateTimeColumn get visitedAt => dateTime().withDefault(currentDateAndTime)();
}
/// A bookmarked page, keyed by its address so it can only be held once.
class Bookmarks extends Table {
TextColumn get url => text()();
TextColumn get title => text().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {url};
}
/// A subscribed page (spec 6). Its posts live in [FeedEntries].
class Subscriptions extends Table {
TextColumn get url => text()();
TextColumn get title => text().nullable()();
/// The page's Atom feed, resolved absolute, when it declares one (spec 6.2).
TextColumn get atomHref => text().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get lastCheckedAt => dateTime().nullable()();
@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};
}
/// 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'))();
@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};
}
@DriftDatabase(tables: [
HistoryItems,
Bookmarks,
Subscriptions,
FeedEntries,
ItemTags,
ReaderSettings,
CachedPages,
])
class MewsDatabase extends _$MewsDatabase {
MewsDatabase() : super(_open());
/// For tests.
MewsDatabase.test(super.executor);
@override
int get schemaVersion => 2;
/// Pre-release: the entry store replaces the old list of seen entry URLs and
/// the library tables gain real primary keys, so an existing database is
/// recreated rather than migrated.
@override
MigrationStrategy get migration => destructiveFallback;
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;
}
/// Row counts for the hub, so it can say what each page holds without
/// 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);
final query = selectOnly(table)..addColumns([total]);
return (await query.getSingle()).read(total) ?? 0;
}
/// 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);
}
/// Bookmarks in the order they were added, newest first. [query] narrows by
/// title or address.
Future<List<Bookmark>> bookmarksByDate({String? query}) async {
final statement = (select(bookmarks)
..orderBy([
(r) => OrderingTerm.desc(r.createdAt),
// Tiebreak bookmarks added within the same clock tick.
(r) => OrderingTerm.asc(r.url),
]));
final filter = _search([bookmarks.url, bookmarks.title], query);
if (filter != null) statement.where((_) => filter);
return statement.get();
}
// 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);
/// 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);
});
// Subscriptions ---------------------------------------------------------
Future<void> addSubscription(String url, String? title, String? atomHref) =>
into(subscriptions).insert(
SubscriptionsCompanion.insert(
url: url, title: Value(title), atomHref: Value(atomHref)),
mode: InsertMode.insertOrIgnore);
/// 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);
});
/// Subscribed sites, newest first. [query] narrows by title or address.
Future<List<Subscription>> allSubscriptions({String? query}) async {
final statement = (select(subscriptions)
..orderBy([
(r) => OrderingTerm.desc(r.createdAt),
(r) => OrderingTerm.asc(r.url),
]));
final filter = _search([subscriptions.url, subscriptions.title], query);
if (filter != null) statement.where((_) => filter);
return statement.get();
}
/// 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)));
}
/// 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();
}
/// 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 ---------------------------------------------------------------------
/// 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);
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();
Future<void> _removeTagsOf(TagKind kind, String url) =>
(delete(itemTags)
..where((r) => r.kind.equalsValue(kind) & r.url.equals(url)))
.go();
// 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,
}) =>
into(readerSettings).insert(
ReaderSettingsCompanion.insert(
id: const Value(0),
font: Value(font),
fontScale: Value(fontScale),
themeMode: Value(themeMode),
variant: Value(variant),
),
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);
}
/// 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,
};
}
/// 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;
}
/// 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;
}