worldhopper/lib/services/opds_parser.dart
Felipe M. 1074e3808d
feat: basic navigation
feat: navigation store

feat: implement OPDS entry caching system

Add comprehensive caching for OPDS publications with local image storage and Dublin Core Terms metadata extraction. This enables offline viewing, fixes Library screen to show book titles and covers, and improves overall UX.

- Add publications table with full metadata (title, authors, series, publisher, ISBN, language)
- Implement local image cache service for covers and thumbnails
- Extract DCTerms metadata (series, publisher, language, ISBN) from OPDS feeds
- Link reading progress to cached publications
- Update Library screen to display cached publication data and covers
- Cache publications automatically when starting to read
- Fix navigation stack issues by using context.push() instead of context.go()
- Add explicit back button to EPUB reader with proper PopScope handling
- Implement UNIQUE constraint on reading_progress to prevent duplicates
- Save reading progress on screen dispose for both EPUB and image readers

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 18:24:07 +01:00

357 lines
11 KiB
Dart

import 'package:xml/xml.dart';
import 'package:worldhopper/config/constants.dart';
import 'package:worldhopper/models/enhanced_metadata.dart';
import 'package:worldhopper/models/opds_feed.dart';
import 'package:worldhopper/models/opds_entry.dart';
import 'package:worldhopper/models/opds_link.dart';
import 'package:worldhopper/models/opds_stream_link.dart';
import 'package:worldhopper/models/opds_server.dart';
/// Container for parsed feed and associated metadata
class ParsedFeed {
final OPDSFeed feed;
final Map<String, EnhancedMetadata> entryMetadata;
const ParsedFeed(this.feed, this.entryMetadata);
}
/// Parser for OPDS 1.2 feeds with PSE (Page Streaming Extension) support
class OPDSParser {
/// Parse an OPDS feed from XML string
ParsedFeed parseFeed(String xmlContent, {OPDSServer? server}) {
final document = XmlDocument.parse(xmlContent);
final feedElement = document.findElements('feed').first;
// Parse feed metadata
final id = _getElementText(feedElement, 'id') ?? '';
final title = _getElementText(feedElement, 'title') ?? 'Untitled';
final subtitle = _getElementText(feedElement, 'subtitle');
final icon = _getElementText(feedElement, 'icon');
final updated = _parseDate(_getElementText(feedElement, 'updated'));
// Parse author
final authorElement = feedElement.findElements('author').firstOrNull;
final author = authorElement != null
? _getElementText(authorElement, 'name')
: null;
// Parse feed links
final feedLinks = feedElement
.findElements('link')
.map((element) => _parseLink(element, server: server))
.where((link) => link != null)
.cast<OPDSLink>()
.toList();
// Parse entries with metadata
final entriesWithMetadata = feedElement
.findElements('entry')
.map((element) => _parseEntry(element, server))
.toList();
final entries = entriesWithMetadata.map((tuple) => tuple.$1).toList();
final entryMetadata = Map.fromEntries(
entriesWithMetadata.map((tuple) => MapEntry(tuple.$1.id, tuple.$2)),
);
// Parse OpenSearch elements (for pagination)
int? totalResults;
int? itemsPerPage;
int? startIndex;
try {
final totalResultsText = feedElement
.findElements('totalResults')
.firstOrNull
?.innerText;
if (totalResultsText != null) {
totalResults = int.tryParse(totalResultsText);
}
final itemsPerPageText = feedElement
.findElements('itemsPerPage')
.firstOrNull
?.innerText;
if (itemsPerPageText != null) {
itemsPerPage = int.tryParse(itemsPerPageText);
}
final startIndexText = feedElement
.findElements('startIndex')
.firstOrNull
?.innerText;
if (startIndexText != null) {
startIndex = int.tryParse(startIndexText);
}
} catch (e) {
// Ignore pagination parsing errors
}
final feed = OPDSFeed(
id: id,
title: title,
subtitle: subtitle,
icon: icon,
updated: updated,
author: author,
links: feedLinks,
entries: entries,
totalResults: totalResults,
itemsPerPage: itemsPerPage,
startIndex: startIndex,
);
return ParsedFeed(feed, entryMetadata);
}
/// Parse an entry element with enhanced metadata
(OPDSEntry, EnhancedMetadata) _parseEntry(XmlElement element, OPDSServer? server) {
final id = _getElementText(element, 'id') ?? '';
final title = _getElementText(element, 'title') ?? 'Untitled';
final summary = _getElementText(element, 'summary');
final content = _getElementText(element, 'content');
final published = _parseDate(_getElementText(element, 'published'));
final updated = _parseDate(_getElementText(element, 'updated'));
// Parse authors
final authors = element
.findElements('author')
.map((author) => _getElementText(author, 'name'))
.where((name) => name != null)
.cast<String>()
.toList();
// Parse categories
final categories = element
.findElements('category')
.map((category) => category.getAttribute('term'))
.where((term) => term != null)
.cast<String>()
.toList();
// Parse links
final links = element
.findElements('link')
.map((linkElement) => _parseLink(linkElement, server: server))
.where((link) => link != null)
.cast<OPDSLink>()
.toList();
// Parse PSE stream link
final streamLink = _parseStreamLink(element, server);
// Extract cover and thumbnail URLs and resolve them
String? coverUrl;
String? thumbnailUrl;
for (final link in links) {
if (link.rel.contains('image') || link.rel.contains('cover')) {
final resolvedHref = server != null
? server.resolveUrl(link.href)
: link.href;
if (link.rel.contains('thumbnail')) {
thumbnailUrl = resolvedHref;
} else {
coverUrl = resolvedHref;
}
}
}
final entry = OPDSEntry(
id: id,
title: title,
summary: summary,
content: content,
published: published,
updated: updated,
authors: authors,
categories: categories,
links: links,
streamLink: streamLink,
coverUrl: coverUrl,
thumbnailUrl: thumbnailUrl,
);
final metadata = _parseDCTermsMetadata(element);
return (entry, metadata);
}
/// Parse a link element
OPDSLink? _parseLink(XmlElement element, {OPDSServer? server}) {
final rel = element.getAttribute('rel');
final href = element.getAttribute('href');
if (rel == null || href == null) return null;
// Resolve href against server URL if server is provided
// This ensures all links (acquisition, navigation, etc.) are absolute URLs
final resolvedHref = server != null
? server.resolveUrl(href)
: href;
return OPDSLink(
rel: rel,
href: resolvedHref, // Now absolute URL
type: element.getAttribute('type'),
title: element.getAttribute('title'),
);
}
/// Parse a PSE stream link from an entry
OPDSStreamLink? _parseStreamLink(XmlElement entryElement, OPDSServer? server) {
try {
// Find link with rel="http://vaemendis.net/opds-pse/stream"
final linkElements = entryElement.findElements('link');
for (final linkElement in linkElements) {
final rel = linkElement.getAttribute('rel');
if (rel == AppConstants.opdsPseStreamRel) {
final href = linkElement.getAttribute('href');
final type = linkElement.getAttribute('type');
if (href == null || type == null) continue;
// Parse PSE namespace attributes
// Try different namespace prefixes
int? pageCount;
int? lastRead;
DateTime? lastReadDate;
// Try to find pse:count attribute
final countAttr = linkElement.getAttribute('count',
namespace: AppConstants.opdsPseNamespace) ??
linkElement.getAttribute('pse:count');
if (countAttr != null) {
pageCount = int.tryParse(countAttr);
}
// Try to find pse:lastRead attribute
final lastReadAttr = linkElement.getAttribute('lastRead',
namespace: AppConstants.opdsPseNamespace) ??
linkElement.getAttribute('pse:lastRead');
if (lastReadAttr != null) {
lastRead = int.tryParse(lastReadAttr);
}
// Try to find pse:lastReadDate attribute
final lastReadDateAttr = linkElement.getAttribute('lastReadDate',
namespace: AppConstants.opdsPseNamespace) ??
linkElement.getAttribute('pse:lastReadDate');
if (lastReadDateAttr != null) {
lastReadDate = _parseDate(lastReadDateAttr);
}
if (pageCount != null) {
// Resolve href against server URL if server is provided
// This matches the pattern used for cover/thumbnail URLs
final resolvedHref = server != null
? server.resolveUrl(href)
: href;
return OPDSStreamLink(
href: resolvedHref,
type: type,
pageCount: pageCount,
lastRead: lastRead,
lastReadDate: lastReadDate,
);
}
}
}
} catch (e) {
// Ignore PSE parsing errors
}
return null;
}
/// Get text content of a child element
String? _getElementText(XmlElement parent, String elementName) {
try {
final element = parent.findElements(elementName).firstOrNull;
return element?.innerText.trim();
} catch (e) {
return null;
}
}
/// Parse an Atom date string
DateTime? _parseDate(String? dateString) {
if (dateString == null || dateString.isEmpty) return null;
try {
return DateTime.parse(dateString);
} catch (e) {
return null;
}
}
/// Parse Dublin Core Terms metadata from entry element
EnhancedMetadata _parseDCTermsMetadata(XmlElement element) {
String? series;
double? seriesPosition;
String? publisher;
String? language;
String? isbn;
try {
// Try to find dcterms:isPartOf (series)
final isPartOfElement = element
.findElements('isPartOf', namespace: AppConstants.dctermsNamespace)
.firstOrNull;
if (isPartOfElement != null) {
series = isPartOfElement.innerText.trim();
}
// Try schema:position or dcterms:position for series position
final positionElement = element.findElements('position').firstOrNull;
if (positionElement != null) {
seriesPosition = double.tryParse(positionElement.innerText.trim());
}
// dcterms:publisher
final publisherElement = element
.findElements('publisher', namespace: AppConstants.dctermsNamespace)
.firstOrNull;
if (publisherElement != null) {
publisher = publisherElement.innerText.trim();
}
// dcterms:language
final languageElement = element
.findElements('language', namespace: AppConstants.dctermsNamespace)
.firstOrNull;
if (languageElement != null) {
language = languageElement.innerText.trim();
}
// dcterms:identifier - look for ISBN
final identifierElements = element.findElements('identifier',
namespace: AppConstants.dctermsNamespace);
for (final idElement in identifierElements) {
final text = idElement.innerText.trim().toUpperCase();
if (text.contains('ISBN')) {
// Extract ISBN: remove "ISBN:", "ISBN-13:", etc.
isbn = text.replaceAll(RegExp(r'ISBN[- ]?(10|13)?:?\s*'), '').trim();
break;
}
}
} catch (e) {
// Ignore dcterms parsing errors
}
return EnhancedMetadata(
series: series,
seriesPosition: seriesPosition,
publisher: publisher,
language: language,
isbn: isbn,
);
}
}