diff --git a/readeck/Data/Utils/HTMLImageEmbedder.swift b/readeck/Data/Utils/HTMLImageEmbedder.swift new file mode 100644 index 0000000..1dc0364 --- /dev/null +++ b/readeck/Data/Utils/HTMLImageEmbedder.swift @@ -0,0 +1,107 @@ +// +// HTMLImageEmbedder.swift +// readeck +// +// Created by Ilyas Hallak on 30.11.25. +// + +import Foundation +import Kingfisher + +/// Utility for embedding images as Base64 data URIs in HTML +struct HTMLImageEmbedder { + + private let imageExtractor = HTMLImageExtractor() + + /// Embeds all images in HTML as Base64 data URIs for offline viewing + /// - Parameter html: The HTML string containing image tags + /// - Returns: Modified HTML with images embedded as Base64 + func embedBase64Images(in html: String) async -> String { + Logger.sync.info("🔄 Starting Base64 image embedding for offline HTML") + + var modifiedHTML = html + let imageURLs = imageExtractor.extract(from: html) + + Logger.sync.info("📊 Found \(imageURLs.count) images to embed") + + var stats = EmbedStatistics() + + for (index, imageURL) in imageURLs.enumerated() { + Logger.sync.debug("Processing image \(index + 1)/\(imageURLs.count): \(imageURL)") + + guard let url = URL(string: imageURL) else { + Logger.sync.warning("❌ Invalid URL: \(imageURL)") + stats.failedCount += 1 + continue + } + + // Try to get image from Kingfisher cache + guard let image = await retrieveImageFromCache(url: url) else { + Logger.sync.warning("❌ Image not found in cache: \(imageURL)") + stats.failedCount += 1 + continue + } + + // Convert to Base64 and embed + if let base64DataURI = convertToBase64DataURI(image: image) { + let beforeLength = modifiedHTML.count + modifiedHTML = modifiedHTML.replacingOccurrences(of: imageURL, with: base64DataURI) + let afterLength = modifiedHTML.count + + if afterLength > beforeLength { + Logger.sync.debug("✅ Embedded image \(index + 1) as Base64") + stats.successCount += 1 + } else { + Logger.sync.warning("⚠️ Image URL found but not replaced in HTML: \(imageURL)") + stats.failedCount += 1 + } + } else { + Logger.sync.warning("❌ Failed to convert image to Base64: \(imageURL)") + stats.failedCount += 1 + } + } + + logEmbedResults(stats: stats, originalSize: html.utf8.count, finalSize: modifiedHTML.utf8.count) + return modifiedHTML + } + + // MARK: - Private Helper Methods + + private func retrieveImageFromCache(url: URL) async -> KFCrossPlatformImage? { + await withCheckedContinuation { continuation in + KingfisherManager.shared.cache.retrieveImage(forKey: url.cacheKey) { result in + switch result { + case .success(let cacheResult): + continuation.resume(returning: cacheResult.image) + case .failure(let error): + Logger.sync.error("❌ Kingfisher cache retrieval error: \(error.localizedDescription)") + continuation.resume(returning: nil) + } + } + } + } + + private func convertToBase64DataURI(image: KFCrossPlatformImage) -> String? { + guard let imageData = image.jpegData(compressionQuality: 0.85) else { + return nil + } + + let base64String = imageData.base64EncodedString() + return "data:image/jpeg;base64,\(base64String)" + } + + private func logEmbedResults(stats: EmbedStatistics, originalSize: Int, finalSize: Int) { + let total = stats.successCount + stats.failedCount + let growth = finalSize - originalSize + + Logger.sync.info("✅ Base64 embedding complete: \(stats.successCount) succeeded, \(stats.failedCount) failed out of \(total) images") + Logger.sync.info("📈 HTML size: \(originalSize) → \(finalSize) bytes (growth: \(growth) bytes)") + } +} + +// MARK: - Helper Types + +private struct EmbedStatistics { + var successCount = 0 + var failedCount = 0 +} diff --git a/readeck/Data/Utils/HTMLImageExtractor.swift b/readeck/Data/Utils/HTMLImageExtractor.swift new file mode 100644 index 0000000..2e6d077 --- /dev/null +++ b/readeck/Data/Utils/HTMLImageExtractor.swift @@ -0,0 +1,63 @@ +// +// HTMLImageExtractor.swift +// readeck +// +// Created by Ilyas Hallak on 30.11.25. +// + +import Foundation + +/// Utility for extracting image URLs from HTML content +struct HTMLImageExtractor { + + /// Extracts all image URLs from HTML using regex + /// - Parameter html: The HTML string to parse + /// - Returns: Array of absolute image URLs (http/https only) + func extract(from html: String) -> [String] { + var imageURLs: [String] = [] + + // Simple regex pattern for img tags + let pattern = #"]+src="([^"]+)""# + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return imageURLs + } + + 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?, + url.hasPrefix("http") { // Only include absolute URLs + imageURLs.append(url) + } + } + } + + Logger.sync.debug("Extracted \(imageURLs.count) image URLs from HTML") + return imageURLs + } + + /// Extracts image URLs from HTML and optionally prepends hero/thumbnail image + /// - Parameters: + /// - html: The HTML string to parse + /// - heroImageURL: Optional hero image URL to prepend + /// - thumbnailURL: Optional thumbnail URL to prepend if no hero image + /// - Returns: Array of image URLs with hero/thumbnail first if provided + func extract(from html: String, heroImageURL: String? = nil, thumbnailURL: String? = nil) -> [String] { + var imageURLs = extract(from: html) + + // Prepend hero or thumbnail image if available + if let heroURL = heroImageURL { + imageURLs.insert(heroURL, at: 0) + Logger.sync.debug("Added hero image: \(heroURL)") + } else if let thumbURL = thumbnailURL { + imageURLs.insert(thumbURL, at: 0) + Logger.sync.debug("Added thumbnail image: \(thumbURL)") + } + + return imageURLs + } +} diff --git a/readeck/Data/Utils/KingfisherImagePrefetcher.swift b/readeck/Data/Utils/KingfisherImagePrefetcher.swift new file mode 100644 index 0000000..8113a26 --- /dev/null +++ b/readeck/Data/Utils/KingfisherImagePrefetcher.swift @@ -0,0 +1,192 @@ +// +// KingfisherImagePrefetcher.swift +// readeck +// +// Created by Claude on 30.11.25. +// + +import Foundation +import Kingfisher + +/// Wrapper around Kingfisher for prefetching and caching images for offline use +class KingfisherImagePrefetcher { + + // MARK: - Public Methods + + /// Prefetches images and stores them in Kingfisher cache for offline access + /// - Parameter urls: Array of image URLs to prefetch + func prefetchImages(urls: [URL]) async { + guard !urls.isEmpty else { return } + + Logger.sync.info("🔄 Starting Kingfisher prefetch for \(urls.count) images") + logPrefetchURLs(urls) + + let options = buildOfflineCachingOptions() + + await withCheckedContinuation { (continuation: CheckedContinuation) in + let prefetcher = ImagePrefetcher( + urls: urls, + options: options, + progressBlock: { [weak self] skippedResources, failedResources, completedResources in + self?.logPrefetchProgress( + total: urls.count, + completed: completedResources.count, + failed: failedResources.count, + skipped: skippedResources.count + ) + }, + completionHandler: { [weak self] skippedResources, failedResources, completedResources in + self?.logPrefetchCompletion( + total: urls.count, + completed: completedResources.count, + failed: failedResources.count, + skipped: skippedResources.count + ) + + // Verify cache after prefetch + Task { + await self?.verifyPrefetchedImages(urls) + continuation.resume() + } + } + ) + prefetcher.start() + } + } + + /// Caches an image with a custom key for offline retrieval + /// - Parameters: + /// - url: The image URL to download + /// - key: Custom cache key + func cacheImageWithCustomKey(url: URL, key: String) async { + Logger.sync.debug("Caching image with custom key: \(key)") + + // Check if already cached + if await isImageCached(forKey: key) { + Logger.sync.debug("Image already cached with key: \(key)") + return + } + + // Download and cache with custom key + let image = await downloadImage(from: url) + + if let image = image { + try? await ImageCache.default.store(image, forKey: key) + Logger.sync.info("✅ Cached image with custom key: \(key)") + } else { + Logger.sync.warning("❌ Failed to cache image with key: \(key)") + } + } + + /// Clears cached images from Kingfisher cache + /// - Parameter urls: Array of image URLs to clear + func clearCachedImages(urls: [URL]) async { + guard !urls.isEmpty else { return } + + Logger.sync.info("Clearing Kingfisher cache for \(urls.count) images") + + await withTaskGroup(of: Void.self) { group in + for url in urls { + group.addTask { + try? await KingfisherManager.shared.cache.removeImage(forKey: url.cacheKey) + } + } + } + + Logger.sync.info("✅ Kingfisher cache cleared for \(urls.count) images") + } + + /// Verifies that images are present in cache + /// - Parameter urls: Array of URLs to verify + func verifyPrefetchedImages(_ urls: [URL]) async { + Logger.sync.info("🔍 Verifying prefetched images in cache...") + + var cachedCount = 0 + var missingCount = 0 + + for url in urls { + let isCached = await isImageCached(forKey: url.cacheKey) + + if isCached { + cachedCount += 1 + Logger.sync.debug("✅ Verified in cache: \(url.absoluteString)") + } else { + missingCount += 1 + Logger.sync.warning("❌ NOT in cache after prefetch: \(url.absoluteString)") + } + } + + Logger.sync.info("📊 Cache verification: \(cachedCount) cached, \(missingCount) missing out of \(urls.count) total") + } + + // MARK: - Private Helper Methods + + private func buildOfflineCachingOptions() -> KingfisherOptionsInfo { + [ + .cacheOriginalImage, + .diskCacheExpiration(.never), // Keep images as long as article is cached + .backgroundDecode, + ] + } + + private func logPrefetchURLs(_ urls: [URL]) { + for (index, url) in urls.enumerated() { + Logger.sync.debug("[\(index + 1)/\(urls.count)] Prefetching: \(url.absoluteString)") + Logger.sync.debug(" Cache key: \(url.cacheKey)") + } + } + + private func logPrefetchProgress( + total: Int, + completed: Int, + failed: Int, + skipped: Int + ) { + let progress = completed + failed + skipped + Logger.sync.debug("Prefetch progress: \(progress)/\(total) - completed: \(completed), failed: \(failed), skipped: \(skipped)") + } + + private func logPrefetchCompletion( + total: Int, + completed: Int, + failed: Int, + skipped: Int + ) { + Logger.sync.info("✅ Prefetch completed: \(completed)/\(total) images cached") + + if failed > 0 { + Logger.sync.warning("❌ Failed to cache \(failed) images") + } + + if skipped > 0 { + Logger.sync.info("⏭️ Skipped \(skipped) images (already cached)") + } + } + + private func isImageCached(forKey key: String) async -> Bool { + await withCheckedContinuation { continuation in + ImageCache.default.retrieveImage(forKey: key) { result in + switch result { + case .success(let cacheResult): + continuation.resume(returning: cacheResult.image != nil) + case .failure: + continuation.resume(returning: false) + } + } + } + } + + private func downloadImage(from url: URL) async -> KFCrossPlatformImage? { + await withCheckedContinuation { continuation in + KingfisherManager.shared.retrieveImage(with: url) { result in + switch result { + case .success(let imageResult): + continuation.resume(returning: imageResult.image) + case .failure(let error): + Logger.sync.error("Failed to download image: \(error.localizedDescription)") + continuation.resume(returning: nil) + } + } + } + } +} diff --git a/readeck/Domain/UseCase/CreateAnnotationUseCase.swift b/readeck/Domain/UseCase/CreateAnnotationUseCase.swift new file mode 100644 index 0000000..a402e24 --- /dev/null +++ b/readeck/Domain/UseCase/CreateAnnotationUseCase.swift @@ -0,0 +1,45 @@ +// +// CreateAnnotationUseCase.swift +// readeck +// +// Created by Ilyas Hallak on 30.11.25. +// + +import Foundation + +protocol PCreateAnnotationUseCase { + func execute( + bookmarkId: String, + color: String, + startOffset: Int, + endOffset: Int, + startSelector: String, + endSelector: String + ) async throws -> Annotation +} + +class CreateAnnotationUseCase: PCreateAnnotationUseCase { + private let repository: PAnnotationsRepository + + init(repository: PAnnotationsRepository) { + self.repository = repository + } + + func execute( + bookmarkId: String, + color: String, + startOffset: Int, + endOffset: Int, + startSelector: String, + endSelector: String + ) async throws -> Annotation { + return try await repository.createAnnotation( + bookmarkId: bookmarkId, + color: color, + startOffset: startOffset, + endOffset: endOffset, + startSelector: startSelector, + endSelector: endSelector + ) + } +} diff --git a/readeck/Domain/UseCase/GetCachedArticleUseCase.swift b/readeck/Domain/UseCase/GetCachedArticleUseCase.swift new file mode 100644 index 0000000..bd963e7 --- /dev/null +++ b/readeck/Domain/UseCase/GetCachedArticleUseCase.swift @@ -0,0 +1,24 @@ +// +// GetCachedArticleUseCase.swift +// readeck +// +// Created by Ilyas Hallak on 30.11.25. +// + +import Foundation + +protocol PGetCachedArticleUseCase { + func execute(id: String) -> String? +} + +class GetCachedArticleUseCase: PGetCachedArticleUseCase { + private let offlineCacheRepository: POfflineCacheRepository + + init(offlineCacheRepository: POfflineCacheRepository) { + self.offlineCacheRepository = offlineCacheRepository + } + + func execute(id: String) -> String? { + return offlineCacheRepository.getCachedArticle(id: id) + } +} diff --git a/readeck/Domain/UseCase/GetCachedBookmarksUseCase.swift b/readeck/Domain/UseCase/GetCachedBookmarksUseCase.swift new file mode 100644 index 0000000..96b301e --- /dev/null +++ b/readeck/Domain/UseCase/GetCachedBookmarksUseCase.swift @@ -0,0 +1,24 @@ +// +// GetCachedBookmarksUseCase.swift +// readeck +// +// Created by Ilyas Hallak on 30.11.25. +// + +import Foundation + +protocol PGetCachedBookmarksUseCase { + func execute() async throws -> [Bookmark] +} + +class GetCachedBookmarksUseCase: PGetCachedBookmarksUseCase { + private let offlineCacheRepository: POfflineCacheRepository + + init(offlineCacheRepository: POfflineCacheRepository) { + self.offlineCacheRepository = offlineCacheRepository + } + + func execute() async throws -> [Bookmark] { + return try await offlineCacheRepository.getCachedBookmarks() + } +} diff --git a/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift b/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift index 5449540..8d511a9 100644 --- a/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift +++ b/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift @@ -10,6 +10,8 @@ import Combine // MARK: - Protocol +/// Use case for syncing articles for offline reading +/// Handles downloading article content and images based on user settings protocol POfflineCacheSyncUseCase { var isSyncing: AnyPublisher { get } var syncProgress: AnyPublisher { get } @@ -21,6 +23,11 @@ protocol POfflineCacheSyncUseCase { // MARK: - Implementation +/// Orchestrates offline article caching with retry logic and progress reporting +/// - Downloads unread bookmarks based on user settings +/// - Prefetches images if enabled +/// - Implements retry logic for temporary server errors (502, 503, 504) +/// - Cleans up old cached articles (FIFO) to respect maxArticles limit final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase { // MARK: - Dependencies @@ -56,6 +63,11 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase { // MARK: - Public Methods + /// Syncs offline articles based on provided settings + /// - Fetches unread bookmarks from API + /// - Caches article HTML and optionally images + /// - Implements retry logic for temporary failures + /// - Updates last sync date in settings @MainActor func syncOfflineArticles(settings: OfflineSettings) async { guard settings.enabled else { @@ -83,6 +95,7 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase { var successCount = 0 var skippedCount = 0 var errorCount = 0 + var retryCount = 0 // Process each bookmark for (index, bookmark) in bookmarks.enumerated() { @@ -101,29 +114,48 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase { _syncProgressSubject.send("📥 Article \(progress)\(imagesSuffix)...") Logger.sync.info("📥 Caching '\(bookmark.title)'") - do { - // Fetch article HTML from API - let html = try await bookmarksRepository.fetchBookmarkArticle(id: bookmark.id) + // Retry logic for temporary server errors + var lastError: Error? + let maxRetries = 2 - // Cache with metadata - try await offlineCacheRepository.cacheBookmarkWithMetadata( - bookmark: bookmark, - html: html, - saveImages: settings.saveImages - ) + for attempt in 0...maxRetries { + do { + if attempt > 0 { + let delay = Double(attempt) * 2.0 // 2s, 4s backoff + Logger.sync.info("⏳ Retry \(attempt)/\(maxRetries) after \(delay)s delay...") + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + retryCount += 1 + } - successCount += 1 - Logger.sync.info("✅ Cached '\(bookmark.title)'") - } catch { - errorCount += 1 + // Fetch article HTML from API + let html = try await bookmarksRepository.fetchBookmarkArticle(id: bookmark.id) - // Detailed error logging - if let urlError = error as? URLError { - Logger.sync.error("❌ Failed to cache '\(bookmark.title)' - Network error: \(urlError.code.rawValue) (\(urlError.localizedDescription))") - } else if let decodingError = error as? DecodingError { - Logger.sync.error("❌ Failed to cache '\(bookmark.title)' - Decoding error: \(decodingError)") - } else { - Logger.sync.error("❌ Failed to cache '\(bookmark.title)' - Error: \(error.localizedDescription) (Type: \(type(of: error)))") + // Cache with metadata + try await offlineCacheRepository.cacheBookmarkWithMetadata( + bookmark: bookmark, + html: html, + saveImages: settings.saveImages + ) + + successCount += 1 + Logger.sync.info("✅ Cached '\(bookmark.title)'\(attempt > 0 ? " (after \(attempt) retries)" : "")") + lastError = nil + break // Success - exit retry loop + + } catch { + lastError = error + + // Check if error is retryable + let shouldRetry = isRetryableError(error) + + if !shouldRetry || attempt == maxRetries { + // Log final error + logCacheError(error: error, bookmark: bookmark, attempt: attempt) + errorCount += 1 + break // Give up + } else { + Logger.sync.warning("⚠️ Temporary error, will retry: \(error.localizedDescription)") + } } } } @@ -137,7 +169,7 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase { try await settingsRepository.saveOfflineSettings(updatedSettings) // Final status - let statusMessage = "✅ Synced: \(successCount), Skipped: \(skippedCount), Errors: \(errorCount)" + let statusMessage = "✅ Synced: \(successCount), Skipped: \(skippedCount), Errors: \(errorCount)\(retryCount > 0 ? ", Retries: \(retryCount)" : "")" Logger.sync.info(statusMessage) _syncProgressSubject.send(statusMessage) @@ -164,4 +196,52 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase { func getCacheSize() -> String { offlineCacheRepository.getCacheSize() } + + // MARK: - Private Helper Methods + + /// Determines if an error is temporary and should be retried + /// - Retries on: 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout + /// - Retries on: Network timeouts and connection losses + private func isRetryableError(_ error: Error) -> Bool { + // Retry on temporary server errors + if let apiError = error as? APIError { + switch apiError { + case .serverError(let statusCode): + // Retry on: 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout + return statusCode == 502 || statusCode == 503 || statusCode == 504 + case .invalidURL, .invalidResponse: + return false // Don't retry on permanent errors + } + } + + // Retry on network timeouts + if let urlError = error as? URLError { + return urlError.code == .timedOut || urlError.code == .networkConnectionLost + } + + return false + } + + private func logCacheError(error: Error, bookmark: Bookmark, attempt: Int) { + let retryInfo = attempt > 0 ? " (after \(attempt) failed attempts)" : "" + + if let urlError = error as? URLError { + Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - Network error: \(urlError.code.rawValue) (\(urlError.localizedDescription))") + } else if let decodingError = error as? DecodingError { + Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - Decoding error: \(decodingError)") + } else if let apiError = error as? APIError { + switch apiError { + case .invalidURL: + Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - APIError: Invalid URL for bookmark ID '\(bookmark.id)'") + case .invalidResponse: + Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - APIError: Invalid server response (nicht 200 OK)") + case .serverError(let statusCode): + Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - APIError: Server error HTTP \(statusCode)") + } + Logger.sync.error(" Bookmark ID: \(bookmark.id)") + Logger.sync.error(" URL: \(bookmark.url)") + } else { + Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - Error: \(error.localizedDescription) (Type: \(type(of: error)))") + } + } } diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift index 02337e1..87bdfc6 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift @@ -8,8 +8,8 @@ class BookmarkDetailViewModel { private let loadSettingsUseCase: PLoadSettingsUseCase private let updateBookmarkUseCase: PUpdateBookmarkUseCase private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase? - private let api: PAPI - private let offlineCacheRepository: POfflineCacheRepository + private let getCachedArticleUseCase: PGetCachedArticleUseCase + private let createAnnotationUseCase: PCreateAnnotationUseCase var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty var articleContent: String = "" @@ -32,9 +32,9 @@ class BookmarkDetailViewModel { self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() - self.api = API() + self.getCachedArticleUseCase = factory.makeGetCachedArticleUseCase() + self.createAnnotationUseCase = factory.makeCreateAnnotationUseCase() self.factory = factory - self.offlineCacheRepository = OfflineCacheRepository() readProgressSubject .debounce(for: .seconds(1), scheduler: DispatchQueue.main) @@ -75,25 +75,38 @@ class BookmarkDetailViewModel { isLoadingArticle = true // First, try to load from cache - if let cachedHTML = offlineCacheRepository.getCachedArticle(id: id) { + if let cachedHTML = getCachedArticleUseCase.execute(id: id) { articleContent = cachedHTML processArticleContent() isLoadingArticle = false - Logger.viewModel.info("📱 Loaded article \(id) from cache") + Logger.viewModel.info("📱 Loaded article \(id) from cache (\(cachedHTML.utf8.count) bytes)") + + // Debug: Check for Base64 images + let base64Count = countOccurrences(in: cachedHTML, of: "data:image/") + let httpCount = countOccurrences(in: cachedHTML, of: "src=\"http") + Logger.viewModel.info(" Images in cached HTML: \(base64Count) Base64, \(httpCount) HTTP") + return } // If not cached, fetch from server + Logger.viewModel.info("📡 Fetching article \(id) from server (not in cache)") do { articleContent = try await getBookmarkArticleUseCase.execute(id: id) processArticleContent() + Logger.viewModel.info("✅ Fetched article from server (\(articleContent.utf8.count) bytes)") } catch { errorMessage = "Error loading article" + Logger.viewModel.error("❌ Failed to load article: \(error.localizedDescription)") } isLoadingArticle = false } + private func countOccurrences(in text: String, of substring: String) -> Int { + return text.components(separatedBy: substring).count - 1 + } + private func processArticleContent() { let paragraphs = articleContent .components(separatedBy: .newlines) @@ -160,7 +173,7 @@ class BookmarkDetailViewModel { @MainActor func createAnnotation(bookmarkId: String, color: String, text: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async { do { - let annotation = try await api.createAnnotation( + let annotation = try await createAnnotationUseCase.execute( bookmarkId: bookmarkId, color: color, startOffset: startOffset, @@ -168,9 +181,9 @@ class BookmarkDetailViewModel { startSelector: startSelector, endSelector: endSelector ) - print("✅ Annotation created: \(annotation.id)") + Logger.viewModel.info("✅ Annotation created: \(annotation.id)") } catch { - print("❌ Failed to create annotation: \(error)") + Logger.viewModel.error("❌ Failed to create annotation: \(error.localizedDescription)") errorMessage = "Error creating annotation" } } diff --git a/readeck/UI/Bookmarks/BookmarksViewModel.swift b/readeck/UI/Bookmarks/BookmarksViewModel.swift index 81aba20..dac95c1 100644 --- a/readeck/UI/Bookmarks/BookmarksViewModel.swift +++ b/readeck/UI/Bookmarks/BookmarksViewModel.swift @@ -8,7 +8,7 @@ class BookmarksViewModel { private let updateBookmarkUseCase: PUpdateBookmarkUseCase private let deleteBookmarkUseCase: PDeleteBookmarkUseCase private let loadCardLayoutUseCase: PLoadCardLayoutUseCase - private let offlineCacheRepository: POfflineCacheRepository + private let getCachedBookmarksUseCase: PGetCachedBookmarksUseCase weak var appSettings: AppSettings? var bookmarks: BookmarksPage? @@ -48,7 +48,7 @@ class BookmarksViewModel { updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase() loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase() - offlineCacheRepository = OfflineCacheRepository() + getCachedBookmarksUseCase = factory.makeGetCachedBookmarksUseCase() setupNotificationObserver() @@ -186,8 +186,8 @@ class BookmarksViewModel { } do { - Logger.viewModel.info("📱 Fetching cached bookmarks from repository...") - let cachedBookmarks = try await offlineCacheRepository.getCachedBookmarks() + Logger.viewModel.info("📱 Fetching cached bookmarks from use case...") + let cachedBookmarks = try await getCachedBookmarksUseCase.execute() Logger.viewModel.info("📱 Retrieved \(cachedBookmarks.count) cached bookmarks") if !cachedBookmarks.isEmpty { diff --git a/readeck/UI/Components/CachedAsyncImage.swift b/readeck/UI/Components/CachedAsyncImage.swift index c3f2bf4..785ba3c 100644 --- a/readeck/UI/Components/CachedAsyncImage.swift +++ b/readeck/UI/Components/CachedAsyncImage.swift @@ -28,48 +28,61 @@ struct CachedAsyncImage: View { @ViewBuilder private func imageView(for url: URL) -> some View { if appSettings.isNetworkConnected { - // Online mode: Normal behavior with caching - KFImage(url) - .cacheOriginalImage() - .diskCacheExpiration(.never) - .placeholder { - Color.gray.opacity(0.3) - } - .fade(duration: 0.25) - .resizable() - .frame(maxWidth: .infinity) + onlineImageView(url: url) } else { - // Offline mode: Only load from cache - if hasCheckedCache && !isImageCached { - // Image not in cache - show placeholder - placeholderWithWarning - } else if let cachedImage { - // Show cached image loaded via custom key - Image(uiImage: cachedImage) - .resizable() - .frame(maxWidth: .infinity) - } else { - KFImage(url) - .cacheOriginalImage() - .diskCacheExpiration(.never) - .loadDiskFileSynchronously() - .onlyFromCache(true) - .placeholder { - Color.gray.opacity(0.3) - } - .onSuccess { _ in - Logger.ui.debug("✅ Loaded image from cache: \(url.absoluteString)") - } - .onFailure { error in - Logger.ui.warning("❌ Failed to load cached image: \(url.absoluteString) - \(error.localizedDescription)") - } - .fade(duration: 0.25) - .resizable() - .frame(maxWidth: .infinity) - } + offlineImageView(url: url) } } + // MARK: - Online Mode + + private func onlineImageView(url: URL) -> some View { + KFImage(url) + .cacheOriginalImage() + .diskCacheExpiration(.never) + .placeholder { Color.gray.opacity(0.3) } + .fade(duration: 0.25) + .resizable() + .frame(maxWidth: .infinity) + } + + // MARK: - Offline Mode + + @ViewBuilder + private func offlineImageView(url: URL) -> some View { + if hasCheckedCache && !isImageCached { + placeholderWithWarning + } else if let cachedImage { + cachedImageView(image: cachedImage) + } else { + kingfisherCacheOnlyView(url: url) + } + } + + private func cachedImageView(image: UIImage) -> some View { + Image(uiImage: image) + .resizable() + .frame(maxWidth: .infinity) + } + + private func kingfisherCacheOnlyView(url: URL) -> some View { + KFImage(url) + .cacheOriginalImage() + .diskCacheExpiration(.never) + .loadDiskFileSynchronously() + .onlyFromCache(true) + .placeholder { Color.gray.opacity(0.3) } + .onSuccess { _ in + Logger.ui.debug("✅ Loaded image from cache: \(url.absoluteString)") + } + .onFailure { error in + Logger.ui.warning("❌ Failed to load cached image: \(url.absoluteString) - \(error.localizedDescription)") + } + .fade(duration: 0.25) + .resizable() + .frame(maxWidth: .infinity) + } + private var placeholderImage: some View { Color.gray.opacity(0.3) .frame(maxWidth: .infinity) @@ -95,40 +108,64 @@ struct CachedAsyncImage: View { ) } + // MARK: - Cache Checking + private func checkCache(for url: URL) async { - // If we have a custom cache key, try to load from cache using that key first - if let cacheKey = cacheKey { - let result = await withCheckedContinuation { (continuation: CheckedContinuation) in - ImageCache.default.retrieveImage(forKey: cacheKey) { result in - switch result { - case .success(let cacheResult): - continuation.resume(returning: cacheResult.image) - case .failure: - continuation.resume(returning: nil) - } - } - } - - await MainActor.run { - if let image = result { - cachedImage = image - isImageCached = true - Logger.ui.debug("✅ Loaded image from cache using key: \(cacheKey)") - } else { - // Fallback to URL-based cache check - Logger.ui.debug("Image not found with cache key, trying URL-based cache") - } - hasCheckedCache = true - } - - // If we found the image with cache key, we're done - if cachedImage != nil { - return - } + // Try custom cache key first, then fallback to URL-based cache + if let cacheKey = cacheKey, await tryLoadFromCustomKey(cacheKey) { + return } - // Fallback: Check standard Kingfisher cache using URL - let isCached = await withCheckedContinuation { continuation in + await checkStandardCache(for: url) + } + + private func tryLoadFromCustomKey(_ key: String) async -> Bool { + let image = await retrieveImageFromCache(key: key) + + await MainActor.run { + if let image { + cachedImage = image + isImageCached = true + Logger.ui.debug("✅ Loaded image from cache using key: \(key)") + } else { + Logger.ui.debug("Image not found with cache key, trying URL-based cache") + } + hasCheckedCache = true + } + + return image != nil + } + + private func checkStandardCache(for url: URL) async { + let isCached = await isImageInCache(url: url) + + await MainActor.run { + isImageCached = isCached + hasCheckedCache = true + + if !appSettings.isNetworkConnected { + Logger.ui.debug(isCached + ? "✅ Image is cached for offline use: \(url.absoluteString)" + : "❌ Image NOT cached for offline use: \(url.absoluteString)") + } + } + } + + private func retrieveImageFromCache(key: String) async -> UIImage? { + await withCheckedContinuation { continuation in + ImageCache.default.retrieveImage(forKey: key) { result in + switch result { + case .success(let cacheResult): + continuation.resume(returning: cacheResult.image) + case .failure: + continuation.resume(returning: nil) + } + } + } + } + + private func isImageInCache(url: URL) async -> Bool { + await withCheckedContinuation { continuation in KingfisherManager.shared.cache.retrieveImage(forKey: url.cacheKey) { result in switch result { case .success(let cacheResult): @@ -138,18 +175,5 @@ struct CachedAsyncImage: View { } } } - - await MainActor.run { - isImageCached = isCached - hasCheckedCache = true - - if !appSettings.isNetworkConnected { - if isCached { - Logger.ui.debug("✅ Image is cached for offline use: \(url.absoluteString)") - } else { - Logger.ui.warning("❌ Image NOT cached for offline use: \(url.absoluteString)") - } - } - } } } diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index 74143d7..d243564 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -28,6 +28,9 @@ protocol UseCaseFactory { func makeSettingsRepository() -> PSettingsRepository func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase + func makeGetCachedBookmarksUseCase() -> PGetCachedBookmarksUseCase + func makeGetCachedArticleUseCase() -> PGetCachedArticleUseCase + func makeCreateAnnotationUseCase() -> PCreateAnnotationUseCase } @@ -165,4 +168,16 @@ class DefaultUseCaseFactory: UseCaseFactory { func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase { return NetworkMonitorUseCase(repository: networkMonitorRepository) } + + func makeGetCachedBookmarksUseCase() -> PGetCachedBookmarksUseCase { + return GetCachedBookmarksUseCase(offlineCacheRepository: offlineCacheRepository) + } + + func makeGetCachedArticleUseCase() -> PGetCachedArticleUseCase { + return GetCachedArticleUseCase(offlineCacheRepository: offlineCacheRepository) + } + + func makeCreateAnnotationUseCase() -> PCreateAnnotationUseCase { + return CreateAnnotationUseCase(repository: annotationsRepository) + } } diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index d03d710..f082880 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -14,6 +14,10 @@ struct readeckApp: App { @StateObject private var appSettings = AppSettings() @Environment(\.scenePhase) private var scenePhase + #if DEBUG + @State private var showDebugMenu = false + #endif + var body: some Scene { WindowGroup { Group { @@ -27,6 +31,15 @@ struct readeckApp: App { .environmentObject(appSettings) .environment(\.managedObjectContext, CoreDataManager.shared.context) .preferredColorScheme(appSettings.theme.colorScheme) + #if DEBUG + .onShake { + showDebugMenu = true + } + .sheet(isPresented: $showDebugMenu) { + DebugMenuView() + .environmentObject(appSettings) + } + #endif .onAppear { #if DEBUG NFX.sharedInstance().start() @@ -59,58 +72,3 @@ struct readeckApp: App { } } } - - -struct TestView: View { - var body: some View { - if #available(iOS 26.0, *) { - Text("hello") - .toolbar { - ToolbarSpacer(.flexible) - - ToolbarItem { - Button { - - } label: { - Label("Favorite", systemImage: "share") - .symbolVariant(.none) - } - } - - ToolbarSpacer(.fixed) - - ToolbarItemGroup { - Button { - - } label: { - Label("Favorite", systemImage: "heart") - .symbolVariant(.none) - } - - Button("Info", systemImage: "info") { - - } - } - - ToolbarItemGroup(placement: .bottomBar) { - Spacer() - Button { - - } label: { - Label("Favorite", systemImage: "heart") - .symbolVariant(.none) - } - - Button("Info", systemImage: "info") { - - } - } - - } - .toolbar(removing: .title) - .ignoresSafeArea(edges: .top) - } else { - Text("hello1") - } - } -}