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
191 lines
5.9 KiB
Dart
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,
|
|
};
|
|
}
|
|
}
|