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>
193 lines
5.9 KiB
Dart
193 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/opds_entry.dart';
|
|
import 'package:worldhopper/models/opds_link.dart';
|
|
import 'package:worldhopper/models/opds_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 opdsId,
|
|
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<OPDSLink> links,
|
|
OPDSStreamLink? 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,
|
|
opdsId: row['opds_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.fromOPDSEntry({
|
|
required String id,
|
|
required String serverId,
|
|
required OPDSEntry entry,
|
|
required EnhancedMetadata metadata,
|
|
String? coverPath,
|
|
String? thumbnailPath,
|
|
}) {
|
|
final now = DateTime.now();
|
|
return Publication(
|
|
id: id,
|
|
serverId: serverId,
|
|
opdsId: 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<OPDSLink> _decodeLinks(dynamic value) {
|
|
if (value == null) return [];
|
|
final list = jsonDecode(value as String) as List;
|
|
return list
|
|
.map((e) => OPDSLink.fromJson(e as Map<String, dynamic>))
|
|
.toList();
|
|
}
|
|
|
|
static OPDSStreamLink? _decodeStreamLink(dynamic value) {
|
|
if (value == null) return null;
|
|
final json = jsonDecode(value as String) as Map<String, dynamic>;
|
|
return OPDSStreamLink.fromJson(json);
|
|
}
|
|
|
|
static DateTime? _decodeDateTime(dynamic value) {
|
|
if (value == null) return null;
|
|
return DateTime.fromMillisecondsSinceEpoch(value as int);
|
|
}
|
|
|
|
/// Convert to OPDSEntry
|
|
OPDSEntry toOPDSEntry() {
|
|
return OPDSEntry(
|
|
id: opdsId,
|
|
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,
|
|
'opds_id': opdsId,
|
|
'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,
|
|
};
|
|
}
|
|
}
|