diff --git a/SPEC.md b/SPEC.md index fb08bc7..50f355e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -20,6 +20,8 @@ A Flutter browser/client for Mews Pages (see mews-spec). Local-first: all data is stored locally in a Drift database. Network operations are secondary and never block local functionality. +Posts found on a subscribed page are stored per site with a read mark, so an unread post survives a restart. A post's address identifies it for its lifetime (spec 6.1), so finding it again never clears its mark. Subscribing to a site stores what it already published as read, so a new subscription arrives without a backlog. + # Architecture directives Keep files small and focused. diff --git a/app/lib/application/controllers/bookmarks_controller.dart b/app/lib/application/controllers/bookmarks_controller.dart new file mode 100644 index 0000000..33f7ba8 --- /dev/null +++ b/app/lib/application/controllers/bookmarks_controller.dart @@ -0,0 +1,35 @@ +import 'package:signals/signals.dart'; + +import '../../database/database.dart'; + +/// Which pages the reader has bookmarked. Only the set of addresses is held +/// here, because that is all the chrome watches; the list itself is read from +/// the database whenever a page needs it. +class BookmarksController { + BookmarksController(this._db); + + final MewsDatabase _db; + + final bookmarkedUrls = signal({}); + + Future load() async { + final rows = await _db.allBookmarks(); + bookmarkedUrls.value = rows.map((row) => row.url).toSet(); + } + + bool isBookmarked(String url) => bookmarkedUrls.value.contains(url); + + Future toggle(String url, String? title) async { + if (isBookmarked(url)) { + await remove(url); + return; + } + await _db.addBookmark(url, title); + bookmarkedUrls.value = {...bookmarkedUrls.value, url}; + } + + Future remove(String url) async { + await _db.removeBookmark(url); + bookmarkedUrls.value = {...bookmarkedUrls.value}..remove(url); + } +} diff --git a/app/lib/application/controllers/reader_settings_controller.dart b/app/lib/application/controllers/reader_settings_controller.dart index 9614dbd..f35ba4f 100644 --- a/app/lib/application/controllers/reader_settings_controller.dart +++ b/app/lib/application/controllers/reader_settings_controller.dart @@ -1,9 +1,6 @@ -import 'dart:convert'; - import 'package:signals/signals.dart'; import '../../database/database.dart'; -import '../../mews/document.dart'; /// The reader's presentation choices (spec 8.2) plus the per-app limits they /// move within. Values persist through ReaderSettings in the database. @@ -76,75 +73,3 @@ class ReaderSettingsController { variant: variant.value, ); } - -/// Bookmarks and subscriptions. Signals expose derived state (which URLs are -/// bookmarked or subscribed); subscription refreshes piggyback on page loads, -/// comparing the page's dated links against the known entry URLs. -class LibraryController { - LibraryController(this._db); - - final MewsDatabase _db; - - final bookmarkedUrls = signal({}); - final subscribedUrls = signal({}); - - /// New posts found on the most recent refresh of a subscribed site, keyed by - /// site URL. Empty when there is nothing to report. - final newEntries = signal(>{}); - - Future load() async { - final bookmarks = await _db.allBookmarks(); - bookmarkedUrls.value = bookmarks.map((b) => b.url).toSet(); - final subs = await _db.allSubscriptions(); - subscribedUrls.value = subs.map((s) => s.url).toSet(); - } - - bool isBookmarked(String url) => bookmarkedUrls.value.contains(url); - bool isSubscribed(String url) => subscribedUrls.value.contains(url); - - Future toggleBookmark(String url, String? title) async { - if (isBookmarked(url)) { - await _db.removeBookmark(url); - bookmarkedUrls.value = {...bookmarkedUrls.value}..remove(url); - } else { - await _db.addBookmark(url, title); - bookmarkedUrls.value = {...bookmarkedUrls.value, url}; - } - } - - Future toggleSubscription(String url, String? title, List entries) async { - if (isSubscribed(url)) { - await _db.removeSubscription(url); - subscribedUrls.value = {...subscribedUrls.value}..remove(url); - newEntries.value = {...newEntries.value}..remove(url); - } else { - await _db.addSubscription(url, title, entries.map((e) => e.url).toList()); - subscribedUrls.value = {...subscribedUrls.value, url}; - newEntries.value = {...newEntries.value}..remove(url); - } - } - - /// Revalidates every subscribed page and reports entries the reader has not - /// seen yet (spec 6.1). Called when a subscribed page is opened. - Future refreshSubscription(MewsPage page) async { - final subs = await _db.allSubscriptions(); - final sub = subs.where((s) => s.url == page.url).firstOrNull; - if (sub == null) return; - - final known = (jsonDecode(sub.entryUrls) as List).cast().toSet(); - final fresh = page.entries.where((e) => !known.contains(e.url)).toList(); - if (fresh.isEmpty) { - // Nothing new this time, so the site stops being flagged. - newEntries.value = {...newEntries.value}..remove(sub.url); - await _db.updateSubscription(sub.url, lastCheckedAt: DateTime.now()); - return; - } - - newEntries.value = {...newEntries.value, page.url: fresh}; - await _db.updateSubscription( - sub.url, - entryUrls: [...known, ...fresh.map((e) => e.url)], - lastCheckedAt: DateTime.now(), - ); - } -} diff --git a/app/lib/application/controllers/subscriptions_controller.dart b/app/lib/application/controllers/subscriptions_controller.dart new file mode 100644 index 0000000..2af50d8 --- /dev/null +++ b/app/lib/application/controllers/subscriptions_controller.dart @@ -0,0 +1,50 @@ +import 'package:signals/signals.dart'; + +import '../../database/database.dart'; +import '../../mews/document.dart'; + +/// Which sites the reader subscribes to (spec 6). The posts themselves live in +/// the database, so an unread post survives a restart; only the set of +/// addresses is held here, because that is what the chrome watches. +class SubscriptionsController { + SubscriptionsController(this._db); + + final MewsDatabase _db; + + final subscribedUrls = signal({}); + + Future load() async { + final rows = await _db.allSubscriptions(); + subscribedUrls.value = rows.map((row) => row.url).toSet(); + } + + bool isSubscribed(String url) => subscribedUrls.value.contains(url); + + Future toggle(MewsPage page) async { + if (isSubscribed(page.url)) { + await remove(page.url); + return; + } + await _db.addSubscription(page.url, page.title, page.atomFeedHref); + subscribedUrls.value = {...subscribedUrls.value, page.url}; + // The posts already on the page are the site's back catalogue, not news, + // so they are stored read and only later posts are flagged. + await _db.addEntries(page.url, page.entries, read: true); + } + + Future remove(String siteUrl) async { + await _db.removeSubscription(siteUrl); + subscribedUrls.value = {...subscribedUrls.value}..remove(siteUrl); + } + + /// Records the posts on a subscribed page the reader just opened, and + /// returns the ones that are new. Does nothing for a site not subscribed to. + Future> refreshSite(MewsPage page) async { + if (!isSubscribed(page.url)) return const []; + await _db.describeSubscription(page.url, + title: page.title, atomHref: page.atomFeedHref); + final fresh = await _db.addEntries(page.url, page.entries); + await _db.touchSubscription(page.url, DateTime.now()); + return fresh; + } +} diff --git a/app/lib/application/internal_pages.dart b/app/lib/application/internal_pages.dart index fb9352d..db07d10 100644 --- a/app/lib/application/internal_pages.dart +++ b/app/lib/application/internal_pages.dart @@ -2,7 +2,7 @@ import '../database/database.dart'; import '../l10n/strings.dart'; import '../mews/document.dart'; import 'controllers/reader_settings_controller.dart' - show LibraryController, ReaderSettingsController; + show ReaderSettingsController; import 'pages/bookmarks_page.dart'; import 'pages/history_page.dart'; import 'pages/hub_page.dart'; @@ -16,16 +16,13 @@ import 'pages/subscriptions_page.dart'; class InternalPages { InternalPages({ required MewsDatabase db, - required LibraryController library, required ReaderSettingsController settings, required Strings strings, }) : _db = db, - _library = library, _settings = settings, _strings = strings; final MewsDatabase _db; - final LibraryController _library; final ReaderSettingsController _settings; final Strings _strings; @@ -39,8 +36,7 @@ class InternalPages { return switch (uri.path) { 'hub' => buildHubPage(uri, db: _db, strings: _strings), 'bookmarks' => buildBookmarksPage(uri, db: _db, strings: _strings), - 'subscriptions' => buildSubscriptionsPage(uri, - db: _db, library: _library, strings: _strings), + 'subscriptions' => buildSubscriptionsPage(uri, db: _db, strings: _strings), 'history' => buildHistoryPage(uri, db: _db, strings: _strings), 'settings' => buildSettingsPage(uri, settings: _settings, strings: _strings), diff --git a/app/lib/application/pages/subscriptions_page.dart b/app/lib/application/pages/subscriptions_page.dart index b758d5a..8400cbe 100644 --- a/app/lib/application/pages/subscriptions_page.dart +++ b/app/lib/application/pages/subscriptions_page.dart @@ -1,16 +1,15 @@ import '../../database/database.dart'; import '../../l10n/strings.dart'; import '../../mews/document.dart'; -import '../controllers/reader_settings_controller.dart' show LibraryController; import 'page_blocks.dart'; Future buildSubscriptionsPage( Uri url, { required MewsDatabase db, - required LibraryController library, required Strings strings, }) async { final sites = await db.allSubscriptions(); + final unread = await db.unreadCounts(); final title = strings.t('subscriptions-title'); return internalPage( @@ -27,17 +26,16 @@ Future buildSubscriptionsPage( href: site.url, label: site.title ?? site.url, date: dayText(site.createdAt), - note: _unreadNote(library, strings, site.url), + note: _unreadNote(strings, unread[site.url] ?? 0), ), ]), ], ); } -/// The unread count for a site, or null when it has nothing new, so the row -/// carries no empty line. -String? _unreadNote(LibraryController library, Strings strings, String siteUrl) { - final count = library.newEntries.value[siteUrl]?.length ?? 0; +/// The unread count for a site, or null when it has nothing unread, so the +/// row carries no second line. +String? _unreadNote(Strings strings, int count) { if (count == 0) return null; return countText(strings, count, none: 'count-unread-none', diff --git a/app/lib/database/database.dart b/app/lib/database/database.dart index 26a0b4a..8f17683 100644 --- a/app/lib/database/database.dart +++ b/app/lib/database/database.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'package:drift/drift.dart'; @@ -6,9 +5,14 @@ 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()(); @@ -16,22 +20,59 @@ class HistoryItems extends Table { DateTimeColumn get visitedAt => dateTime().withDefault(currentDateAndTime)(); } +/// A bookmarked page, keyed by its address so it can only be held once. class Bookmarks extends Table { - IntColumn get id => integer().autoIncrement()(); TextColumn get url => text()(); TextColumn get title => text().nullable()(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {url}; } -/// A subscription to one page (spec 6). [entryUrls] is a JSON list of the -/// entry URLs already seen, so a refresh can report only new posts. +/// A subscribed page (spec 6). Its posts live in [FeedEntries]. class Subscriptions extends Table { - IntColumn get id => integer().autoIncrement()(); TextColumn get url => text()(); TextColumn get title => text().nullable()(); - TextColumn get entryUrls => text().withDefault(const Constant('[]'))(); + + /// 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. @@ -59,7 +100,15 @@ class CachedPages extends Table { Set get primaryKey => {url}; } -@DriftDatabase(tables: [HistoryItems, Bookmarks, Subscriptions, ReaderSettings, CachedPages]) +@DriftDatabase(tables: [ + HistoryItems, + Bookmarks, + Subscriptions, + FeedEntries, + ItemTags, + ReaderSettings, + CachedPages, +]) class MewsDatabase extends _$MewsDatabase { MewsDatabase() : super(_open()); @@ -67,7 +116,13 @@ class MewsDatabase extends _$MewsDatabase { MewsDatabase.test(super.executor); @override - int get schemaVersion => 1; + 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(); @@ -92,14 +147,17 @@ class MewsDatabase extends _$MewsDatabase { } /// Row counts for the hub, so it can say what each page holds without - /// reading every row. - Future countHistory() => _countOf(historyItems, historyItems.id); - Future countBookmarks() => _countOf(bookmarks, bookmarks.id); - Future countSubscriptions() => _countOf(subscriptions, subscriptions.id); + /// 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) async { - final total = column.count(); + 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; } @@ -120,7 +178,8 @@ class MewsDatabase extends _$MewsDatabase { final query = (select(bookmarks) ..orderBy([ (r) => OrderingTerm.desc(r.createdAt), - (r) => OrderingTerm.desc(r.id), + // Tiebreak bookmarks added within the same clock tick. + (r) => OrderingTerm.asc(r.url), ])); return query.get(); } @@ -133,30 +192,153 @@ class MewsDatabase extends _$MewsDatabase { BookmarksCompanion.insert(url: url, title: Value(title)), mode: InsertMode.insertOrIgnore); - Future removeBookmark(String url) => - (delete(bookmarks)..where((r) => r.url.equals(url))).go(); + /// 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, List entryUrls) => into( - subscriptions) - .insert( + Future addSubscription(String url, String? title, String? atomHref) => + into(subscriptions).insert( SubscriptionsCompanion.insert( - url: url, title: Value(title), entryUrls: Value(jsonEncode(entryUrls))), + url: url, title: Value(title), atomHref: Value(atomHref)), mode: InsertMode.insertOrIgnore); - Future removeSubscription(String url) => - (delete(subscriptions)..where((r) => r.url.equals(url))).go(); + /// 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() => select(subscriptions).get(); + Future> allSubscriptions() async { + final query = (select(subscriptions) + ..orderBy([ + (r) => OrderingTerm.desc(r.createdAt), + (r) => OrderingTerm.asc(r.url), + ])); + return query.get(); + } - Future updateSubscription(String url, - {String? title, List? entryUrls, DateTime? lastCheckedAt}) => - (update(subscriptions)..where((r) => r.url.equals(url))).write(SubscriptionsCompanion( - title: Value(title), - entryUrls: entryUrls == null ? const Value.absent() : Value(jsonEncode(entryUrls)), - lastCheckedAt: Value(lastCheckedAt), - )); + /// 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))); + } + + /// 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 --------------------------------------------------------------- diff --git a/app/lib/database/database.g.dart b/app/lib/database/database.g.dart index bc7601c..afa737e 100644 --- a/app/lib/database/database.g.dart +++ b/app/lib/database/database.g.dart @@ -304,19 +304,6 @@ class $BookmarksTable extends Bookmarks final GeneratedDatabase attachedDatabase; final String? _alias; $BookmarksTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', - aliasedName, - false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'PRIMARY KEY AUTOINCREMENT', - ), - ); static const VerificationMeta _urlMeta = const VerificationMeta('url'); @override late final GeneratedColumn url = GeneratedColumn( @@ -348,7 +335,7 @@ class $BookmarksTable extends Bookmarks defaultValue: currentDateAndTime, ); @override - List get $columns => [id, url, title, createdAt]; + List get $columns => [url, title, createdAt]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -361,9 +348,6 @@ class $BookmarksTable extends Bookmarks }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } if (data.containsKey('url')) { context.handle( _urlMeta, @@ -388,15 +372,11 @@ class $BookmarksTable extends Bookmarks } @override - Set get $primaryKey => {id}; + Set get $primaryKey => {url}; @override Bookmark map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return Bookmark( - id: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, url: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}url'], @@ -419,20 +399,13 @@ class $BookmarksTable extends Bookmarks } class Bookmark extends DataClass implements Insertable { - final int id; final String url; final String? title; final DateTime createdAt; - const Bookmark({ - required this.id, - required this.url, - this.title, - required this.createdAt, - }); + const Bookmark({required this.url, this.title, required this.createdAt}); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['id'] = Variable(id); map['url'] = Variable(url); if (!nullToAbsent || title != null) { map['title'] = Variable(title); @@ -443,7 +416,6 @@ class Bookmark extends DataClass implements Insertable { BookmarksCompanion toCompanion(bool nullToAbsent) { return BookmarksCompanion( - id: Value(id), url: Value(url), title: title == null && nullToAbsent ? const Value.absent() @@ -458,7 +430,6 @@ class Bookmark extends DataClass implements Insertable { }) { serializer ??= driftRuntimeOptions.defaultSerializer; return Bookmark( - id: serializer.fromJson(json['id']), url: serializer.fromJson(json['url']), title: serializer.fromJson(json['title']), createdAt: serializer.fromJson(json['createdAt']), @@ -468,7 +439,6 @@ class Bookmark extends DataClass implements Insertable { Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'id': serializer.toJson(id), 'url': serializer.toJson(url), 'title': serializer.toJson(title), 'createdAt': serializer.toJson(createdAt), @@ -476,19 +446,16 @@ class Bookmark extends DataClass implements Insertable { } Bookmark copyWith({ - int? id, String? url, Value title = const Value.absent(), DateTime? createdAt, }) => Bookmark( - id: id ?? this.id, url: url ?? this.url, title: title.present ? title.value : this.title, createdAt: createdAt ?? this.createdAt, ); Bookmark copyWithCompanion(BookmarksCompanion data) { return Bookmark( - id: data.id.present ? data.id.value : this.id, url: data.url.present ? data.url.value : this.url, title: data.title.present ? data.title.value : this.title, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, @@ -498,7 +465,6 @@ class Bookmark extends DataClass implements Insertable { @override String toString() { return (StringBuffer('Bookmark(') - ..write('id: $id, ') ..write('url: $url, ') ..write('title: $title, ') ..write('createdAt: $createdAt') @@ -507,68 +473,64 @@ class Bookmark extends DataClass implements Insertable { } @override - int get hashCode => Object.hash(id, url, title, createdAt); + int get hashCode => Object.hash(url, title, createdAt); @override bool operator ==(Object other) => identical(this, other) || (other is Bookmark && - other.id == this.id && other.url == this.url && other.title == this.title && other.createdAt == this.createdAt); } class BookmarksCompanion extends UpdateCompanion { - final Value id; final Value url; final Value title; final Value createdAt; + final Value rowid; const BookmarksCompanion({ - this.id = const Value.absent(), this.url = const Value.absent(), this.title = const Value.absent(), this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), }); BookmarksCompanion.insert({ - this.id = const Value.absent(), required String url, this.title = const Value.absent(), this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), }) : url = Value(url); static Insertable custom({ - Expression? id, Expression? url, Expression? title, Expression? createdAt, + Expression? rowid, }) { return RawValuesInsertable({ - if (id != null) 'id': id, if (url != null) 'url': url, if (title != null) 'title': title, if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, }); } BookmarksCompanion copyWith({ - Value? id, Value? url, Value? title, Value? createdAt, + Value? rowid, }) { return BookmarksCompanion( - id: id ?? this.id, url: url ?? this.url, title: title ?? this.title, createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } if (url.present) { map['url'] = Variable(url.value); } @@ -578,16 +540,19 @@ class BookmarksCompanion extends UpdateCompanion { if (createdAt.present) { map['created_at'] = Variable(createdAt.value); } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } return map; } @override String toString() { return (StringBuffer('BookmarksCompanion(') - ..write('id: $id, ') ..write('url: $url, ') ..write('title: $title, ') - ..write('createdAt: $createdAt') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') ..write(')')) .toString(); } @@ -599,19 +564,6 @@ class $SubscriptionsTable extends Subscriptions final GeneratedDatabase attachedDatabase; final String? _alias; $SubscriptionsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', - aliasedName, - false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'PRIMARY KEY AUTOINCREMENT', - ), - ); static const VerificationMeta _urlMeta = const VerificationMeta('url'); @override late final GeneratedColumn url = GeneratedColumn( @@ -630,17 +582,16 @@ class $SubscriptionsTable extends Subscriptions type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _entryUrlsMeta = const VerificationMeta( - 'entryUrls', + static const VerificationMeta _atomHrefMeta = const VerificationMeta( + 'atomHref', ); @override - late final GeneratedColumn entryUrls = GeneratedColumn( - 'entry_urls', + late final GeneratedColumn atomHref = GeneratedColumn( + 'atom_href', aliasedName, - false, + true, type: DriftSqlType.string, requiredDuringInsert: false, - defaultValue: const Constant('[]'), ); static const VerificationMeta _createdAtMeta = const VerificationMeta( 'createdAt', @@ -668,10 +619,9 @@ class $SubscriptionsTable extends Subscriptions ); @override List get $columns => [ - id, url, title, - entryUrls, + atomHref, createdAt, lastCheckedAt, ]; @@ -687,9 +637,6 @@ class $SubscriptionsTable extends Subscriptions }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } if (data.containsKey('url')) { context.handle( _urlMeta, @@ -704,10 +651,10 @@ class $SubscriptionsTable extends Subscriptions title.isAcceptableOrUnknown(data['title']!, _titleMeta), ); } - if (data.containsKey('entry_urls')) { + if (data.containsKey('atom_href')) { context.handle( - _entryUrlsMeta, - entryUrls.isAcceptableOrUnknown(data['entry_urls']!, _entryUrlsMeta), + _atomHrefMeta, + atomHref.isAcceptableOrUnknown(data['atom_href']!, _atomHrefMeta), ); } if (data.containsKey('created_at')) { @@ -729,15 +676,11 @@ class $SubscriptionsTable extends Subscriptions } @override - Set get $primaryKey => {id}; + Set get $primaryKey => {url}; @override Subscription map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return Subscription( - id: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], - )!, url: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}url'], @@ -746,10 +689,10 @@ class $SubscriptionsTable extends Subscriptions DriftSqlType.string, data['${effectivePrefix}title'], ), - entryUrls: attachedDatabase.typeMapping.read( + atomHref: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}entry_urls'], - )!, + data['${effectivePrefix}atom_href'], + ), createdAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}created_at'], @@ -768,29 +711,30 @@ class $SubscriptionsTable extends Subscriptions } class Subscription extends DataClass implements Insertable { - final int id; final String url; final String? title; - final String entryUrls; + + /// The page's Atom feed, resolved absolute, when it declares one (spec 6.2). + final String? atomHref; final DateTime createdAt; final DateTime? lastCheckedAt; const Subscription({ - required this.id, required this.url, this.title, - required this.entryUrls, + this.atomHref, required this.createdAt, this.lastCheckedAt, }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['id'] = Variable(id); map['url'] = Variable(url); if (!nullToAbsent || title != null) { map['title'] = Variable(title); } - map['entry_urls'] = Variable(entryUrls); + if (!nullToAbsent || atomHref != null) { + map['atom_href'] = Variable(atomHref); + } map['created_at'] = Variable(createdAt); if (!nullToAbsent || lastCheckedAt != null) { map['last_checked_at'] = Variable(lastCheckedAt); @@ -800,12 +744,13 @@ class Subscription extends DataClass implements Insertable { SubscriptionsCompanion toCompanion(bool nullToAbsent) { return SubscriptionsCompanion( - id: Value(id), url: Value(url), title: title == null && nullToAbsent ? const Value.absent() : Value(title), - entryUrls: Value(entryUrls), + atomHref: atomHref == null && nullToAbsent + ? const Value.absent() + : Value(atomHref), createdAt: Value(createdAt), lastCheckedAt: lastCheckedAt == null && nullToAbsent ? const Value.absent() @@ -819,10 +764,9 @@ class Subscription extends DataClass implements Insertable { }) { serializer ??= driftRuntimeOptions.defaultSerializer; return Subscription( - id: serializer.fromJson(json['id']), url: serializer.fromJson(json['url']), title: serializer.fromJson(json['title']), - entryUrls: serializer.fromJson(json['entryUrls']), + atomHref: serializer.fromJson(json['atomHref']), createdAt: serializer.fromJson(json['createdAt']), lastCheckedAt: serializer.fromJson(json['lastCheckedAt']), ); @@ -831,27 +775,24 @@ class Subscription extends DataClass implements Insertable { Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'id': serializer.toJson(id), 'url': serializer.toJson(url), 'title': serializer.toJson(title), - 'entryUrls': serializer.toJson(entryUrls), + 'atomHref': serializer.toJson(atomHref), 'createdAt': serializer.toJson(createdAt), 'lastCheckedAt': serializer.toJson(lastCheckedAt), }; } Subscription copyWith({ - int? id, String? url, Value title = const Value.absent(), - String? entryUrls, + Value atomHref = const Value.absent(), DateTime? createdAt, Value lastCheckedAt = const Value.absent(), }) => Subscription( - id: id ?? this.id, url: url ?? this.url, title: title.present ? title.value : this.title, - entryUrls: entryUrls ?? this.entryUrls, + atomHref: atomHref.present ? atomHref.value : this.atomHref, createdAt: createdAt ?? this.createdAt, lastCheckedAt: lastCheckedAt.present ? lastCheckedAt.value @@ -859,10 +800,9 @@ class Subscription extends DataClass implements Insertable { ); Subscription copyWithCompanion(SubscriptionsCompanion data) { return Subscription( - id: data.id.present ? data.id.value : this.id, url: data.url.present ? data.url.value : this.url, title: data.title.present ? data.title.value : this.title, - entryUrls: data.entryUrls.present ? data.entryUrls.value : this.entryUrls, + atomHref: data.atomHref.present ? data.atomHref.value : this.atomHref, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, lastCheckedAt: data.lastCheckedAt.present ? data.lastCheckedAt.value @@ -873,10 +813,9 @@ class Subscription extends DataClass implements Insertable { @override String toString() { return (StringBuffer('Subscription(') - ..write('id: $id, ') ..write('url: $url, ') ..write('title: $title, ') - ..write('entryUrls: $entryUrls, ') + ..write('atomHref: $atomHref, ') ..write('createdAt: $createdAt, ') ..write('lastCheckedAt: $lastCheckedAt') ..write(')')) @@ -885,92 +824,88 @@ class Subscription extends DataClass implements Insertable { @override int get hashCode => - Object.hash(id, url, title, entryUrls, createdAt, lastCheckedAt); + Object.hash(url, title, atomHref, createdAt, lastCheckedAt); @override bool operator ==(Object other) => identical(this, other) || (other is Subscription && - other.id == this.id && other.url == this.url && other.title == this.title && - other.entryUrls == this.entryUrls && + other.atomHref == this.atomHref && other.createdAt == this.createdAt && other.lastCheckedAt == this.lastCheckedAt); } class SubscriptionsCompanion extends UpdateCompanion { - final Value id; final Value url; final Value title; - final Value entryUrls; + final Value atomHref; final Value createdAt; final Value lastCheckedAt; + final Value rowid; const SubscriptionsCompanion({ - this.id = const Value.absent(), this.url = const Value.absent(), this.title = const Value.absent(), - this.entryUrls = const Value.absent(), + this.atomHref = const Value.absent(), this.createdAt = const Value.absent(), this.lastCheckedAt = const Value.absent(), + this.rowid = const Value.absent(), }); SubscriptionsCompanion.insert({ - this.id = const Value.absent(), required String url, this.title = const Value.absent(), - this.entryUrls = const Value.absent(), + this.atomHref = const Value.absent(), this.createdAt = const Value.absent(), this.lastCheckedAt = const Value.absent(), + this.rowid = const Value.absent(), }) : url = Value(url); static Insertable custom({ - Expression? id, Expression? url, Expression? title, - Expression? entryUrls, + Expression? atomHref, Expression? createdAt, Expression? lastCheckedAt, + Expression? rowid, }) { return RawValuesInsertable({ - if (id != null) 'id': id, if (url != null) 'url': url, if (title != null) 'title': title, - if (entryUrls != null) 'entry_urls': entryUrls, + if (atomHref != null) 'atom_href': atomHref, if (createdAt != null) 'created_at': createdAt, if (lastCheckedAt != null) 'last_checked_at': lastCheckedAt, + if (rowid != null) 'rowid': rowid, }); } SubscriptionsCompanion copyWith({ - Value? id, Value? url, Value? title, - Value? entryUrls, + Value? atomHref, Value? createdAt, Value? lastCheckedAt, + Value? rowid, }) { return SubscriptionsCompanion( - id: id ?? this.id, url: url ?? this.url, title: title ?? this.title, - entryUrls: entryUrls ?? this.entryUrls, + atomHref: atomHref ?? this.atomHref, createdAt: createdAt ?? this.createdAt, lastCheckedAt: lastCheckedAt ?? this.lastCheckedAt, + rowid: rowid ?? this.rowid, ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } if (url.present) { map['url'] = Variable(url.value); } if (title.present) { map['title'] = Variable(title.value); } - if (entryUrls.present) { - map['entry_urls'] = Variable(entryUrls.value); + if (atomHref.present) { + map['atom_href'] = Variable(atomHref.value); } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); @@ -978,18 +913,737 @@ class SubscriptionsCompanion extends UpdateCompanion { if (lastCheckedAt.present) { map['last_checked_at'] = Variable(lastCheckedAt.value); } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } return map; } @override String toString() { return (StringBuffer('SubscriptionsCompanion(') - ..write('id: $id, ') ..write('url: $url, ') ..write('title: $title, ') - ..write('entryUrls: $entryUrls, ') + ..write('atomHref: $atomHref, ') ..write('createdAt: $createdAt, ') - ..write('lastCheckedAt: $lastCheckedAt') + ..write('lastCheckedAt: $lastCheckedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $FeedEntriesTable extends FeedEntries + with TableInfo<$FeedEntriesTable, FeedEntry> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $FeedEntriesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _siteUrlMeta = const VerificationMeta( + 'siteUrl', + ); + @override + late final GeneratedColumn siteUrl = GeneratedColumn( + 'site_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _urlMeta = const VerificationMeta('url'); + @override + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _dateMeta = const VerificationMeta('date'); + @override + late final GeneratedColumn date = GeneratedColumn( + 'date', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _foundAtMeta = const VerificationMeta( + 'foundAt', + ); + @override + late final GeneratedColumn foundAt = GeneratedColumn( + 'found_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _readAtMeta = const VerificationMeta('readAt'); + @override + late final GeneratedColumn readAt = GeneratedColumn( + 'read_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + siteUrl, + url, + title, + date, + foundAt, + readAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'feed_entries'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('site_url')) { + context.handle( + _siteUrlMeta, + siteUrl.isAcceptableOrUnknown(data['site_url']!, _siteUrlMeta), + ); + } else if (isInserting) { + context.missing(_siteUrlMeta); + } + if (data.containsKey('url')) { + context.handle( + _urlMeta, + url.isAcceptableOrUnknown(data['url']!, _urlMeta), + ); + } else if (isInserting) { + context.missing(_urlMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('date')) { + context.handle( + _dateMeta, + date.isAcceptableOrUnknown(data['date']!, _dateMeta), + ); + } else if (isInserting) { + context.missing(_dateMeta); + } + if (data.containsKey('found_at')) { + context.handle( + _foundAtMeta, + foundAt.isAcceptableOrUnknown(data['found_at']!, _foundAtMeta), + ); + } + if (data.containsKey('read_at')) { + context.handle( + _readAtMeta, + readAt.isAcceptableOrUnknown(data['read_at']!, _readAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {siteUrl, url}; + @override + FeedEntry map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return FeedEntry( + siteUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}site_url'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + date: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}date'], + )!, + foundAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}found_at'], + )!, + readAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}read_at'], + ), + ); + } + + @override + $FeedEntriesTable createAlias(String alias) { + return $FeedEntriesTable(attachedDatabase, alias); + } +} + +class FeedEntry extends DataClass implements Insertable { + final String siteUrl; + final String url; + final String title; + final DateTime date; + final DateTime foundAt; + final DateTime? readAt; + const FeedEntry({ + required this.siteUrl, + required this.url, + required this.title, + required this.date, + required this.foundAt, + this.readAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['site_url'] = Variable(siteUrl); + map['url'] = Variable(url); + map['title'] = Variable(title); + map['date'] = Variable(date); + map['found_at'] = Variable(foundAt); + if (!nullToAbsent || readAt != null) { + map['read_at'] = Variable(readAt); + } + return map; + } + + FeedEntriesCompanion toCompanion(bool nullToAbsent) { + return FeedEntriesCompanion( + siteUrl: Value(siteUrl), + url: Value(url), + title: Value(title), + date: Value(date), + foundAt: Value(foundAt), + readAt: readAt == null && nullToAbsent + ? const Value.absent() + : Value(readAt), + ); + } + + factory FeedEntry.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return FeedEntry( + siteUrl: serializer.fromJson(json['siteUrl']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + date: serializer.fromJson(json['date']), + foundAt: serializer.fromJson(json['foundAt']), + readAt: serializer.fromJson(json['readAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'siteUrl': serializer.toJson(siteUrl), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'date': serializer.toJson(date), + 'foundAt': serializer.toJson(foundAt), + 'readAt': serializer.toJson(readAt), + }; + } + + FeedEntry copyWith({ + String? siteUrl, + String? url, + String? title, + DateTime? date, + DateTime? foundAt, + Value readAt = const Value.absent(), + }) => FeedEntry( + siteUrl: siteUrl ?? this.siteUrl, + url: url ?? this.url, + title: title ?? this.title, + date: date ?? this.date, + foundAt: foundAt ?? this.foundAt, + readAt: readAt.present ? readAt.value : this.readAt, + ); + FeedEntry copyWithCompanion(FeedEntriesCompanion data) { + return FeedEntry( + siteUrl: data.siteUrl.present ? data.siteUrl.value : this.siteUrl, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + date: data.date.present ? data.date.value : this.date, + foundAt: data.foundAt.present ? data.foundAt.value : this.foundAt, + readAt: data.readAt.present ? data.readAt.value : this.readAt, + ); + } + + @override + String toString() { + return (StringBuffer('FeedEntry(') + ..write('siteUrl: $siteUrl, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('date: $date, ') + ..write('foundAt: $foundAt, ') + ..write('readAt: $readAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(siteUrl, url, title, date, foundAt, readAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is FeedEntry && + other.siteUrl == this.siteUrl && + other.url == this.url && + other.title == this.title && + other.date == this.date && + other.foundAt == this.foundAt && + other.readAt == this.readAt); +} + +class FeedEntriesCompanion extends UpdateCompanion { + final Value siteUrl; + final Value url; + final Value title; + final Value date; + final Value foundAt; + final Value readAt; + final Value rowid; + const FeedEntriesCompanion({ + this.siteUrl = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.date = const Value.absent(), + this.foundAt = const Value.absent(), + this.readAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + FeedEntriesCompanion.insert({ + required String siteUrl, + required String url, + required String title, + required DateTime date, + this.foundAt = const Value.absent(), + this.readAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : siteUrl = Value(siteUrl), + url = Value(url), + title = Value(title), + date = Value(date); + static Insertable custom({ + Expression? siteUrl, + Expression? url, + Expression? title, + Expression? date, + Expression? foundAt, + Expression? readAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (siteUrl != null) 'site_url': siteUrl, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (date != null) 'date': date, + if (foundAt != null) 'found_at': foundAt, + if (readAt != null) 'read_at': readAt, + if (rowid != null) 'rowid': rowid, + }); + } + + FeedEntriesCompanion copyWith({ + Value? siteUrl, + Value? url, + Value? title, + Value? date, + Value? foundAt, + Value? readAt, + Value? rowid, + }) { + return FeedEntriesCompanion( + siteUrl: siteUrl ?? this.siteUrl, + url: url ?? this.url, + title: title ?? this.title, + date: date ?? this.date, + foundAt: foundAt ?? this.foundAt, + readAt: readAt ?? this.readAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (siteUrl.present) { + map['site_url'] = Variable(siteUrl.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (date.present) { + map['date'] = Variable(date.value); + } + if (foundAt.present) { + map['found_at'] = Variable(foundAt.value); + } + if (readAt.present) { + map['read_at'] = Variable(readAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('FeedEntriesCompanion(') + ..write('siteUrl: $siteUrl, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('date: $date, ') + ..write('foundAt: $foundAt, ') + ..write('readAt: $readAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $ItemTagsTable extends ItemTags with TableInfo<$ItemTagsTable, ItemTag> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ItemTagsTable(this.attachedDatabase, [this._alias]); + @override + late final GeneratedColumnWithTypeConverter kind = + GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter($ItemTagsTable.$converterkind); + static const VerificationMeta _urlMeta = const VerificationMeta('url'); + @override + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _tagMeta = const VerificationMeta('tag'); + @override + late final GeneratedColumn tag = GeneratedColumn( + 'tag', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [kind, url, tag, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'item_tags'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('url')) { + context.handle( + _urlMeta, + url.isAcceptableOrUnknown(data['url']!, _urlMeta), + ); + } else if (isInserting) { + context.missing(_urlMeta); + } + if (data.containsKey('tag')) { + context.handle( + _tagMeta, + tag.isAcceptableOrUnknown(data['tag']!, _tagMeta), + ); + } else if (isInserting) { + context.missing(_tagMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {kind, url, tag}; + @override + ItemTag map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ItemTag( + kind: $ItemTagsTable.$converterkind.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}kind'], + )!, + ), + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + tag: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tag'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $ItemTagsTable createAlias(String alias) { + return $ItemTagsTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 $converterkind = + const EnumNameConverter(TagKind.values); +} + +class ItemTag extends DataClass implements Insertable { + final TagKind kind; + final String url; + final String tag; + final DateTime createdAt; + const ItemTag({ + required this.kind, + required this.url, + required this.tag, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + { + map['kind'] = Variable($ItemTagsTable.$converterkind.toSql(kind)); + } + map['url'] = Variable(url); + map['tag'] = Variable(tag); + map['created_at'] = Variable(createdAt); + return map; + } + + ItemTagsCompanion toCompanion(bool nullToAbsent) { + return ItemTagsCompanion( + kind: Value(kind), + url: Value(url), + tag: Value(tag), + createdAt: Value(createdAt), + ); + } + + factory ItemTag.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ItemTag( + kind: $ItemTagsTable.$converterkind.fromJson( + serializer.fromJson(json['kind']), + ), + url: serializer.fromJson(json['url']), + tag: serializer.fromJson(json['tag']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'kind': serializer.toJson( + $ItemTagsTable.$converterkind.toJson(kind), + ), + 'url': serializer.toJson(url), + 'tag': serializer.toJson(tag), + 'createdAt': serializer.toJson(createdAt), + }; + } + + ItemTag copyWith({ + TagKind? kind, + String? url, + String? tag, + DateTime? createdAt, + }) => ItemTag( + kind: kind ?? this.kind, + url: url ?? this.url, + tag: tag ?? this.tag, + createdAt: createdAt ?? this.createdAt, + ); + ItemTag copyWithCompanion(ItemTagsCompanion data) { + return ItemTag( + kind: data.kind.present ? data.kind.value : this.kind, + url: data.url.present ? data.url.value : this.url, + tag: data.tag.present ? data.tag.value : this.tag, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('ItemTag(') + ..write('kind: $kind, ') + ..write('url: $url, ') + ..write('tag: $tag, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(kind, url, tag, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ItemTag && + other.kind == this.kind && + other.url == this.url && + other.tag == this.tag && + other.createdAt == this.createdAt); +} + +class ItemTagsCompanion extends UpdateCompanion { + final Value kind; + final Value url; + final Value tag; + final Value createdAt; + final Value rowid; + const ItemTagsCompanion({ + this.kind = const Value.absent(), + this.url = const Value.absent(), + this.tag = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ItemTagsCompanion.insert({ + required TagKind kind, + required String url, + required String tag, + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : kind = Value(kind), + url = Value(url), + tag = Value(tag); + static Insertable custom({ + Expression? kind, + Expression? url, + Expression? tag, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (kind != null) 'kind': kind, + if (url != null) 'url': url, + if (tag != null) 'tag': tag, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ItemTagsCompanion copyWith({ + Value? kind, + Value? url, + Value? tag, + Value? createdAt, + Value? rowid, + }) { + return ItemTagsCompanion( + kind: kind ?? this.kind, + url: url ?? this.url, + tag: tag ?? this.tag, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (kind.present) { + map['kind'] = Variable( + $ItemTagsTable.$converterkind.toSql(kind.value), + ); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (tag.present) { + map['tag'] = Variable(tag.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ItemTagsCompanion(') + ..write('kind: $kind, ') + ..write('url: $url, ') + ..write('tag: $tag, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') ..write(')')) .toString(); } @@ -1752,8 +2406,14 @@ abstract class _$MewsDatabase extends GeneratedDatabase { late final $HistoryItemsTable historyItems = $HistoryItemsTable(this); late final $BookmarksTable bookmarks = $BookmarksTable(this); late final $SubscriptionsTable subscriptions = $SubscriptionsTable(this); + late final $FeedEntriesTable feedEntries = $FeedEntriesTable(this); + late final $ItemTagsTable itemTags = $ItemTagsTable(this); late final $ReaderSettingsTable readerSettings = $ReaderSettingsTable(this); late final $CachedPagesTable cachedPages = $CachedPagesTable(this); + late final Index historyItemsUrl = Index( + 'history_items_url', + 'CREATE INDEX history_items_url ON history_items (url)', + ); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -1762,8 +2422,11 @@ abstract class _$MewsDatabase extends GeneratedDatabase { historyItems, bookmarks, subscriptions, + feedEntries, + itemTags, readerSettings, cachedPages, + historyItemsUrl, ]; } @@ -1944,17 +2607,17 @@ typedef $$HistoryItemsTableProcessedTableManager = >; typedef $$BookmarksTableCreateCompanionBuilder = BookmarksCompanion Function({ - Value id, required String url, Value title, Value createdAt, + Value rowid, }); typedef $$BookmarksTableUpdateCompanionBuilder = BookmarksCompanion Function({ - Value id, Value url, Value title, Value createdAt, + Value rowid, }); class $$BookmarksTableFilterComposer @@ -1966,11 +2629,6 @@ class $$BookmarksTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), - ); - ColumnFilters get url => $composableBuilder( column: $table.url, builder: (column) => ColumnFilters(column), @@ -1996,11 +2654,6 @@ class $$BookmarksTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); - ColumnOrderings get url => $composableBuilder( column: $table.url, builder: (column) => ColumnOrderings(column), @@ -2026,9 +2679,6 @@ class $$BookmarksTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get url => $composableBuilder(column: $table.url, builder: (column) => column); @@ -2067,27 +2717,27 @@ class $$BookmarksTableTableManager $$BookmarksTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ - Value id = const Value.absent(), Value url = const Value.absent(), Value title = const Value.absent(), Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), }) => BookmarksCompanion( - id: id, url: url, title: title, createdAt: createdAt, + rowid: rowid, ), createCompanionCallback: ({ - Value id = const Value.absent(), required String url, Value title = const Value.absent(), Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), }) => BookmarksCompanion.insert( - id: id, url: url, title: title, createdAt: createdAt, + rowid: rowid, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) @@ -2113,21 +2763,21 @@ typedef $$BookmarksTableProcessedTableManager = >; typedef $$SubscriptionsTableCreateCompanionBuilder = SubscriptionsCompanion Function({ - Value id, required String url, Value title, - Value entryUrls, + Value atomHref, Value createdAt, Value lastCheckedAt, + Value rowid, }); typedef $$SubscriptionsTableUpdateCompanionBuilder = SubscriptionsCompanion Function({ - Value id, Value url, Value title, - Value entryUrls, + Value atomHref, Value createdAt, Value lastCheckedAt, + Value rowid, }); class $$SubscriptionsTableFilterComposer @@ -2139,11 +2789,6 @@ class $$SubscriptionsTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), - ); - ColumnFilters get url => $composableBuilder( column: $table.url, builder: (column) => ColumnFilters(column), @@ -2154,8 +2799,8 @@ class $$SubscriptionsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get entryUrls => $composableBuilder( - column: $table.entryUrls, + ColumnFilters get atomHref => $composableBuilder( + column: $table.atomHref, builder: (column) => ColumnFilters(column), ); @@ -2179,11 +2824,6 @@ class $$SubscriptionsTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); - ColumnOrderings get url => $composableBuilder( column: $table.url, builder: (column) => ColumnOrderings(column), @@ -2194,8 +2834,8 @@ class $$SubscriptionsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get entryUrls => $composableBuilder( - column: $table.entryUrls, + ColumnOrderings get atomHref => $composableBuilder( + column: $table.atomHref, builder: (column) => ColumnOrderings(column), ); @@ -2219,17 +2859,14 @@ class $$SubscriptionsTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get url => $composableBuilder(column: $table.url, builder: (column) => column); GeneratedColumn get title => $composableBuilder(column: $table.title, builder: (column) => column); - GeneratedColumn get entryUrls => - $composableBuilder(column: $table.entryUrls, builder: (column) => column); + GeneratedColumn get atomHref => + $composableBuilder(column: $table.atomHref, builder: (column) => column); GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); @@ -2271,35 +2908,35 @@ class $$SubscriptionsTableTableManager $$SubscriptionsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ - Value id = const Value.absent(), Value url = const Value.absent(), Value title = const Value.absent(), - Value entryUrls = const Value.absent(), + Value atomHref = const Value.absent(), Value createdAt = const Value.absent(), Value lastCheckedAt = const Value.absent(), + Value rowid = const Value.absent(), }) => SubscriptionsCompanion( - id: id, url: url, title: title, - entryUrls: entryUrls, + atomHref: atomHref, createdAt: createdAt, lastCheckedAt: lastCheckedAt, + rowid: rowid, ), createCompanionCallback: ({ - Value id = const Value.absent(), required String url, Value title = const Value.absent(), - Value entryUrls = const Value.absent(), + Value atomHref = const Value.absent(), Value createdAt = const Value.absent(), Value lastCheckedAt = const Value.absent(), + Value rowid = const Value.absent(), }) => SubscriptionsCompanion.insert( - id: id, url: url, title: title, - entryUrls: entryUrls, + atomHref: atomHref, createdAt: createdAt, lastCheckedAt: lastCheckedAt, + rowid: rowid, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) @@ -2326,6 +2963,398 @@ typedef $$SubscriptionsTableProcessedTableManager = Subscription, PrefetchHooks Function() >; +typedef $$FeedEntriesTableCreateCompanionBuilder = + FeedEntriesCompanion Function({ + required String siteUrl, + required String url, + required String title, + required DateTime date, + Value foundAt, + Value readAt, + Value rowid, + }); +typedef $$FeedEntriesTableUpdateCompanionBuilder = + FeedEntriesCompanion Function({ + Value siteUrl, + Value url, + Value title, + Value date, + Value foundAt, + Value readAt, + Value rowid, + }); + +class $$FeedEntriesTableFilterComposer + extends Composer<_$MewsDatabase, $FeedEntriesTable> { + $$FeedEntriesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get siteUrl => $composableBuilder( + column: $table.siteUrl, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get date => $composableBuilder( + column: $table.date, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get foundAt => $composableBuilder( + column: $table.foundAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get readAt => $composableBuilder( + column: $table.readAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$FeedEntriesTableOrderingComposer + extends Composer<_$MewsDatabase, $FeedEntriesTable> { + $$FeedEntriesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get siteUrl => $composableBuilder( + column: $table.siteUrl, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get date => $composableBuilder( + column: $table.date, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get foundAt => $composableBuilder( + column: $table.foundAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get readAt => $composableBuilder( + column: $table.readAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$FeedEntriesTableAnnotationComposer + extends Composer<_$MewsDatabase, $FeedEntriesTable> { + $$FeedEntriesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get siteUrl => + $composableBuilder(column: $table.siteUrl, builder: (column) => column); + + GeneratedColumn get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get date => + $composableBuilder(column: $table.date, builder: (column) => column); + + GeneratedColumn get foundAt => + $composableBuilder(column: $table.foundAt, builder: (column) => column); + + GeneratedColumn get readAt => + $composableBuilder(column: $table.readAt, builder: (column) => column); +} + +class $$FeedEntriesTableTableManager + extends + RootTableManager< + _$MewsDatabase, + $FeedEntriesTable, + FeedEntry, + $$FeedEntriesTableFilterComposer, + $$FeedEntriesTableOrderingComposer, + $$FeedEntriesTableAnnotationComposer, + $$FeedEntriesTableCreateCompanionBuilder, + $$FeedEntriesTableUpdateCompanionBuilder, + ( + FeedEntry, + BaseReferences<_$MewsDatabase, $FeedEntriesTable, FeedEntry>, + ), + FeedEntry, + PrefetchHooks Function() + > { + $$FeedEntriesTableTableManager(_$MewsDatabase db, $FeedEntriesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$FeedEntriesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$FeedEntriesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$FeedEntriesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value siteUrl = const Value.absent(), + Value url = const Value.absent(), + Value title = const Value.absent(), + Value date = const Value.absent(), + Value foundAt = const Value.absent(), + Value readAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => FeedEntriesCompanion( + siteUrl: siteUrl, + url: url, + title: title, + date: date, + foundAt: foundAt, + readAt: readAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String siteUrl, + required String url, + required String title, + required DateTime date, + Value foundAt = const Value.absent(), + Value readAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => FeedEntriesCompanion.insert( + siteUrl: siteUrl, + url: url, + title: title, + date: date, + foundAt: foundAt, + readAt: readAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$FeedEntriesTableProcessedTableManager = + ProcessedTableManager< + _$MewsDatabase, + $FeedEntriesTable, + FeedEntry, + $$FeedEntriesTableFilterComposer, + $$FeedEntriesTableOrderingComposer, + $$FeedEntriesTableAnnotationComposer, + $$FeedEntriesTableCreateCompanionBuilder, + $$FeedEntriesTableUpdateCompanionBuilder, + (FeedEntry, BaseReferences<_$MewsDatabase, $FeedEntriesTable, FeedEntry>), + FeedEntry, + PrefetchHooks Function() + >; +typedef $$ItemTagsTableCreateCompanionBuilder = + ItemTagsCompanion Function({ + required TagKind kind, + required String url, + required String tag, + Value createdAt, + Value rowid, + }); +typedef $$ItemTagsTableUpdateCompanionBuilder = + ItemTagsCompanion Function({ + Value kind, + Value url, + Value tag, + Value createdAt, + Value rowid, + }); + +class $$ItemTagsTableFilterComposer + extends Composer<_$MewsDatabase, $ItemTagsTable> { + $$ItemTagsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnWithTypeConverterFilters get kind => + $composableBuilder( + column: $table.kind, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get tag => $composableBuilder( + column: $table.tag, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ItemTagsTableOrderingComposer + extends Composer<_$MewsDatabase, $ItemTagsTable> { + $$ItemTagsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get kind => $composableBuilder( + column: $table.kind, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get url => $composableBuilder( + column: $table.url, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tag => $composableBuilder( + column: $table.tag, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ItemTagsTableAnnotationComposer + extends Composer<_$MewsDatabase, $ItemTagsTable> { + $$ItemTagsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumnWithTypeConverter get kind => + $composableBuilder(column: $table.kind, builder: (column) => column); + + GeneratedColumn get url => + $composableBuilder(column: $table.url, builder: (column) => column); + + GeneratedColumn get tag => + $composableBuilder(column: $table.tag, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$ItemTagsTableTableManager + extends + RootTableManager< + _$MewsDatabase, + $ItemTagsTable, + ItemTag, + $$ItemTagsTableFilterComposer, + $$ItemTagsTableOrderingComposer, + $$ItemTagsTableAnnotationComposer, + $$ItemTagsTableCreateCompanionBuilder, + $$ItemTagsTableUpdateCompanionBuilder, + (ItemTag, BaseReferences<_$MewsDatabase, $ItemTagsTable, ItemTag>), + ItemTag, + PrefetchHooks Function() + > { + $$ItemTagsTableTableManager(_$MewsDatabase db, $ItemTagsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ItemTagsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ItemTagsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ItemTagsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value kind = const Value.absent(), + Value url = const Value.absent(), + Value tag = const Value.absent(), + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ItemTagsCompanion( + kind: kind, + url: url, + tag: tag, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required TagKind kind, + required String url, + required String tag, + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ItemTagsCompanion.insert( + kind: kind, + url: url, + tag: tag, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ItemTagsTableProcessedTableManager = + ProcessedTableManager< + _$MewsDatabase, + $ItemTagsTable, + ItemTag, + $$ItemTagsTableFilterComposer, + $$ItemTagsTableOrderingComposer, + $$ItemTagsTableAnnotationComposer, + $$ItemTagsTableCreateCompanionBuilder, + $$ItemTagsTableUpdateCompanionBuilder, + (ItemTag, BaseReferences<_$MewsDatabase, $ItemTagsTable, ItemTag>), + ItemTag, + PrefetchHooks Function() + >; typedef $$ReaderSettingsTableCreateCompanionBuilder = ReaderSettingsCompanion Function({ Value id, @@ -2753,6 +3782,10 @@ class $MewsDatabaseManager { $$BookmarksTableTableManager(_db, _db.bookmarks); $$SubscriptionsTableTableManager get subscriptions => $$SubscriptionsTableTableManager(_db, _db.subscriptions); + $$FeedEntriesTableTableManager get feedEntries => + $$FeedEntriesTableTableManager(_db, _db.feedEntries); + $$ItemTagsTableTableManager get itemTags => + $$ItemTagsTableTableManager(_db, _db.itemTags); $$ReaderSettingsTableTableManager get readerSettings => $$ReaderSettingsTableTableManager(_db, _db.readerSettings); $$CachedPagesTableTableManager get cachedPages => diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index 52c37fd..6ab50a9 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -3,8 +3,10 @@ import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart' show SignalBuilder; import '../application/actors/fetch_actor.dart'; +import '../application/controllers/bookmarks_controller.dart'; import '../application/controllers/browser_controller.dart'; import '../application/controllers/reader_settings_controller.dart'; +import '../application/controllers/subscriptions_controller.dart'; import '../application/internal_actions.dart'; import '../application/internal_pages.dart'; import '../database/database.dart'; @@ -24,9 +26,10 @@ class AppDependencies { fetch = fetch ?? Actor.create(FetchHandler.new), strings = strings ?? Strings() { settings = ReaderSettingsController(db); - library = LibraryController(db); - internalPages = InternalPages( - db: db, library: library, settings: settings, strings: this.strings); + bookmarks = BookmarksController(db); + subscriptions = SubscriptionsController(db); + internalPages = + InternalPages(db: db, settings: settings, strings: this.strings); internalActions = InternalActions(settings: settings); browser = BrowserController( db: db, @@ -43,13 +46,15 @@ class AppDependencies { final Actor fetch; late final BrowserController browser; late final ReaderSettingsController settings; - late final LibraryController library; + late final BookmarksController bookmarks; + late final SubscriptionsController subscriptions; late final InternalPages internalPages; late final InternalActions internalActions; Future start() async { await settings.load(); - await library.load(); + await bookmarks.load(); + await subscriptions.load(); await browser.restoreLastSession(); } } diff --git a/app/lib/presentation/reader/address_bar.dart b/app/lib/presentation/reader/address_bar.dart index ad4453b..8b2f430 100644 --- a/app/lib/presentation/reader/address_bar.dart +++ b/app/lib/presentation/reader/address_bar.dart @@ -132,28 +132,26 @@ class _AddressBarState extends State { if (canSubscribe) IconButton( icon: Icon( - deps.library.isSubscribed(page.url) + deps.subscriptions.isSubscribed(page.url) ? Icons.notifications_active : Icons.notifications_outlined, ), - tooltip: deps.library.isSubscribed(page.url) + tooltip: deps.subscriptions.isSubscribed(page.url) ? strings.t('reader-unsubscribe') : strings.t('reader-subscribe'), - onPressed: () => deps.library - .toggleSubscription(page.url, page.title, page.entries), + onPressed: () => deps.subscriptions.toggle(page), ), if (canBookmark) IconButton( icon: Icon( - deps.library.isBookmarked(page.url) + deps.bookmarks.isBookmarked(page.url) ? Icons.star : Icons.star_border, ), - tooltip: deps.library.isBookmarked(page.url) + tooltip: deps.bookmarks.isBookmarked(page.url) ? strings.t('reader-bookmark-remove') : strings.t('reader-bookmark-add'), - onPressed: () => - deps.library.toggleBookmark(page.url, page.title), + onPressed: () => deps.bookmarks.toggle(page.url, page.title), ), ], ), diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index 475f74b..26e7f0d 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -28,7 +28,6 @@ class _ReaderScreenState extends State { /// scrolls down, back on any upward scroll. bool _chromeVisible = true; bool _showBrokenText = false; - final _reportedNewPosts = {}; final _checkedSubscriptions = {}; @override @@ -151,7 +150,6 @@ class _ReaderScreenState extends State { final fontScale = settings.fontScale.value; final variant = settings.variant.value; final visited = deps.browser.visitedUrls.value; - _maybeReportNewPosts(context, page); final palette = paletteFor( pageVariant: page.variant, @@ -251,32 +249,15 @@ class _ReaderScreenState extends State { ); } - /// Revalidates a subscribed page once per page load so new entries surface - /// (spec 6.1). The library controller no-ops for unsubscribed pages. + /// Records the posts of a subscribed page once per page load, so what is new + /// shows up on the subscriptions and posts pages (spec 6.1). The controller + /// no-ops for a page the reader is not subscribed to. void _checkSubscription(BuildContext context, MewsPage page) { if (_checkedSubscriptions.contains(page.url)) return; _checkedSubscriptions.add(page.url); final deps = AppScope.of(context).deps; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) deps.library.refreshSubscription(page); - }); - } - - /// Shows the new-posts snackbar once per site after the refresh reports - /// entries the reader has not seen. - void _maybeReportNewPosts(BuildContext context, MewsPage page) { - final scope = AppScope.of(context); - final fresh = scope.deps.library.newEntries.value[page.url]; - if (fresh == null || fresh.isEmpty || _reportedNewPosts.contains(page.url)) return; - _reportedNewPosts.add(page.url); - - final strings = scope.strings; - final message = fresh.length == 1 - ? strings.t('new-posts-one') - : strings.t('new-posts-many', {'count': fresh.length}); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + if (mounted) deps.subscriptions.refreshSite(page); }); } } diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl index 3e00b7f..cbe93f7 100644 --- a/app/locales/en-US/main.ftl +++ b/app/locales/en-US/main.ftl @@ -72,12 +72,6 @@ settings-variant-cool = Cool settings-variant-green = Green settings-variant-mono = Mono -## New posts - -# Shown when a subscribed site has new posts. Format the number in code. -new-posts-one = 1 new post is available on this site. -new-posts-many = { $count } new posts are available on this site. - ## Forms # Default label for a page form's submit control when the author gave none. diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index ab2df02..131a02e 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -312,13 +312,13 @@ void main() { expect(find.byIcon(Icons.notifications_outlined), findsOneWidget); await tester.tap(find.byIcon(Icons.notifications_outlined)); await tester.pumpAndSettle(); - expect(deps.library.isSubscribed('https://example.com/index.html'), isTrue); + expect(deps.subscriptions.isSubscribed('https://example.com/index.html'), isTrue); expect(find.byIcon(Icons.notifications_active), findsOneWidget); expect(find.byIcon(Icons.star_border), findsOneWidget); await tester.tap(find.byIcon(Icons.star_border)); await tester.pumpAndSettle(); - expect(deps.library.isBookmarked('https://example.com/index.html'), isTrue); + expect(deps.bookmarks.isBookmarked('https://example.com/index.html'), isTrue); expect(find.byIcon(Icons.star), findsOneWidget); // Pages with neither Atom feed nor dated links offer no subscription. @@ -464,10 +464,12 @@ void main() { expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html'); // Bookmarks and subscriptions get the same treatment. - await stubDeps.library.load(); - await stubDeps.library.toggleBookmark('https://example.com/b.html', 'Page B'); - await stubDeps.library.toggleSubscription( - 'https://example.com/a.html', 'Page A', const []); + await stubDeps.bookmarks.load(); + await stubDeps.subscriptions.load(); + await stubDeps.bookmarks.toggle('https://example.com/b.html', 'Page B'); + await stubDeps.subscriptions.toggle(parseSource( + _page('Page A', '

Page A

The first page.

'), + 'https://example.com/a.html')); await stubDeps.browser.open('mews:bookmarks'); await tester.pumpAndSettle(); expect(find.text('Bookmarks'), findsOneWidget); @@ -518,23 +520,37 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - testWidgets('reports new posts once per page', (tester) async { + testWidgets('new posts are counted on the subscriptions page', + (tester) async { await pumpApp(tester); + // Subscribing takes on what is already published without flagging it. final page = parseSource(pageSource, 'https://example.com/index.html'); - deps.library.subscribedUrls.value = {'https://example.com/index.html'}; - deps.library.newEntries.value = { - 'https://example.com/index.html': [ - MewsEntry( - url: 'https://example.com/2026-10-08-retro.html', - date: DateTime(2026, 10, 8), - title: 'Collecting retro phones'), - ], - }; - deps.browser.currentPage.value = page; + await deps.subscriptions.toggle(page); + await deps.browser.open(MewsUrls.subscriptions); await tester.pumpAndSettle(); + expect(find.textContaining('unread post'), findsNothing); - expect(find.textContaining('1 new post'), findsOneWidget); + // Opening the page later, with a post that was not there before, counts + // it; the count comes from the database, so it outlives the session. + const laterSource = ''' + + + Signal Bars + +

Signal Bars

+ + + +'''; + await deps.subscriptions + .refreshSite(parseSource(laterSource, 'https://example.com/index.html')); + await deps.browser.open(MewsUrls.subscriptions); + await tester.pumpAndSettle(); + expect(find.textContaining('1 unread post'), findsOneWidget); }); } diff --git a/app/test/controllers_test.dart b/app/test/controllers_test.dart index 8181297..dd07ca2 100644 --- a/app/test/controllers_test.dart +++ b/app/test/controllers_test.dart @@ -2,7 +2,9 @@ import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mews/application/controllers/bookmarks_controller.dart'; import 'package:mews/application/controllers/reader_settings_controller.dart'; +import 'package:mews/application/controllers/subscriptions_controller.dart'; import 'package:mews/database/database.dart'; import 'package:mews/mews/document.dart'; @@ -45,44 +47,89 @@ void main() { }); test('bookmarks toggle and persist', () async { - final library = LibraryController(db); - await library.load(); - await library.toggleBookmark('https://example.com/', 'Example'); - expect(library.isBookmarked('https://example.com/'), isTrue); + final bookmarks = BookmarksController(db); + await bookmarks.load(); + await bookmarks.toggle('https://example.com/', 'Example'); + expect(bookmarks.isBookmarked('https://example.com/'), isTrue); - final reloaded = LibraryController(db); + final reloaded = BookmarksController(db); await reloaded.load(); expect(reloaded.isBookmarked('https://example.com/'), isTrue); - await reloaded.toggleBookmark('https://example.com/', 'Example'); + await reloaded.toggle('https://example.com/', 'Example'); expect(reloaded.isBookmarked('https://example.com/'), isFalse); }); - test('subscription refresh reports only unseen entries', () async { - final library = LibraryController(db); - await library.load(); + test('subscribing takes on the back catalogue without flagging it', () async { + final subscriptions = SubscriptionsController(db); + await subscriptions.load(); final firstPost = MewsEntry( - url: 'https://example.com/a.html', date: DateTime(2026, 9, 21), title: 'First post'); - await library.toggleSubscription( - 'https://example.com/', 'Example', [firstPost]); - expect(library.isSubscribed('https://example.com/'), isTrue); + url: 'https://example.com/a.html', + date: DateTime(2026, 9, 21), + title: 'First post'); + await subscriptions.toggle(_page('https://example.com/', [firstPost])); + expect(subscriptions.isSubscribed('https://example.com/'), isTrue); - // Nothing new yet. - await library.refreshSubscription(_page('https://example.com/', [firstPost])); - expect(library.newEntries.value['https://example.com/'], isNull); + // The post was stored, but read, so nothing is waiting for the reader. + expect(await db.unreadCount(), 0); - // A new post appears. + // Opening the page again finds nothing new. + expect(await subscriptions.refreshSite(_page('https://example.com/', [firstPost])), + isEmpty); + + // A later post is new, and stays unread until the reader opens it. final newPost = MewsEntry( - url: 'https://example.com/b.html', date: DateTime(2026, 10, 8), title: 'Second post'); - await library.refreshSubscription(_page('https://example.com/', [newPost, firstPost])); - final fresh = library.newEntries.value['https://example.com/']!; - expect(fresh, hasLength(1)); - expect(fresh.single.url, 'https://example.com/b.html'); + url: 'https://example.com/b.html', + date: DateTime(2026, 10, 8), + title: 'Second post'); + final fresh = await subscriptions + .refreshSite(_page('https://example.com/', [newPost, firstPost])); + expect(fresh.map((e) => e.url), ['https://example.com/b.html']); + expect(await db.unreadCount(siteUrl: 'https://example.com/'), 1); - // The refresh stored it; a repeat refresh reports nothing. - await library.refreshSubscription(_page('https://example.com/', [newPost, firstPost])); - expect(library.newEntries.value['https://example.com/'], isNull); + // Finding it a second time does not report it again. + expect( + await subscriptions + .refreshSite(_page('https://example.com/', [newPost, firstPost])), + isEmpty); + expect(await db.unreadCount(), 1); + }); + + test('unread posts survive a restart', () async { + final subscriptions = SubscriptionsController(db); + await subscriptions.load(); + await subscriptions.toggle(_page('https://example.com/', const [])); + await subscriptions.refreshSite(_page('https://example.com/', [ + MewsEntry( + url: 'https://example.com/a.html', + date: DateTime(2026, 9, 21), + title: 'First post'), + ])); + + // A fresh controller over the same database sees the same unread post, + // which the old in-memory count could not do. + final restarted = SubscriptionsController(db); + await restarted.load(); + expect(restarted.isSubscribed('https://example.com/'), isTrue); + expect(await db.unreadCount(), 1); + }); + + test('leaving a site takes its posts with it', () async { + final subscriptions = SubscriptionsController(db); + await subscriptions.load(); + await subscriptions.toggle(_page('https://example.com/', const [])); + await subscriptions.refreshSite(_page('https://example.com/', [ + MewsEntry( + url: 'https://example.com/a.html', + date: DateTime(2026, 9, 21), + title: 'First post'), + ])); + expect(await db.unreadCount(), 1); + + await subscriptions.remove('https://example.com/'); + expect(subscriptions.isSubscribed('https://example.com/'), isFalse); + expect(await db.unreadCount(), 0); }); test('caches pages for offline reopening', () async { diff --git a/app/test/database_test.dart b/app/test/database_test.dart new file mode 100644 index 0000000..a9ef053 --- /dev/null +++ b/app/test/database_test.dart @@ -0,0 +1,139 @@ +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +import 'package:mews/database/database.dart'; +import 'package:mews/mews/document.dart'; + +MewsEntry _entry(String url, {String title = 'A post', int day = 8}) => + MewsEntry(url: url, date: DateTime(2026, 10, day), title: title); + +void main() { + late MewsDatabase db; + + setUp(() { + db = MewsDatabase.test(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + test('a page can only be bookmarked once', () async { + await db.addBookmark('https://example.com/', 'Example'); + await db.addBookmark('https://example.com/', 'Example again'); + expect(await db.allBookmarks(), hasLength(1)); + expect(await db.countBookmarks(), 1); + }); + + test('a site can only be subscribed to once', () async { + await db.addSubscription('https://example.com/', 'Example', null); + await db.addSubscription('https://example.com/', 'Example', null); + expect(await db.allSubscriptions(), hasLength(1)); + expect(await db.countSubscriptions(), 1); + }); + + test('finding a post again keeps its read mark', () async { + const site = 'https://example.com/'; + await db.addSubscription(site, 'Example', null); + await db.addEntries(site, [_entry('https://example.com/a.html')]); + expect(await db.unreadCount(), 1); + + await db.markEntryRead('https://example.com/a.html'); + expect(await db.unreadCount(), 0); + + // A refresh sees the same post again; it must not come back unread. + final fresh = await db.addEntries(site, [_entry('https://example.com/a.html')]); + expect(fresh, isEmpty); + expect(await db.unreadCount(), 0); + }); + + test('unread counts split by site', () async { + await db.addEntries('https://a.example/', [ + _entry('https://a.example/one.html'), + _entry('https://a.example/two.html', day: 9), + ]); + await db.addEntries('https://b.example/', [_entry('https://b.example/one.html')]); + + expect(await db.unreadCount(), 3); + expect(await db.unreadCount(siteUrl: 'https://a.example/'), 2); + expect(await db.unreadCounts(), + {'https://a.example/': 2, 'https://b.example/': 1}); + }); + + test('describing a site leaves the fields it is not given', () async { + const site = 'https://example.com/'; + await db.addSubscription(site, 'Signal Bars', null); + + // The old single update method blanked the title whenever a refresh only + // had a feed address to record. + await db.describeSubscription(site, atomHref: 'https://example.com/feed.xml'); + final row = (await db.allSubscriptions()).single; + expect(row.title, 'Signal Bars'); + expect(row.atomHref, 'https://example.com/feed.xml'); + }); + + test('checking a site records the time without touching its posts', () async { + const site = 'https://example.com/'; + await db.addSubscription(site, 'Example', null); + await db.addEntries(site, [_entry('https://example.com/a.html')]); + + final checkedAt = DateTime(2026, 10, 11, 9, 30); + await db.touchSubscription(site, checkedAt); + expect((await db.allSubscriptions()).single.lastCheckedAt, checkedAt); + expect(await db.unreadCount(), 1); + }); + + test('history counts addresses rather than visits', () async { + await db.recordVisit('https://example.com/a.html', 'A'); + await db.recordVisit('https://example.com/a.html', 'A'); + await db.recordVisit('https://example.com/b.html', 'B'); + expect(await db.countHistory(), 2); + }); + + test('an older database is recreated rather than migrated', () async { + final dir = Directory.systemTemp.createTempSync('mews-schema'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = File(p.join(dir.path, 'mews.sqlite')); + + final before = MewsDatabase.test(NativeDatabase(file)); + await before.addBookmark('https://example.com/', 'Example'); + await before.addEntries( + 'https://example.com/', [_entry('https://example.com/a.html')]); + // Make the file look like the shipped version 1: the posts and tags + // tables did not exist, and the version counter said 1. + await before.customStatement('DROP TABLE feed_entries'); + await before.customStatement('DROP TABLE item_tags'); + await before.customStatement('PRAGMA user_version = 1'); + await before.close(); + + // Opening it now runs the destructive upgrade: the reader loses their + // local data, which is the agreed trade before release, but the app must + // come up rather than fail on a table that was never there. + final after = MewsDatabase.test(NativeDatabase(file)); + addTearDown(after.close); + expect(await after.allBookmarks(), isEmpty); + expect(await after.unreadCount(), 0); + + // The new schema works afterwards. + await after.addBookmark('https://example.com/', 'Example'); + await after.addEntries( + 'https://example.com/', [_entry('https://example.com/a.html')]); + expect(await after.countBookmarks(), 1); + expect(await after.unreadCount(), 1); + }); + + test('tags follow the item they are on', () async { + const url = 'https://example.com/'; + await db.addBookmark(url, 'Example'); + await db.addTag(TagKind.bookmark, url, 'phones'); + await db.addTag(TagKind.bookmark, url, 'phones'); + expect(await db.tagsOf(TagKind.bookmark, url), ['phones']); + + // Removing the bookmark takes its tags with it. + await db.removeBookmark(url); + expect(await db.tagsOf(TagKind.bookmark, url), isEmpty); + }); +}