Major improvements to offline reading functionality:
**Hero Image Offline Support:**
- Add heroImageURL field to BookmarkEntity for persistent storage
- Implement ImageCache-based caching with custom keys (bookmark-{id}-hero)
- Update CachedAsyncImage to support offline loading via cache keys
- Hero images now work offline without URL dependency
**Offline Bookmark Loading:**
- Add proactive offline detection before API calls
- Implement automatic fallback to cached bookmarks when offline
- Fix network status initialization race condition
- Network monitor now checks status synchronously on init
**Core Data Enhancements:**
- Persist hero image URLs in BookmarkEntity.heroImageURL
- Reconstruct ImageResource from cached URLs on offline load
- Add extensive logging for debugging persistence issues
**UI Updates:**
- Update BookmarkDetailView2 to use cache keys for hero images
- Update BookmarkCardView (all 3 layouts) with cache key support
- Improve BookmarksView offline state handling with task-based loading
- Add 50ms delay for network status propagation
**Technical Details:**
- NetworkMonitorRepository: Fix initial status from hardcoded true to actual network check
- BookmarksViewModel: Inject AppSettings for offline detection
- OfflineCacheRepository: Add verification logging for save/load operations
- BookmarkEntityMapper: Sync heroImageURL on save, restore on load
This enables full offline reading with hero images visible in bookmark lists
and detail views, even after app restart.
275 lines
9.0 KiB
Swift
275 lines
9.0 KiB
Swift
import Foundation
|
|
import CoreData
|
|
|
|
|
|
// MARK: - DTO -> Entity
|
|
|
|
extension BookmarkDto {
|
|
func toEntity(context: NSManagedObjectContext) -> BookmarkEntity {
|
|
let entity = BookmarkEntity(context: context)
|
|
entity.title = self.title
|
|
entity.url = self.url
|
|
entity.authors = self.authors.first
|
|
entity.desc = self.description
|
|
entity.created = self.created
|
|
|
|
entity.siteName = self.siteName
|
|
entity.site = self.site
|
|
entity.authors = self.authors.first // TODO: support multiple authors
|
|
entity.published = self.published
|
|
entity.created = self.created
|
|
entity.update = self.updated
|
|
entity.readingTime = Int16(self.readingTime ?? 0)
|
|
entity.readProgress = Int16(self.readProgress)
|
|
entity.wordCount = Int64(self.wordCount ?? 0)
|
|
entity.isArchived = self.isArchived
|
|
entity.isMarked = self.isMarked
|
|
entity.hasArticle = self.hasArticle
|
|
entity.loaded = self.loaded
|
|
entity.hasDeleted = self.isDeleted
|
|
entity.documentType = self.documentType
|
|
entity.href = self.href
|
|
entity.lang = self.lang
|
|
entity.textDirection = self.textDirection
|
|
entity.type = self.type
|
|
entity.state = Int16(self.state)
|
|
|
|
// entity.resources = self.resources.toEntity(context: context)
|
|
|
|
return entity
|
|
}
|
|
}
|
|
|
|
extension BookmarkResourcesDto {
|
|
func toEntity(context: NSManagedObjectContext) -> BookmarkResourcesEntity {
|
|
let entity = BookmarkResourcesEntity(context: context)
|
|
|
|
entity.article = self.article?.toEntity(context: context)
|
|
entity.icon = self.icon?.toEntity(context: context)
|
|
entity.image = self.image?.toEntity(context: context)
|
|
entity.log = self.log?.toEntity(context: context)
|
|
entity.props = self.props?.toEntity(context: context)
|
|
entity.thumbnail = self.thumbnail?.toEntity(context: context)
|
|
|
|
return entity
|
|
}
|
|
}
|
|
|
|
extension ImageResourceDto {
|
|
func toEntity(context: NSManagedObjectContext) -> ImageResourceEntity {
|
|
let entity = ImageResourceEntity(context: context)
|
|
entity.src = self.src
|
|
entity.width = Int64(self.width)
|
|
entity.height = Int64(self.height)
|
|
return entity
|
|
}
|
|
}
|
|
|
|
extension ResourceDto {
|
|
func toEntity(context: NSManagedObjectContext) -> ResourceEntity {
|
|
let entity = ResourceEntity(context: context)
|
|
entity.src = self.src
|
|
return entity
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------
|
|
|
|
// MARK: - BookmarkEntity to Domain Mapping
|
|
extension BookmarkEntity {
|
|
func toDomain() -> Bookmark? {
|
|
guard let id = self.id,
|
|
let title = self.title,
|
|
let url = self.url,
|
|
let href = self.href,
|
|
let created = self.created,
|
|
let update = self.update,
|
|
let siteName = self.siteName,
|
|
let site = self.site else {
|
|
return nil
|
|
}
|
|
|
|
// Reconstruct hero image from cached URL for offline access
|
|
let heroImage: ImageResource? = self.heroImageURL.flatMap { urlString in
|
|
ImageResource(src: urlString, height: 0, width: 0)
|
|
}
|
|
|
|
let resources = BookmarkResources(
|
|
article: nil,
|
|
icon: nil,
|
|
image: heroImage,
|
|
log: nil,
|
|
props: nil,
|
|
thumbnail: nil
|
|
)
|
|
|
|
return Bookmark(
|
|
id: id,
|
|
title: title,
|
|
url: url,
|
|
href: href,
|
|
description: self.desc ?? "",
|
|
authors: self.authors?.components(separatedBy: ",") ?? [],
|
|
created: created,
|
|
published: self.published,
|
|
updated: update,
|
|
siteName: siteName,
|
|
site: site,
|
|
readingTime: Int(self.readingTime),
|
|
wordCount: Int(self.wordCount),
|
|
hasArticle: self.hasArticle,
|
|
isArchived: self.isArchived,
|
|
isDeleted: self.hasDeleted,
|
|
isMarked: self.isMarked,
|
|
labels: [],
|
|
lang: self.lang,
|
|
loaded: self.loaded,
|
|
readProgress: Int(self.readProgress),
|
|
documentType: self.documentType ?? "",
|
|
state: Int(self.state),
|
|
textDirection: self.textDirection ?? "",
|
|
type: self.type ?? "",
|
|
resources: resources
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Domain to BookmarkEntity Mapping
|
|
extension Bookmark {
|
|
func toEntity(context: NSManagedObjectContext) -> BookmarkEntity {
|
|
let entity = BookmarkEntity(context: context)
|
|
entity.populateFrom(bookmark: self)
|
|
return entity
|
|
}
|
|
|
|
func updateEntity(_ entity: BookmarkEntity) {
|
|
entity.populateFrom(bookmark: self)
|
|
}
|
|
}
|
|
|
|
extension Resource {
|
|
func toEntity(context: NSManagedObjectContext) -> ResourceEntity {
|
|
let entity = ResourceEntity(context: context)
|
|
entity.populateFrom(resource: self)
|
|
return entity
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Helper Methods
|
|
private extension BookmarkEntity {
|
|
func populateFrom(bookmark: Bookmark) {
|
|
self.id = bookmark.id
|
|
self.title = bookmark.title
|
|
self.url = bookmark.url
|
|
self.desc = bookmark.description
|
|
self.siteName = bookmark.siteName
|
|
self.site = bookmark.site
|
|
self.authors = bookmark.authors.first // TODO: support multiple authors
|
|
self.published = bookmark.published
|
|
self.created = bookmark.created
|
|
self.update = bookmark.updated
|
|
self.readingTime = Int16(bookmark.readingTime ?? 0)
|
|
self.readProgress = Int16(bookmark.readProgress)
|
|
self.wordCount = Int64(bookmark.wordCount ?? 0)
|
|
self.isArchived = bookmark.isArchived
|
|
self.isMarked = bookmark.isMarked
|
|
self.hasArticle = bookmark.hasArticle
|
|
self.loaded = bookmark.loaded
|
|
self.hasDeleted = bookmark.isDeleted
|
|
self.documentType = bookmark.documentType
|
|
self.href = bookmark.href
|
|
self.lang = bookmark.lang
|
|
self.textDirection = bookmark.textDirection
|
|
self.type = bookmark.type
|
|
self.state = Int16(bookmark.state)
|
|
|
|
// Save hero image URL for offline access
|
|
if let heroImageUrl = bookmark.resources.image?.src {
|
|
self.heroImageURL = heroImageUrl
|
|
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
|
|
self.heroImageURL = thumbnailUrl
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - BookmarkState Mapping
|
|
private extension BookmarkState {
|
|
static func fromRawValue(_ value: Int) -> BookmarkState {
|
|
switch value {
|
|
case 0: return .unread
|
|
case 1: return .favorite
|
|
case 2: return .archived
|
|
default: return .unread
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension BookmarkResourcesEntity {
|
|
func populateFrom(bookmarkResources: BookmarkResources) {
|
|
|
|
}
|
|
}
|
|
|
|
private extension ImageResourceEntity {
|
|
func populateFrom(imageResource: ImageResource) {
|
|
self.src = imageResource.src
|
|
self.height = Int64(imageResource.height)
|
|
self.width = Int64(imageResource.width)
|
|
}
|
|
}
|
|
|
|
private extension ResourceEntity {
|
|
func populateFrom(resource: Resource) {
|
|
self.src = resource.src
|
|
}
|
|
}
|
|
|
|
// MARK: - Date Conversion Helpers
|
|
private extension String {
|
|
func toDate() -> Date? {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return formatter.date(from: self) ??
|
|
ISO8601DateFormatter().date(from: self)
|
|
}
|
|
}
|
|
|
|
private extension Date {
|
|
func toISOString() -> String {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return formatter.string(from: self)
|
|
}
|
|
}
|
|
|
|
// MARK: - Array Mapping Extensions
|
|
extension Array where Element == BookmarkEntity {
|
|
func toDomain() -> [Bookmark] {
|
|
return [] // self.map { $0.toDomain() }
|
|
}
|
|
}
|
|
|
|
extension Array where Element == Bookmark {
|
|
func toEntities(context: NSManagedObjectContext) -> [BookmarkEntity] {
|
|
return self.map { $0.toEntity(context: context) }
|
|
}
|
|
}
|
|
/*
|
|
extension BookmarkEntity {
|
|
func toDomain() -> Bookmark {
|
|
return Bookmark(id: id ?? "", title: title ?? "", url: url!, href: href ?? "", description: description, authors: [authors ?? ""], created: created ?? "", published: published, updated: update!, siteName: siteName ?? "", site: site!, readingTime: Int(readingTime), wordCount: Int(wordCount), hasArticle: hasArticle, isArchived: isArchived, isDeleted: isDeleted, isMarked: isMarked, labels: [], lang: lang, loaded: loaded, readProgress: Int(readProgress), documentType: documentType ?? "", state: Int(state), textDirection: textDirection ?? "", type: type ?? "", resources: resources.toDomain())
|
|
)
|
|
}
|
|
}
|
|
|
|
extension BookmarkResourcesEntity {
|
|
func toDomain() -> BookmarkResources {
|
|
return BookmarkResources(article: ar, icon: <#T##ImageResource?#>, image: <#T##ImageResource?#>, log: <#T##Resource?#>, props: <#T##Resource?#>, thumbnail: <#T##ImageResource?#>
|
|
}
|
|
}
|
|
|
|
extension ImageResourceEntity {
|
|
|
|
}
|
|
*/
|