Rename all OPDS-prefixed models to generic names (Server, Feed, Entry, Link, StreamLink), expand the ServerSoftware interface to cover all server interactions, and implement a full Kavita REST API client that replaces the OPDS delegation. - Rename OPDS* models to generic names across ~60 files - Add database migrations 13 (opds_id → entry_id) and 14 (unify credentials) - Create OPDSServerSoftware wrapping existing OPDS services - Create KavitaApiClient for direct Kavita REST API calls - Create KavitaFeedMapper to convert Kavita JSON to Feed/Entry models - Rewrite KavitaServerSoftware to use native API (no OPDS delegation) - Unify server credentials (remove softwareUsername/softwarePassword) - Simplify add/edit server UI to single auth section - Add test connection button to server add/edit screens - Add progress indicator to PublicationCard using local and server data - Eliminate softwareType branching in reader screens - Add EntryProgress and fetchEntryProgress to ServerSoftware interface - Fix Entry.acquisitionLink crash on empty links - Add 59 new tests covering models, services, and providers
346 lines
11 KiB
Dart
346 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/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<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, {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<Link>()
|
|
.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<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<Link>()
|
|
.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,
|
|
);
|
|
}
|
|
}
|