import 'package:xml/xml.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/models/enhanced_metadata.dart'; import 'package:worldhopper/models/feed.dart'; import 'package:worldhopper/models/entry.dart'; import 'package:worldhopper/models/link.dart'; import 'package:worldhopper/models/stream_link.dart'; import 'package:worldhopper/models/server.dart'; /// Container for parsed feed and associated metadata class ParsedFeed { final Feed feed; final Map 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, {Server? 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() .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 = Feed( 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 (Entry, EnhancedMetadata) _parseEntry(XmlElement element, Server? 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() .toList(); // Parse categories final categories = element .findElements('category') .map((category) => category.getAttribute('term')) .where((term) => term != null) .cast() .toList(); // Parse links final links = element .findElements('link') .map((linkElement) => _parseLink(linkElement, server: server)) .where((link) => link != null) .cast() .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 = Entry( 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 Link? _parseLink(XmlElement element, {Server? 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 Link( rel: rel, href: resolvedHref, // Now absolute URL type: element.getAttribute('type'), title: element.getAttribute('title'), ); } /// Parse a PSE stream link from an entry StreamLink? _parseStreamLink(XmlElement entryElement, Server? 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 StreamLink( 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, ); } }