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>
357 lines
11 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|