Refactor offline sync to enforce Clean Architecture
Refactorings: - Extract HTMLImageEmbedder and HTMLImageExtractor utilities - Create UseCases for cached data access (GetCachedBookmarksUseCase, GetCachedArticleUseCase) - Create CreateAnnotationUseCase to remove API dependency from ViewModel - Simplify CachedAsyncImage by extracting helper methods - Fix Kingfisher API compatibility (Source types, Result handling) - Add documentation to OfflineCacheSyncUseCase - Remove unused TestView from production code Enforces Clean Architecture: - ViewModels now only use UseCases, no direct Repository or API access - All data layer access goes through Domain layer
This commit is contained in:
parent
305b8f733e
commit
b9f8e11782
107
readeck/Data/Utils/HTMLImageEmbedder.swift
Normal file
107
readeck/Data/Utils/HTMLImageEmbedder.swift
Normal file
@ -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
|
||||
}
|
||||
63
readeck/Data/Utils/HTMLImageExtractor.swift
Normal file
63
readeck/Data/Utils/HTMLImageExtractor.swift
Normal file
@ -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 = #"<img[^>]+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
|
||||
}
|
||||
}
|
||||
192
readeck/Data/Utils/KingfisherImagePrefetcher.swift
Normal file
192
readeck/Data/Utils/KingfisherImagePrefetcher.swift
Normal file
@ -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<Void, Never>) 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
readeck/Domain/UseCase/CreateAnnotationUseCase.swift
Normal file
45
readeck/Domain/UseCase/CreateAnnotationUseCase.swift
Normal file
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
24
readeck/Domain/UseCase/GetCachedArticleUseCase.swift
Normal file
24
readeck/Domain/UseCase/GetCachedArticleUseCase.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
24
readeck/Domain/UseCase/GetCachedBookmarksUseCase.swift
Normal file
24
readeck/Domain/UseCase/GetCachedBookmarksUseCase.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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<Bool, Never> { get }
|
||||
var syncProgress: AnyPublisher<String?, Never> { 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)))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<UIImage?, Never>) 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user