import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:worldhopper/models/link.dart'; import 'package:worldhopper/models/stream_link.dart'; part 'entry.freezed.dart'; part 'entry.g.dart'; /// Represents an entry in an OPDS feed (a publication or collection) @freezed class Entry with _$Entry { const factory Entry({ /// Unique identifier for the entry required String id, /// Entry title required String title, /// Entry summary/description String? summary, /// Entry content (HTML description) String? content, /// Publication date DateTime? published, /// Last update date DateTime? updated, /// Authors @Default([]) List authors, /// Categories/tags @Default([]) List categories, /// All links associated with this entry @Default([]) List links, /// PSE stream link (if available) StreamLink? streamLink, /// Cover image URL String? coverUrl, /// Thumbnail image URL String? thumbnailUrl, /// Server-reported pages read (0-indexed current position). For EPUB /// entries this is the only place progress is exposed; for image streams /// it mirrors [StreamLink.lastRead]. int? pagesRead, /// Server-reported total page count. int? totalPages, }) = _Entry; const Entry._(); /// Create Entry from JSON factory Entry.fromJson(Map json) => _$EntryFromJson(json); /// Check if this entry has a stream link (can be read page by page) bool get hasStreamLink => streamLink != null; /// Check if this entry is a navigation entry (leads to another feed) bool get isNavigation => links.any( (link) => link.rel == 'subsection' || link.rel.contains('navigation'), ); /// Get the acquisition link (for download or reading) Link? get acquisitionLink => links.isEmpty ? null : links.cast().firstWhere( (link) => link!.rel.contains('acquisition') || link.rel.contains('open-access'), orElse: () => links.first, ); /// Get the self link Link? get selfLink => links.cast().firstWhere( (link) => link?.rel == 'self', orElse: () => null, ); /// Check if this entry is an EPUB publication bool get isEpub { final link = acquisitionLink; if (link == null) return false; return link.type?.toLowerCase().contains('epub') ?? false; } /// Check if this entry is an image stream (comic/manga) bool get isImageStream => hasStreamLink; /// Server-reported current page (0-indexed). Prefers the direct Entry field /// populated by Kavita for both EPUB and image streams; falls back to /// StreamLink.lastRead (OPDS PSE). int? get serverCurrentPage { if (pagesRead != null && pagesRead! >= 0) return pagesRead; return streamLink?.lastRead; } /// Server-reported total pages. Prefers Entry.totalPages; falls back to /// StreamLink.pageCount. int? get serverTotalPages { if (totalPages != null && totalPages! > 0) return totalPages; final pc = streamLink?.pageCount; return (pc != null && pc > 0) ? pc : null; } /// Server-reported progress as a percentage (0.0–1.0), or null when no /// usable progress is available. Treats a current page of 0 as "not /// started" so we do not show a spurious 1/N progress bar. double? get serverProgress { final cur = serverCurrentPage; final tot = serverTotalPages; if (cur == null || tot == null || tot <= 0 || cur <= 0) return null; return ((cur + 1) / tot).clamp(0.0, 1.0); } bool get hasServerProgress => serverProgress != null; }