feat: read subscriptions from an Atom feed when a page lists no dated links
This commit is contained in:
parent
b0b86ef5b1
commit
fa51d5793d
9 changed files with 411 additions and 4 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
85
app/lib/mews/atom.dart
Normal file
85
app/lib/mews/atom.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/// Reads an Atom feed (spec 6.2) into the same shape dated links produce, so a
|
||||
/// site's posts come from one store however they were published. Pure data: no
|
||||
/// Flutter imports, so it can run inside the fetch actor's isolate.
|
||||
library;
|
||||
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import 'document.dart';
|
||||
import 'parser.dart' show MewsParseException;
|
||||
|
||||
/// The entries of an Atom feed, newest first. Entries without a usable address
|
||||
/// or date are skipped rather than guessed at.
|
||||
///
|
||||
/// Element names are matched without their namespace, so a feed using the
|
||||
/// default Atom namespace and one using an `atom:` prefix both read the same.
|
||||
List<MewsEntry> parseAtomEntries({
|
||||
required String source,
|
||||
required String feedUrl,
|
||||
}) {
|
||||
final XmlDocument doc;
|
||||
try {
|
||||
doc = XmlDocument.parse(source);
|
||||
} on XmlException catch (e) {
|
||||
throw MewsParseException(e.toString());
|
||||
}
|
||||
|
||||
final base = Uri.tryParse(feedUrl);
|
||||
final entries = <MewsEntry>[];
|
||||
for (final element in doc.rootElement.children.whereType<XmlElement>()) {
|
||||
if (element.name.local != 'entry') continue;
|
||||
final url = _entryUrl(element, base);
|
||||
final date = _entryDate(element);
|
||||
if (url == null || date == null) continue;
|
||||
entries.add(MewsEntry(
|
||||
url: url,
|
||||
date: date,
|
||||
title: _childText(element, 'title') ?? url,
|
||||
));
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.date.compareTo(a.date));
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// An entry's address: its alternate link, or its id when that is a web
|
||||
/// address. The id is a fallback because many feeds use the post's URL for it.
|
||||
String? _entryUrl(XmlElement entry, Uri? base) {
|
||||
for (final child in entry.children.whereType<XmlElement>()) {
|
||||
if (child.name.local != 'link') continue;
|
||||
final rel = child.getAttribute('rel');
|
||||
if (rel != null && rel != 'alternate') continue;
|
||||
final href = child.getAttribute('href');
|
||||
if (href == null || href.isEmpty) continue;
|
||||
final resolved = base?.resolve(href) ?? Uri.tryParse(href);
|
||||
if (resolved != null) return resolved.toString();
|
||||
}
|
||||
|
||||
final id = _childText(entry, 'id');
|
||||
if (id == null) return null;
|
||||
final asUri = Uri.tryParse(id);
|
||||
return asUri != null && (asUri.scheme == 'http' || asUri.scheme == 'https')
|
||||
? asUri.toString()
|
||||
: null;
|
||||
}
|
||||
|
||||
/// When an entry was published. `updated` is required by Atom, so it comes
|
||||
/// first, with `published` as the fallback.
|
||||
DateTime? _entryDate(XmlElement entry) {
|
||||
for (final name in const ['updated', 'published']) {
|
||||
final text = _childText(entry, name);
|
||||
if (text == null) continue;
|
||||
final date = DateTime.tryParse(text);
|
||||
if (date != null) return date;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _childText(XmlElement parent, String name) {
|
||||
for (final child in parent.children.whereType<XmlElement>()) {
|
||||
if (child.name.local != name) continue;
|
||||
final text = child.innerText.trim();
|
||||
if (text.isNotEmpty) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ MewsPage parseMewsPage({required String source, required String url}) {
|
|||
title: headData.title,
|
||||
description: headData.description,
|
||||
author: headData.author,
|
||||
atomFeedHref: headData.atomFeedHref,
|
||||
atomFeedHref: _feedHref(headData.atomFeedHref, url),
|
||||
lang: html.getAttribute('xml:lang') ??
|
||||
html.getAttribute('lang') ??
|
||||
bodyElement.getAttribute('lang') ??
|
||||
|
|
@ -294,6 +294,18 @@ ImageBlock _imageBlock(XmlElement element, String pageUrl) {
|
|||
|
||||
/// Rough registrable-domain match: the last two labels. Fine for the small
|
||||
/// web; a public-suffix list is out of scope for v1.
|
||||
/// The page's Atom feed as an absolute address (spec 6.2), or null when the
|
||||
/// page declares none or points at another site: a client must not load
|
||||
/// another site's resources without the reader acting (specs 7.4 and 8.1).
|
||||
String? _feedHref(String? href, String pageUrl) {
|
||||
if (href == null || href.isEmpty) return null;
|
||||
final page = Uri.tryParse(pageUrl);
|
||||
if (page == null) return null;
|
||||
final resolved = page.resolve(href);
|
||||
if (resolved.scheme != 'http' && resolved.scheme != 'https') return null;
|
||||
return _sameSite(resolved, page) ? resolved.toString() : null;
|
||||
}
|
||||
|
||||
bool _sameSite(Uri image, Uri page) {
|
||||
String? domain(Uri uri) {
|
||||
final host = uri.host;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue