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

85
app/lib/mews/atom.dart Normal file
View 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;
}