feat: store subscription posts with a read mark and split the library controller

This commit is contained in:
randogoth 2026-10-11 13:12:48 +03:00
parent b311b9f7dd
commit 953ae798eb
15 changed files with 1787 additions and 386 deletions

View file

@ -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(<String>{});
Future<void> load() async {
final rows = await _db.allBookmarks();
bookmarkedUrls.value = rows.map((row) => row.url).toSet();
}
bool isBookmarked(String url) => bookmarkedUrls.value.contains(url);
Future<void> toggle(String url, String? title) async {
if (isBookmarked(url)) {
await remove(url);
return;
}
await _db.addBookmark(url, title);
bookmarkedUrls.value = {...bookmarkedUrls.value, url};
}
Future<void> remove(String url) async {
await _db.removeBookmark(url);
bookmarkedUrls.value = {...bookmarkedUrls.value}..remove(url);
}
}

View file

@ -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(<String>{});
final subscribedUrls = signal(<String>{});
/// 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(<String, List<MewsEntry>>{});
Future<void> 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<void> 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<void> toggleSubscription(String url, String? title, List<MewsEntry> 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<void> 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<String>().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(),
);
}
}

View file

@ -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(<String>{});
Future<void> load() async {
final rows = await _db.allSubscriptions();
subscribedUrls.value = rows.map((row) => row.url).toSet();
}
bool isSubscribed(String url) => subscribedUrls.value.contains(url);
Future<void> 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<void> 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<List<MewsEntry>> 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;
}
}