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")
- }
- }
-}