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 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 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 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()(); TextColumn get url => text()(); TextColumn get tag => text()(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); @override Set 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 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 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 recordVisit(String url, String? title) => (into(historyItems).insert(HistoryItemsCompanion.insert(url: url, title: Value(title)))); Future 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 countHistory() => _countOf(historyItems, historyItems.url, distinct: true); Future countBookmarks() => _countOf(bookmarks, bookmarks.url); Future countSubscriptions() => _countOf(subscriptions, subscriptions.url); Future _countOf( ResultSetImplementation table, GeneratedColumn 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 recent external visits, newest first. Future> recentVisits({int limit = 50}) async { final query = (select(historyItems) ..orderBy([ (r) => OrderingTerm.desc(r.visitedAt), (r) => OrderingTerm.desc(r.id), ]) ..limit(limit)); return query.get(); } /// Bookmarks in the order they were added, newest first. Future> bookmarksByDate() async { final query = (select(bookmarks) ..orderBy([ (r) => OrderingTerm.desc(r.createdAt), // Tiebreak bookmarks added within the same clock tick. (r) => OrderingTerm.asc(r.url), ])); return query.get(); } // Bookmarks ----------------------------------------------------------- Future> allBookmarks() => select(bookmarks).get(); Future 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 removeBookmark(String url) => transaction(() async { await (delete(bookmarks)..where((r) => r.url.equals(url))).go(); await _removeTagsOf(TagKind.bookmark, url); }); // Subscriptions --------------------------------------------------------- Future 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 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); }); Future> allSubscriptions() async { final query = (select(subscriptions) ..orderBy([ (r) => OrderingTerm.desc(r.createdAt), (r) => OrderingTerm.asc(r.url), ])); return query.get(); } /// Records that a site was checked, whether or not anything was found. Future 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 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> addEntries(String siteUrl, List 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 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 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> 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 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> 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> 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 addTag(TagKind kind, String url, String tag) => into(itemTags).insert( ItemTagsCompanion.insert(kind: kind, url: url, tag: tag), mode: InsertMode.insertOrIgnore); Future 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 _removeTagsOf(TagKind kind, String url) => (delete(itemTags) ..where((r) => r.kind.equalsValue(kind) & r.url.equals(url))) .go(); // Settings --------------------------------------------------------------- Future readSettings() async { final query = select(readerSettings)..where((r) => r.id.equals(0)); return query.getSingleOrNull(); } Future 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(String url) async => (select(cachedPages)..where((r) => r.url.equals(url))).getSingleOrNull(); Future 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 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; }