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'))(); TextColumn get language => text().withDefault(const Constant('system'))(); @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 => 3; @override 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); } }, ); 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 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> 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 forgetPage(String url) => (delete(historyItems)..where((r) => r.url.equals(url))).go(); Future 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? _search( List> 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> bookmarksByDate({String? query, String? tag}) 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); if (tag != null && tag.isNotEmpty) { statement.where((r) => r.url.isInQuery(_taggedUrls(TagKind.bookmark, tag))); } return statement.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); }); /// Subscribed sites, newest first. [query] narrows by title or address. Future> allSubscriptions({String? query, String? tag}) 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); if (tag != null && tag.isNotEmpty) { statement .where((r) => r.url.isInQuery(_taggedUrls(TagKind.subscription, tag))); } return statement.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 addresses of one kind carrying [tag], for filtering a library page. JoinedSelectStatement _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> 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 = {}; 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>> tagsOfMany( TagKind kind, List 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 = >{}; for (final row in rows) { (tags[row.url] ??= []).add(row.tag); } return tags; } /// Removes a tag from everything carrying it. Future 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 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; } } /// 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, required String language, }) => into(readerSettings).insert( ReaderSettingsCompanion.insert( id: const Value(0), font: Value(font), fontScale: Value(fontScale), themeMode: Value(themeMode), variant: Value(variant), language: Value(language), ), 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, }; } /// 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; } /// 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; }