diff --git a/readeck/Data/Repository/BookmarksRepository.swift b/readeck/Data/Repository/BookmarksRepository.swift index 56e7208..91b133b 100644 --- a/readeck/Data/Repository/BookmarksRepository.swift +++ b/readeck/Data/Repository/BookmarksRepository.swift @@ -1,7 +1,11 @@ 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 @@ -80,4 +84,288 @@ class BookmarksRepository: PBookmarksRepository { 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/SettingsRepository.swift b/readeck/Data/Repository/SettingsRepository.swift index fe82342..514eb19 100644 --- a/readeck/Data/Repository/SettingsRepository.swift +++ b/readeck/Data/Repository/SettingsRepository.swift @@ -1,22 +1,6 @@ import Foundation import CoreData -protocol PSettingsRepository { - func saveSettings(_ settings: Settings) async throws - func loadSettings() async throws -> Settings? - func clearSettings() async throws - func saveToken(_ token: String) async throws - func saveUsername(_ username: String) async throws - func savePassword(_ password: String) async throws - func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws - func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws - func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws - func loadCardLayoutStyle() async throws -> CardLayoutStyle - func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws - func loadTagSortOrder() async throws -> TagSortOrder - var hasFinishedSetup: Bool { get } -} - class SettingsRepository: PSettingsRepository { private let coreDataManager = CoreDataManager.shared private let userDefault = UserDefaults.standard @@ -286,4 +270,28 @@ class SettingsRepository: PSettingsRepository { } } } + + // MARK: - Offline Settings + + private let offlineSettingsKey = "offlineSettings" + private let logger = Logger.data + + func loadOfflineSettings() async throws -> OfflineSettings { + guard let data = userDefault.data(forKey: offlineSettingsKey) else { + logger.info("No offline settings found, returning defaults") + return OfflineSettings() // Default settings + } + + let decoder = JSONDecoder() + let settings = try decoder.decode(OfflineSettings.self, from: data) + logger.debug("Loaded offline settings: enabled=\(settings.enabled), max=\(settings.maxUnreadArticlesInt)") + return settings + } + + func saveOfflineSettings(_ settings: OfflineSettings) async throws { + let encoder = JSONEncoder() + let data = try encoder.encode(settings) + userDefault.set(data, forKey: offlineSettingsKey) + logger.info("Saved offline settings: enabled=\(settings.enabled), max=\(settings.maxUnreadArticlesInt)") + } } diff --git a/readeck/Domain/Model/OfflineSettings.swift b/readeck/Domain/Model/OfflineSettings.swift new file mode 100644 index 0000000..d142805 --- /dev/null +++ b/readeck/Domain/Model/OfflineSettings.swift @@ -0,0 +1,30 @@ +// +// OfflineSettings.swift +// readeck +// +// Created by Claude on 08.11.25. +// + +import Foundation + +struct OfflineSettings: Codable { + var enabled: Bool = true + var maxUnreadArticles: Double = 20 // Double für Slider (Default: 20 Artikel) + var saveImages: Bool = false + var lastSyncDate: Date? + + var maxUnreadArticlesInt: Int { + Int(maxUnreadArticles) + } + + var shouldSyncOnAppStart: Bool { + guard enabled else { return false } + + // Sync if never synced before + guard let lastSync = lastSyncDate else { return true } + + // Sync if more than 4 hours since last sync + let fourHoursAgo = Date().addingTimeInterval(-4 * 60 * 60) + return lastSync < fourHoursAgo + } +} diff --git a/readeck/Domain/Protocols/PBookmarksRepository.swift b/readeck/Domain/Protocols/PBookmarksRepository.swift index 4d25fd1..4a78ef1 100644 --- a/readeck/Domain/Protocols/PBookmarksRepository.swift +++ b/readeck/Domain/Protocols/PBookmarksRepository.swift @@ -6,6 +6,7 @@ // protocol PBookmarksRepository { + // Existing Bookmark 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 @@ -13,4 +14,14 @@ 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/PSettingsRepository.swift b/readeck/Domain/Protocols/PSettingsRepository.swift new file mode 100644 index 0000000..978ef60 --- /dev/null +++ b/readeck/Domain/Protocols/PSettingsRepository.swift @@ -0,0 +1,29 @@ +// +// PSettingsRepository.swift +// readeck +// +// Created by Claude on 08.11.25. +// + +import Foundation + +protocol PSettingsRepository { + // Existing Settings methods + func saveSettings(_ settings: Settings) async throws + func loadSettings() async throws -> Settings? + func clearSettings() async throws + func saveToken(_ token: String) async throws + func saveUsername(_ username: String) async throws + func savePassword(_ password: String) async throws + func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws + func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws + func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws + func loadCardLayoutStyle() async throws -> CardLayoutStyle + func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws + func loadTagSortOrder() async throws -> TagSortOrder + var hasFinishedSetup: Bool { get } + + // Offline Settings methods + func loadOfflineSettings() async throws -> OfflineSettings + func saveOfflineSettings(_ settings: OfflineSettings) async throws +} diff --git a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents index 37064bb..8034741 100644 --- a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents +++ b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents @@ -8,16 +8,21 @@ + + + + +