Add offline cache infrastructure with clean architecture
Create separate cache repository layer: - Add POfflineCacheRepository protocol for cache operations - Add OfflineCacheRepository with CoreData and Kingfisher - Add OfflineCacheSyncUseCase to coordinate sync workflow - Update PBookmarksRepository to focus on API calls only - Extend BookmarkEntityMapper with toDomain() conversion UseCase coordinates between cache, API, and settings repositories following dependency inversion principle.
This commit is contained in:
parent
24dba33b39
commit
f5dab38038
@ -77,7 +77,56 @@ extension ResourceDto {
|
|||||||
|
|
||||||
// MARK: - BookmarkEntity to Domain Mapping
|
// MARK: - BookmarkEntity to Domain Mapping
|
||||||
extension BookmarkEntity {
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
let resources = BookmarkResources(
|
||||||
|
article: nil,
|
||||||
|
icon: nil,
|
||||||
|
image: nil,
|
||||||
|
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
|
// MARK: - Domain to BookmarkEntity Mapping
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CoreData
|
|
||||||
import Kingfisher
|
|
||||||
|
|
||||||
class BookmarksRepository: PBookmarksRepository {
|
class BookmarksRepository: PBookmarksRepository {
|
||||||
private var api: PAPI
|
private var api: PAPI
|
||||||
private let coreDataManager = CoreDataManager.shared
|
|
||||||
private let logger = Logger.sync
|
|
||||||
|
|
||||||
init(api: PAPI) {
|
init(api: PAPI) {
|
||||||
self.api = api
|
self.api = api
|
||||||
@ -84,288 +80,4 @@ class BookmarksRepository: PBookmarksRepository {
|
|||||||
func searchBookmarks(search: String) async throws -> BookmarksPage {
|
func searchBookmarks(search: String) async throws -> BookmarksPage {
|
||||||
try await api.searchBookmarks(search: search).toDomain()
|
try await api.searchBookmarks(search: search).toDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Offline Cache Methods
|
|
||||||
|
|
||||||
func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws {
|
|
||||||
// Check if already cached
|
|
||||||
if hasCachedArticle(id: bookmark.id) {
|
|
||||||
logger.debug("Bookmark \(bookmark.id) is already cached, skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let context = coreDataManager.context
|
|
||||||
|
|
||||||
try await context.perform { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
// Find or create BookmarkEntity
|
|
||||||
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
|
||||||
fetchRequest.predicate = NSPredicate(format: "id == %@", bookmark.id)
|
|
||||||
fetchRequest.fetchLimit = 1
|
|
||||||
|
|
||||||
let existingEntities = try context.fetch(fetchRequest)
|
|
||||||
let entity = existingEntities.first ?? BookmarkEntity(context: context)
|
|
||||||
|
|
||||||
// Populate entity from bookmark using existing mapper
|
|
||||||
bookmark.updateEntity(entity)
|
|
||||||
|
|
||||||
// Set cache-specific fields
|
|
||||||
entity.htmlContent = html
|
|
||||||
entity.cachedDate = Date()
|
|
||||||
entity.lastAccessDate = Date()
|
|
||||||
entity.cacheSize = Int64(html.utf8.count)
|
|
||||||
|
|
||||||
// Extract and save image URLs if needed
|
|
||||||
if saveImages {
|
|
||||||
let imageURLs = self.extractImageURLsFromHTML(html: html)
|
|
||||||
if !imageURLs.isEmpty {
|
|
||||||
entity.imageURLs = imageURLs.joined(separator: ",")
|
|
||||||
self.logger.debug("Found \(imageURLs.count) images for bookmark \(bookmark.id)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try context.save()
|
|
||||||
self.logger.info("Cached bookmark \(bookmark.id) with HTML (\(html.utf8.count) bytes)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefetch images with Kingfisher (outside of CoreData context)
|
|
||||||
if saveImages, let entity = try? await getCachedEntity(id: bookmark.id), let imageURLsString = entity.imageURLs {
|
|
||||||
let imageURLs = imageURLsString.split(separator: ",").compactMap { URL(string: String($0)) }
|
|
||||||
if !imageURLs.isEmpty {
|
|
||||||
await prefetchImagesWithKingfisher(imageURLs: imageURLs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCachedArticle(id: String) -> String? {
|
|
||||||
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
|
||||||
fetchRequest.predicate = NSPredicate(format: "id == %@ AND htmlContent != nil", id)
|
|
||||||
fetchRequest.fetchLimit = 1
|
|
||||||
|
|
||||||
do {
|
|
||||||
let results = try coreDataManager.context.fetch(fetchRequest)
|
|
||||||
if let entity = results.first {
|
|
||||||
// Update last access date
|
|
||||||
entity.lastAccessDate = Date()
|
|
||||||
coreDataManager.save()
|
|
||||||
logger.debug("Retrieved cached article for bookmark \(id)")
|
|
||||||
return entity.htmlContent
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logger.error("Error fetching cached article: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasCachedArticle(id: String) -> Bool {
|
|
||||||
return getCachedArticle(id: id) != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCachedBookmarks() async throws -> [Bookmark] {
|
|
||||||
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
|
||||||
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
|
|
||||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: false)]
|
|
||||||
|
|
||||||
let context = coreDataManager.context
|
|
||||||
return try await context.perform {
|
|
||||||
let entities = try context.fetch(fetchRequest)
|
|
||||||
self.logger.debug("Found \(entities.count) cached bookmarks")
|
|
||||||
|
|
||||||
// Convert entities to Bookmark domain objects
|
|
||||||
return entities.compactMap { entity -> Bookmark? in
|
|
||||||
guard let id = entity.id,
|
|
||||||
let title = entity.title,
|
|
||||||
let url = entity.url,
|
|
||||||
let href = entity.href,
|
|
||||||
let created = entity.created,
|
|
||||||
let update = entity.update,
|
|
||||||
let siteName = entity.siteName,
|
|
||||||
let site = entity.site else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create BookmarkResources (simplified for now)
|
|
||||||
let resources = BookmarkResources(
|
|
||||||
article: nil,
|
|
||||||
icon: nil,
|
|
||||||
image: nil,
|
|
||||||
log: nil,
|
|
||||||
props: nil,
|
|
||||||
thumbnail: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
return Bookmark(
|
|
||||||
id: id,
|
|
||||||
title: title,
|
|
||||||
url: url,
|
|
||||||
href: href,
|
|
||||||
description: entity.desc ?? "",
|
|
||||||
authors: entity.authors?.components(separatedBy: ",") ?? [],
|
|
||||||
created: created,
|
|
||||||
published: entity.published,
|
|
||||||
updated: update,
|
|
||||||
siteName: siteName,
|
|
||||||
site: site,
|
|
||||||
readingTime: Int(entity.readingTime),
|
|
||||||
wordCount: Int(entity.wordCount),
|
|
||||||
hasArticle: entity.hasArticle,
|
|
||||||
isArchived: entity.isArchived,
|
|
||||||
isDeleted: entity.hasDeleted,
|
|
||||||
isMarked: entity.isMarked,
|
|
||||||
labels: [],
|
|
||||||
lang: entity.lang,
|
|
||||||
loaded: entity.loaded,
|
|
||||||
readProgress: Int(entity.readProgress),
|
|
||||||
documentType: entity.documentType ?? "",
|
|
||||||
state: Int(entity.state),
|
|
||||||
textDirection: entity.textDirection ?? "",
|
|
||||||
type: entity.type ?? "",
|
|
||||||
resources: resources
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCachedArticlesCount() -> Int {
|
|
||||||
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
|
||||||
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
|
|
||||||
|
|
||||||
do {
|
|
||||||
let count = try coreDataManager.context.count(for: fetchRequest)
|
|
||||||
return count
|
|
||||||
} catch {
|
|
||||||
logger.error("Error counting cached articles: \(error.localizedDescription)")
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCacheSize() -> String {
|
|
||||||
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
|
||||||
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
|
|
||||||
|
|
||||||
do {
|
|
||||||
let entities = try coreDataManager.context.fetch(fetchRequest)
|
|
||||||
let totalBytes = entities.reduce(0) { $0 + $1.cacheSize }
|
|
||||||
return ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .file)
|
|
||||||
} catch {
|
|
||||||
logger.error("Error calculating cache size: \(error.localizedDescription)")
|
|
||||||
return "0 KB"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearCache() async throws {
|
|
||||||
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
|
||||||
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
|
|
||||||
|
|
||||||
let context = coreDataManager.context
|
|
||||||
try await context.perform { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
let entities = try context.fetch(fetchRequest)
|
|
||||||
for entity in entities {
|
|
||||||
entity.htmlContent = nil
|
|
||||||
entity.cachedDate = nil
|
|
||||||
entity.lastAccessDate = nil
|
|
||||||
entity.imageURLs = nil
|
|
||||||
entity.cacheSize = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
try context.save()
|
|
||||||
self.logger.info("Cleared cache for \(entities.count) articles")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: Also clear Kingfisher cache
|
|
||||||
// KingfisherManager.shared.cache.clearDiskCache()
|
|
||||||
// KingfisherManager.shared.cache.clearMemoryCache()
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanupOldestCachedArticles(keepCount: Int) async throws {
|
|
||||||
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
|
||||||
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
|
|
||||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: true)]
|
|
||||||
|
|
||||||
let context = coreDataManager.context
|
|
||||||
try await context.perform { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
let allEntities = try context.fetch(fetchRequest)
|
|
||||||
|
|
||||||
// Delete oldest articles if we exceed keepCount
|
|
||||||
if allEntities.count > keepCount {
|
|
||||||
let entitiesToDelete = allEntities.prefix(allEntities.count - keepCount)
|
|
||||||
for entity in entitiesToDelete {
|
|
||||||
entity.htmlContent = nil
|
|
||||||
entity.cachedDate = nil
|
|
||||||
entity.lastAccessDate = nil
|
|
||||||
entity.imageURLs = nil
|
|
||||||
entity.cacheSize = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
try context.save()
|
|
||||||
self.logger.info("Cleaned up \(entitiesToDelete.count) oldest cached articles (keeping \(keepCount))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helper Methods
|
|
||||||
|
|
||||||
private func extractImageURLsFromHTML(html: String) -> [String] {
|
|
||||||
var imageURLs: [String] = []
|
|
||||||
|
|
||||||
// Simple regex pattern for img tags
|
|
||||||
let pattern = #"<img[^>]+src=\"([^\"]+)\""#
|
|
||||||
|
|
||||||
if let regex = try? NSRegularExpression(pattern: pattern, options: []) {
|
|
||||||
let nsString = html as NSString
|
|
||||||
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length))
|
|
||||||
|
|
||||||
for result in results {
|
|
||||||
if result.numberOfRanges >= 2 {
|
|
||||||
let urlRange = result.range(at: 1)
|
|
||||||
if let url = nsString.substring(with: urlRange) as String? {
|
|
||||||
// Only include absolute URLs (http/https)
|
|
||||||
if url.hasPrefix("http") {
|
|
||||||
imageURLs.append(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Extracted \(imageURLs.count) image URLs from HTML")
|
|
||||||
return imageURLs
|
|
||||||
}
|
|
||||||
|
|
||||||
private func prefetchImagesWithKingfisher(imageURLs: [URL]) async {
|
|
||||||
guard !imageURLs.isEmpty else { return }
|
|
||||||
|
|
||||||
logger.info("Starting Kingfisher prefetch for \(imageURLs.count) images")
|
|
||||||
|
|
||||||
// Use Kingfisher's prefetcher with low priority
|
|
||||||
let prefetcher = ImagePrefetcher(urls: imageURLs) { [weak self] skippedResources, failedResources, completedResources in
|
|
||||||
self?.logger.info("Prefetch completed: \(completedResources.count)/\(imageURLs.count) images cached")
|
|
||||||
if !failedResources.isEmpty {
|
|
||||||
self?.logger.warning("Failed to cache \(failedResources.count) images")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: Set download priority to low for background downloads
|
|
||||||
// prefetcher.options = [.downloadPriority(.low)]
|
|
||||||
|
|
||||||
prefetcher.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getCachedEntity(id: String) async throws -> BookmarkEntity? {
|
|
||||||
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
|
||||||
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
|
|
||||||
fetchRequest.fetchLimit = 1
|
|
||||||
|
|
||||||
let context = coreDataManager.context
|
|
||||||
return try await context.perform {
|
|
||||||
let results = try context.fetch(fetchRequest)
|
|
||||||
return results.first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
272
readeck/Data/Repository/OfflineCacheRepository.swift
Normal file
272
readeck/Data/Repository/OfflineCacheRepository.swift
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
//
|
||||||
|
// OfflineCacheRepository.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Claude on 17.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
class OfflineCacheRepository: POfflineCacheRepository {
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let coreDataManager = CoreDataManager.shared
|
||||||
|
private let logger = Logger.sync
|
||||||
|
|
||||||
|
// MARK: - Cache Operations
|
||||||
|
|
||||||
|
func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws {
|
||||||
|
if hasCachedArticle(id: bookmark.id) {
|
||||||
|
logger.debug("Bookmark \(bookmark.id) is already cached, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try await saveBookmarkToCache(bookmark: bookmark, html: html, saveImages: saveImages)
|
||||||
|
|
||||||
|
if saveImages {
|
||||||
|
await prefetchImagesForBookmark(id: bookmark.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCachedArticle(id: String) -> String? {
|
||||||
|
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "id == %@ AND htmlContent != nil", id)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
do {
|
||||||
|
let results = try coreDataManager.context.fetch(fetchRequest)
|
||||||
|
if let entity = results.first {
|
||||||
|
// Update last access date
|
||||||
|
entity.lastAccessDate = Date()
|
||||||
|
coreDataManager.save()
|
||||||
|
logger.debug("Retrieved cached article for bookmark \(id)")
|
||||||
|
return entity.htmlContent
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Error fetching cached article: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCachedArticle(id: String) -> Bool {
|
||||||
|
return getCachedArticle(id: id) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCachedBookmarks() async throws -> [Bookmark] {
|
||||||
|
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
|
||||||
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: false)]
|
||||||
|
|
||||||
|
let context = coreDataManager.context
|
||||||
|
return try await context.perform {
|
||||||
|
let entities = try context.fetch(fetchRequest)
|
||||||
|
self.logger.debug("Found \(entities.count) cached bookmarks")
|
||||||
|
|
||||||
|
// Convert entities to Bookmark domain objects using mapper
|
||||||
|
return entities.compactMap { $0.toDomain() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Statistics
|
||||||
|
|
||||||
|
func getCachedArticlesCount() -> Int {
|
||||||
|
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let count = try coreDataManager.context.count(for: fetchRequest)
|
||||||
|
return count
|
||||||
|
} catch {
|
||||||
|
logger.error("Error counting cached articles: \(error.localizedDescription)")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCacheSize() -> String {
|
||||||
|
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let entities = try coreDataManager.context.fetch(fetchRequest)
|
||||||
|
let totalBytes = entities.reduce(0) { $0 + $1.cacheSize }
|
||||||
|
return ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .file)
|
||||||
|
} catch {
|
||||||
|
logger.error("Error calculating cache size: \(error.localizedDescription)")
|
||||||
|
return "0 KB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Management
|
||||||
|
|
||||||
|
func clearCache() async throws {
|
||||||
|
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
|
||||||
|
|
||||||
|
let context = coreDataManager.context
|
||||||
|
try await context.perform { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
let entities = try context.fetch(fetchRequest)
|
||||||
|
for entity in entities {
|
||||||
|
entity.htmlContent = nil
|
||||||
|
entity.cachedDate = nil
|
||||||
|
entity.lastAccessDate = nil
|
||||||
|
entity.imageURLs = nil
|
||||||
|
entity.cacheSize = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try context.save()
|
||||||
|
self.logger.info("Cleared cache for \(entities.count) articles")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Also clear Kingfisher cache
|
||||||
|
// KingfisherManager.shared.cache.clearDiskCache()
|
||||||
|
// KingfisherManager.shared.cache.clearMemoryCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupOldestCachedArticles(keepCount: Int) async throws {
|
||||||
|
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
|
||||||
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: true)]
|
||||||
|
|
||||||
|
let context = coreDataManager.context
|
||||||
|
try await context.perform { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
let allEntities = try context.fetch(fetchRequest)
|
||||||
|
|
||||||
|
// Delete oldest articles if we exceed keepCount
|
||||||
|
if allEntities.count > keepCount {
|
||||||
|
let entitiesToDelete = allEntities.prefix(allEntities.count - keepCount)
|
||||||
|
for entity in entitiesToDelete {
|
||||||
|
entity.htmlContent = nil
|
||||||
|
entity.cachedDate = nil
|
||||||
|
entity.lastAccessDate = nil
|
||||||
|
entity.imageURLs = nil
|
||||||
|
entity.cacheSize = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try context.save()
|
||||||
|
self.logger.info("Cleaned up \(entitiesToDelete.count) oldest cached articles (keeping \(keepCount))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helper Methods
|
||||||
|
|
||||||
|
private func saveBookmarkToCache(bookmark: Bookmark, html: String, saveImages: Bool) async throws {
|
||||||
|
let context = coreDataManager.context
|
||||||
|
|
||||||
|
try await context.perform { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
let entity = try self.findOrCreateEntity(for: bookmark.id, in: context)
|
||||||
|
bookmark.updateEntity(entity)
|
||||||
|
self.updateEntityWithCacheData(entity: entity, html: html, saveImages: saveImages)
|
||||||
|
|
||||||
|
try context.save()
|
||||||
|
self.logger.info("Cached bookmark \(bookmark.id) with HTML (\(html.utf8.count) bytes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findOrCreateEntity(for bookmarkId: String, in context: NSManagedObjectContext) throws -> BookmarkEntity {
|
||||||
|
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "id == %@", bookmarkId)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let existingEntities = try context.fetch(fetchRequest)
|
||||||
|
return existingEntities.first ?? BookmarkEntity(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateEntityWithCacheData(entity: BookmarkEntity, html: String, saveImages: Bool) {
|
||||||
|
entity.htmlContent = html
|
||||||
|
entity.cachedDate = Date()
|
||||||
|
entity.lastAccessDate = Date()
|
||||||
|
entity.cacheSize = Int64(html.utf8.count)
|
||||||
|
|
||||||
|
if saveImages {
|
||||||
|
let imageURLs = extractImageURLsFromHTML(html: html)
|
||||||
|
if !imageURLs.isEmpty {
|
||||||
|
entity.imageURLs = imageURLs.joined(separator: ",")
|
||||||
|
logger.debug("Found \(imageURLs.count) images for bookmark \(entity.id ?? "unknown")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prefetchImagesForBookmark(id: String) async {
|
||||||
|
guard let entity = try? await getCachedEntity(id: id),
|
||||||
|
let imageURLsString = entity.imageURLs else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageURLs = imageURLsString
|
||||||
|
.split(separator: ",")
|
||||||
|
.compactMap { URL(string: String($0)) }
|
||||||
|
|
||||||
|
if !imageURLs.isEmpty {
|
||||||
|
await prefetchImagesWithKingfisher(imageURLs: imageURLs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractImageURLsFromHTML(html: String) -> [String] {
|
||||||
|
var imageURLs: [String] = []
|
||||||
|
|
||||||
|
// Simple regex pattern for img tags
|
||||||
|
let pattern = #"<img[^>]+src=\"([^\"]+)\""#
|
||||||
|
|
||||||
|
if let regex = try? NSRegularExpression(pattern: pattern, options: []) {
|
||||||
|
let nsString = html as NSString
|
||||||
|
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length))
|
||||||
|
|
||||||
|
for result in results {
|
||||||
|
if result.numberOfRanges >= 2 {
|
||||||
|
let urlRange = result.range(at: 1)
|
||||||
|
if let url = nsString.substring(with: urlRange) as String? {
|
||||||
|
// Only include absolute URLs (http/https)
|
||||||
|
if url.hasPrefix("http") {
|
||||||
|
imageURLs.append(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Extracted \(imageURLs.count) image URLs from HTML")
|
||||||
|
return imageURLs
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prefetchImagesWithKingfisher(imageURLs: [URL]) async {
|
||||||
|
guard !imageURLs.isEmpty else { return }
|
||||||
|
|
||||||
|
logger.info("Starting Kingfisher prefetch for \(imageURLs.count) images")
|
||||||
|
|
||||||
|
// Use Kingfisher's prefetcher with low priority
|
||||||
|
let prefetcher = ImagePrefetcher(urls: imageURLs) { [weak self] skippedResources, failedResources, completedResources in
|
||||||
|
self?.logger.info("Prefetch completed: \(completedResources.count)/\(imageURLs.count) images cached")
|
||||||
|
if !failedResources.isEmpty {
|
||||||
|
self?.logger.warning("Failed to cache \(failedResources.count) images")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Set download priority to low for background downloads
|
||||||
|
// prefetcher.options = [.downloadPriority(.low)]
|
||||||
|
|
||||||
|
prefetcher.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getCachedEntity(id: String) async throws -> BookmarkEntity? {
|
||||||
|
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
|
let context = coreDataManager.context
|
||||||
|
return try await context.perform {
|
||||||
|
let results = try context.fetch(fetchRequest)
|
||||||
|
return results.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
protocol PBookmarksRepository {
|
protocol PBookmarksRepository {
|
||||||
// Existing Bookmark methods
|
// Bookmark API methods
|
||||||
func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage
|
func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage
|
||||||
func fetchBookmark(id: String) async throws -> BookmarkDetail
|
func fetchBookmark(id: String) async throws -> BookmarkDetail
|
||||||
func fetchBookmarkArticle(id: String) async throws -> String
|
func fetchBookmarkArticle(id: String) async throws -> String
|
||||||
@ -14,14 +14,4 @@ protocol PBookmarksRepository {
|
|||||||
func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws
|
func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws
|
||||||
func deleteBookmark(id: String) async throws
|
func deleteBookmark(id: String) async throws
|
||||||
func searchBookmarks(search: String) async throws -> BookmarksPage
|
func searchBookmarks(search: String) async throws -> BookmarksPage
|
||||||
|
|
||||||
// Offline Cache methods
|
|
||||||
func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws
|
|
||||||
func getCachedArticle(id: String) -> String?
|
|
||||||
func hasCachedArticle(id: String) -> Bool
|
|
||||||
func getCachedBookmarks() async throws -> [Bookmark]
|
|
||||||
func getCachedArticlesCount() -> Int
|
|
||||||
func getCacheSize() -> String
|
|
||||||
func clearCache() async throws
|
|
||||||
func cleanupOldestCachedArticles(keepCount: Int) async throws
|
|
||||||
}
|
}
|
||||||
|
|||||||
24
readeck/Domain/Protocols/POfflineCacheRepository.swift
Normal file
24
readeck/Domain/Protocols/POfflineCacheRepository.swift
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
//
|
||||||
|
// POfflineCacheRepository.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Claude on 17.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol POfflineCacheRepository {
|
||||||
|
// Cache operations
|
||||||
|
func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws
|
||||||
|
func hasCachedArticle(id: String) -> Bool
|
||||||
|
func getCachedArticle(id: String) -> String?
|
||||||
|
func getCachedBookmarks() async throws -> [Bookmark]
|
||||||
|
|
||||||
|
// Cache statistics
|
||||||
|
func getCachedArticlesCount() -> Int
|
||||||
|
func getCacheSize() -> String
|
||||||
|
|
||||||
|
// Cache management
|
||||||
|
func clearCache() async throws
|
||||||
|
func cleanupOldestCachedArticles(keepCount: Int) async throws
|
||||||
|
}
|
||||||
159
readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift
Normal file
159
readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
//
|
||||||
|
// OfflineCacheSyncUseCase.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Claude on 17.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Protocol
|
||||||
|
|
||||||
|
protocol POfflineCacheSyncUseCase {
|
||||||
|
var isSyncing: AnyPublisher<Bool, Never> { get }
|
||||||
|
var syncProgress: AnyPublisher<String?, Never> { get }
|
||||||
|
|
||||||
|
func syncOfflineArticles(settings: OfflineSettings) async
|
||||||
|
func getCachedArticlesCount() -> Int
|
||||||
|
func getCacheSize() -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Implementation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let offlineCacheRepository: POfflineCacheRepository
|
||||||
|
private let bookmarksRepository: PBookmarksRepository
|
||||||
|
private let settingsRepository: PSettingsRepository
|
||||||
|
|
||||||
|
// MARK: - Published State
|
||||||
|
|
||||||
|
@Published private var _isSyncing = false
|
||||||
|
@Published private var _syncProgress: String?
|
||||||
|
|
||||||
|
var isSyncing: AnyPublisher<Bool, Never> {
|
||||||
|
$_isSyncing.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncProgress: AnyPublisher<String?, Never> {
|
||||||
|
$_syncProgress.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(
|
||||||
|
offlineCacheRepository: POfflineCacheRepository,
|
||||||
|
bookmarksRepository: PBookmarksRepository,
|
||||||
|
settingsRepository: PSettingsRepository
|
||||||
|
) {
|
||||||
|
self.offlineCacheRepository = offlineCacheRepository
|
||||||
|
self.bookmarksRepository = bookmarksRepository
|
||||||
|
self.settingsRepository = settingsRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
func syncOfflineArticles(settings: OfflineSettings) async {
|
||||||
|
guard settings.enabled else {
|
||||||
|
Logger.sync.info("Offline sync skipped: disabled in settings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_isSyncing = true
|
||||||
|
Logger.sync.info("🔄 Starting offline sync (max: \(settings.maxUnreadArticlesInt) articles, images: \(settings.saveImages))")
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Fetch unread bookmarks from API
|
||||||
|
let page = try await bookmarksRepository.fetchBookmarks(
|
||||||
|
state: .unread,
|
||||||
|
limit: settings.maxUnreadArticlesInt,
|
||||||
|
offset: 0,
|
||||||
|
search: nil,
|
||||||
|
type: nil,
|
||||||
|
tag: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let bookmarks = page.bookmarks
|
||||||
|
Logger.sync.info("📚 Fetched \(bookmarks.count) unread bookmarks")
|
||||||
|
|
||||||
|
var successCount = 0
|
||||||
|
var skippedCount = 0
|
||||||
|
var errorCount = 0
|
||||||
|
|
||||||
|
// Process each bookmark
|
||||||
|
for (index, bookmark) in bookmarks.enumerated() {
|
||||||
|
let progress = "\(index + 1)/\(bookmarks.count)"
|
||||||
|
|
||||||
|
// Check cache status
|
||||||
|
if offlineCacheRepository.hasCachedArticle(id: bookmark.id) {
|
||||||
|
Logger.sync.debug("⏭️ Skipping '\(bookmark.title)' (already cached)")
|
||||||
|
skippedCount += 1
|
||||||
|
_syncProgress = "⏭️ Artikel \(progress) bereits gecacht..."
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
let imagesSuffix = settings.saveImages ? " + Bilder" : ""
|
||||||
|
_syncProgress = "📥 Artikel \(progress)\(imagesSuffix)..."
|
||||||
|
Logger.sync.info("📥 Caching '\(bookmark.title)'")
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Fetch article HTML from API
|
||||||
|
let html = try await bookmarksRepository.fetchBookmarkArticle(id: bookmark.id)
|
||||||
|
|
||||||
|
// Cache with metadata
|
||||||
|
try await offlineCacheRepository.cacheBookmarkWithMetadata(
|
||||||
|
bookmark: bookmark,
|
||||||
|
html: html,
|
||||||
|
saveImages: settings.saveImages
|
||||||
|
)
|
||||||
|
|
||||||
|
successCount += 1
|
||||||
|
Logger.sync.info("✅ Cached '\(bookmark.title)'")
|
||||||
|
} catch {
|
||||||
|
errorCount += 1
|
||||||
|
Logger.sync.error("❌ Failed to cache '\(bookmark.title)': \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup old articles (FIFO)
|
||||||
|
try await offlineCacheRepository.cleanupOldestCachedArticles(keepCount: settings.maxUnreadArticlesInt)
|
||||||
|
|
||||||
|
// Update last sync date in settings
|
||||||
|
var updatedSettings = settings
|
||||||
|
updatedSettings.lastSyncDate = Date()
|
||||||
|
try await settingsRepository.saveOfflineSettings(updatedSettings)
|
||||||
|
|
||||||
|
// Final status
|
||||||
|
let statusMessage = "✅ Synchronisiert: \(successCount), Übersprungen: \(skippedCount), Fehler: \(errorCount)"
|
||||||
|
Logger.sync.info(statusMessage)
|
||||||
|
_syncProgress = statusMessage
|
||||||
|
|
||||||
|
// Clear progress message after 3 seconds
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
_syncProgress = nil
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Logger.sync.error("❌ Offline sync failed: \(error.localizedDescription)")
|
||||||
|
_syncProgress = "❌ Synchronisierung fehlgeschlagen"
|
||||||
|
|
||||||
|
// Clear error message after 5 seconds
|
||||||
|
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||||
|
_syncProgress = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_isSyncing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCachedArticlesCount() -> Int {
|
||||||
|
offlineCacheRepository.getCachedArticlesCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCacheSize() -> String {
|
||||||
|
offlineCacheRepository.getCacheSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user