worldhopper/lib/models/publication.dart
Felipe M. 58a562abe8
feat: refactor server software abstraction with Kavita REST API integration
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
2026-04-06 17:46:48 +02:00

191 lines
5.9 KiB
Dart

import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:worldhopper/models/enhanced_metadata.dart';
import 'package:worldhopper/models/entry.dart';
import 'package:worldhopper/models/link.dart';
import 'package:worldhopper/models/stream_link.dart';
part 'publication.freezed.dart';
part 'publication.g.dart';
/// Cached publication with full metadata and local image paths
@freezed
class Publication with _$Publication {
const factory Publication({
required String id,
required String serverId,
required String entryId,
required String title,
@Default([]) List<String> authors,
String? summary,
String? content,
String? coverPath,
String? thumbnailPath,
String? coverUrl,
String? thumbnailUrl,
String? series,
double? seriesPosition,
String? publisher,
String? language,
String? isbn,
@Default([]) List<String> categories,
@Default([]) List<Link> links,
StreamLink? streamLink,
DateTime? published,
DateTime? updated,
required DateTime firstCachedAt,
required DateTime lastCachedAt,
required DateTime lastAccessedAt,
}) = _Publication;
const Publication._();
factory Publication.fromJson(Map<String, dynamic> json) =>
_$PublicationFromJson(json);
/// Create from database row
factory Publication.fromDatabase(Map<String, dynamic> row) {
return Publication(
id: row['id'] as String,
serverId: row['server_id'] as String,
entryId: row['entry_id'] as String,
title: row['title'] as String,
authors: _decodeJsonList(row['authors']),
summary: row['summary'] as String?,
content: row['content'] as String?,
coverPath: row['cover_path'] as String?,
thumbnailPath: row['thumbnail_path'] as String?,
coverUrl: row['cover_url'] as String?,
thumbnailUrl: row['thumbnail_url'] as String?,
series: row['series'] as String?,
seriesPosition: row['series_position'] as double?,
publisher: row['publisher'] as String?,
language: row['language'] as String?,
isbn: row['isbn'] as String?,
categories: _decodeJsonList(row['categories']),
links: _decodeLinks(row['links']),
streamLink: _decodeStreamLink(row['stream_link']),
published: _decodeDateTime(row['published']),
updated: _decodeDateTime(row['updated']),
firstCachedAt:
DateTime.fromMillisecondsSinceEpoch(row['first_cached_at'] as int),
lastCachedAt:
DateTime.fromMillisecondsSinceEpoch(row['last_cached_at'] as int),
lastAccessedAt:
DateTime.fromMillisecondsSinceEpoch(row['last_accessed_at'] as int),
);
}
/// Create from OPDS entry with enhanced metadata
factory Publication.fromEntry({
required String id,
required String serverId,
required Entry entry,
required EnhancedMetadata metadata,
String? coverPath,
String? thumbnailPath,
}) {
final now = DateTime.now();
return Publication(
id: id,
serverId: serverId,
entryId: entry.id,
title: entry.title,
authors: entry.authors,
summary: entry.summary,
content: entry.content,
coverPath: coverPath,
thumbnailPath: thumbnailPath,
coverUrl: entry.coverUrl,
thumbnailUrl: entry.thumbnailUrl,
series: metadata.series,
seriesPosition: metadata.seriesPosition,
publisher: metadata.publisher,
language: metadata.language,
isbn: metadata.isbn,
categories: entry.categories,
links: entry.links,
streamLink: entry.streamLink,
published: entry.published,
updated: entry.updated,
firstCachedAt: now,
lastCachedAt: now,
lastAccessedAt: now,
);
}
static List<String> _decodeJsonList(dynamic value) {
if (value == null) return [];
final list = jsonDecode(value as String) as List;
return list.map((e) => e.toString()).toList();
}
static List<Link> _decodeLinks(dynamic value) {
if (value == null) return [];
final list = jsonDecode(value as String) as List;
return list.map((e) => Link.fromJson(e as Map<String, dynamic>)).toList();
}
static StreamLink? _decodeStreamLink(dynamic value) {
if (value == null) return null;
final json = jsonDecode(value as String) as Map<String, dynamic>;
return StreamLink.fromJson(json);
}
static DateTime? _decodeDateTime(dynamic value) {
if (value == null) return null;
return DateTime.fromMillisecondsSinceEpoch(value as int);
}
/// Convert to Entry
Entry toEntry() {
return Entry(
id: entryId,
title: title,
authors: authors,
summary: summary,
content: content,
coverUrl: coverUrl,
thumbnailUrl: thumbnailUrl,
categories: categories,
links: links,
streamLink: streamLink,
published: published,
updated: updated,
);
}
}
extension PublicationX on Publication {
Map<String, dynamic> toDatabase() {
return {
'id': id,
'server_id': serverId,
'entry_id': entryId,
'title': title,
'authors': jsonEncode(authors),
'summary': summary,
'content': content,
'cover_path': coverPath,
'thumbnail_path': thumbnailPath,
'cover_url': coverUrl,
'thumbnail_url': thumbnailUrl,
'series': series,
'series_position': seriesPosition,
'publisher': publisher,
'language': language,
'isbn': isbn,
'categories': jsonEncode(categories),
'links': jsonEncode(links.map((l) => l.toJson()).toList()),
'stream_link': streamLink?.toJson() != null
? jsonEncode(streamLink!.toJson())
: null,
'published': published?.millisecondsSinceEpoch,
'updated': updated?.millisecondsSinceEpoch,
'first_cached_at': firstCachedAt.millisecondsSinceEpoch,
'last_cached_at': lastCachedAt.millisecondsSinceEpoch,
'last_accessed_at': lastAccessedAt.millisecondsSinceEpoch,
};
}
}