feat: read subscriptions from an Atom feed when a page lists no dated links

This commit is contained in:
randogoth 2026-10-11 13:35:19 +03:00
parent b0b86ef5b1
commit fa51d5793d
9 changed files with 411 additions and 4 deletions

View file

@ -1,6 +1,7 @@
import 'package:actors/actors.dart';
import 'package:http/http.dart' as http;
import '../../mews/atom.dart';
import '../../mews/document.dart';
import '../../mews/parser.dart';
@ -31,6 +32,14 @@ class SubmitForm extends FetchCommand {
final Map<String, String> fields;
}
/// Fetch and read a subscribed page's Atom feed (spec 6.2). [url] is absolute
/// and on the page's own site, which the parser has already checked.
class FetchFeed extends FetchCommand {
const FetchFeed({required this.url});
final String url;
}
/// Parses a page's source without touching the network; used to prepare the
/// neighboring pages that swipes reveal.
class ParseSource extends FetchCommand {
@ -44,6 +53,14 @@ sealed class FetchResult {
const FetchResult();
}
/// The posts an Atom feed lists.
class FetchFeedOk extends FetchResult {
const FetchFeedOk({required this.url, required this.entries});
final String url;
final List<MewsEntry> entries;
}
class FetchPageOk extends FetchResult {
const FetchPageOk({
required this.page,
@ -103,6 +120,7 @@ class FetchHandler with Handler<FetchCommand, FetchResult> {
await _fetchPage(url, etag, lastModified, cachedSource),
SubmitForm(:final url, :final method, :final fields) =>
await _submitForm(url, method, fields),
FetchFeed(:final url) => await _fetchFeed(url),
ParseSource(:final url, :final source) => _parse(source, url),
};
}
@ -149,6 +167,28 @@ class FetchHandler with Handler<FetchCommand, FetchResult> {
}
}
Future<FetchResult> _fetchFeed(String url) async {
final http.Response response;
try {
response = await _client
.get(Uri.parse(url), headers: {'User-Agent': _userAgent})
.timeout(const Duration(seconds: 20));
} catch (_) {
return const FetchNetworkError();
}
if (response.statusCode != 200) {
return FetchHttpStatusError(response.statusCode);
}
try {
return FetchFeedOk(
url: url,
entries: parseAtomEntries(source: response.body, feedUrl: url),
);
} on MewsParseException catch (e) {
return FetchParseError(e.message, response.body);
}
}
Future<FetchResult> _submitForm(String url, String method, Map<String, String> fields) async {
final headers = {'User-Agent': _userAgent};
final http.Response response;

View file

@ -43,9 +43,14 @@ class SubscriptionsController {
}
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.
// Whatever the site has already published is its back catalogue, not
// news, so it is stored read and only later posts are flagged. A site
// that publishes only through a feed is seeded the same way.
await _db.addEntries(page.url, page.entries, read: true);
final feed = page.atomFeedHref;
if (page.entries.isEmpty && feed != null) {
await _checkFeed(page.url, feed, read: true);
}
}
Future<void> remove(String siteUrl) async {
@ -127,6 +132,13 @@ class SubscriptionsController {
await _db.describeSubscription(siteUrl,
title: page.title, atomHref: page.atomFeedHref);
await _db.addEntries(siteUrl, page.entries);
// Dated links are the normative way a site publishes (spec 6.1), so
// the feed is only read when the page itself listed nothing. A site
// where both agree is then fetched once, not twice.
final feed = page.atomFeedHref;
if (page.entries.isEmpty && feed != null) {
await _checkFeed(siteUrl, feed);
}
if (!notModified) {
// Keep the cache current so the site also reads offline, but do not
// record a visit: the reader did not open it.
@ -144,4 +156,14 @@ class SubscriptionsController {
}
await _db.touchSubscription(siteUrl, DateTime.now());
}
/// Reads a site's Atom feed (spec 6.2). Entries are keyed by address, so a
/// post listed by both the page and the feed is stored once.
Future<void> _checkFeed(String siteUrl, String feedUrl,
{bool read = false}) async {
final result = await _fetch.send(FetchFeed(url: feedUrl));
if (result case FetchFeedOk(:final entries)) {
await _db.addEntries(siteUrl, entries, read: read);
}
}
}