From f5dab380384afda79a9b2d848ac00bc2065ace4f Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Mon, 17 Nov 2025 23:53:44 +0100 Subject: [PATCH] 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. --- .../Data/Mappers/BookmarkEntityMapper.swift | 51 ++- .../Data/Repository/BookmarksRepository.swift | 310 +----------------- .../Repository/OfflineCacheRepository.swift | 272 +++++++++++++++ .../Protocols/PBookmarksRepository.swift | 12 +- .../Protocols/POfflineCacheRepository.swift | 24 ++ .../UseCase/OfflineCacheSyncUseCase.swift | 159 +++++++++ 6 files changed, 517 insertions(+), 311 deletions(-) create mode 100644 readeck/Data/Repository/OfflineCacheRepository.swift create mode 100644 readeck/Domain/Protocols/POfflineCacheRepository.swift create mode 100644 readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift diff --git a/readeck/Data/Mappers/BookmarkEntityMapper.swift b/readeck/Data/Mappers/BookmarkEntityMapper.swift index a13d681..33f0a4e 100644 --- a/readeck/Data/Mappers/BookmarkEntityMapper.swift +++ b/readeck/Data/Mappers/BookmarkEntityMapper.swift @@ -77,7 +77,56 @@ extension ResourceDto { // 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 + } + + 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 diff --git a/readeck/Data/Repository/BookmarksRepository.swift b/readeck/Data/Repository/BookmarksRepository.swift index 91b133b..8b53941 100644 --- a/readeck/Data/Repository/BookmarksRepository.swift +++ b/readeck/Data/Repository/BookmarksRepository.swift @@ -1,21 +1,17 @@ import Foundation -import CoreData -import Kingfisher class BookmarksRepository: PBookmarksRepository { private var api: PAPI - private let coreDataManager = CoreDataManager.shared - private let logger = Logger.sync init(api: PAPI) { self.api = api } - + func fetchBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil, tag: String? = nil) async throws -> BookmarksPage { let bookmarkDtos = try await api.getBookmarks(state: state, limit: limit, offset: offset, search: search, type: type, tag: tag) return bookmarkDtos.toDomain() } - + func fetchBookmark(id: String) async throws -> BookmarkDetail { let bookmarkDetailDto = try await api.getBookmark(id: id) return BookmarkDetail( @@ -39,32 +35,32 @@ class BookmarksRepository: PBookmarksRepository { readProgress: bookmarkDetailDto.readProgress ) } - + func fetchBookmarkArticle(id: String) async throws -> String { return try await api.getBookmarkArticle(id: id) } - + func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String { let dto = CreateBookmarkRequestDto( url: createRequest.url, title: createRequest.title, labels: createRequest.labels ) - + let response = try await api.createBookmark(createRequest: dto) - + // Prüfe ob die Erstellung erfolgreich war guard response.status == 0 || response.status == 202 else { throw CreateBookmarkError.serverError(response.message) } - + return response.message } - + func deleteBookmark(id: String) async throws { try await api.deleteBookmark(id: id) } - + func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws { let dto = UpdateBookmarkRequestDto( addLabels: updateRequest.addLabels, @@ -77,295 +73,11 @@ class BookmarksRepository: PBookmarksRepository { removeLabels: updateRequest.removeLabels, title: updateRequest.title ) - + try await api.updateBookmark(id: id, updateRequest: dto) } - + func searchBookmarks(search: String) async throws -> BookmarksPage { 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.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.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.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.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.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.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.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 = #"]+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.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 - } - } } diff --git a/readeck/Data/Repository/OfflineCacheRepository.swift b/readeck/Data/Repository/OfflineCacheRepository.swift new file mode 100644 index 0000000..93d38f4 --- /dev/null +++ b/readeck/Data/Repository/OfflineCacheRepository.swift @@ -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.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.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.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.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.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.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.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 = #"]+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.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 + } + } +} diff --git a/readeck/Domain/Protocols/PBookmarksRepository.swift b/readeck/Domain/Protocols/PBookmarksRepository.swift index 4a78ef1..fd4e2c5 100644 --- a/readeck/Domain/Protocols/PBookmarksRepository.swift +++ b/readeck/Domain/Protocols/PBookmarksRepository.swift @@ -6,7 +6,7 @@ // 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 fetchBookmark(id: String) async throws -> BookmarkDetail func fetchBookmarkArticle(id: String) async throws -> String @@ -14,14 +14,4 @@ protocol PBookmarksRepository { func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws func deleteBookmark(id: String) async throws 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 } diff --git a/readeck/Domain/Protocols/POfflineCacheRepository.swift b/readeck/Domain/Protocols/POfflineCacheRepository.swift new file mode 100644 index 0000000..e628a0b --- /dev/null +++ b/readeck/Domain/Protocols/POfflineCacheRepository.swift @@ -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 +} diff --git a/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift b/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift new file mode 100644 index 0000000..49699c2 --- /dev/null +++ b/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift @@ -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 { get } + var syncProgress: AnyPublisher { 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 { + $_isSyncing.eraseToAnyPublisher() + } + + var syncProgress: AnyPublisher { + $_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() + } +}