Add offline cache infrastructure with clean architecture

Create separate cache repository layer:
- Add POfflineCacheRepository protocol for cache operations
- Add OfflineCacheRepository with CoreData and Kingfisher
- Add OfflineCacheSyncUseCase to coordinate sync workflow
- Update PBookmarksRepository to focus on API calls only
- Extend BookmarkEntityMapper with toDomain() conversion

UseCase coordinates between cache, API, and settings repositories
following dependency inversion principle.
This commit is contained in:
Ilyas Hallak 2025-11-17 23:53:44 +01:00
parent 24dba33b39
commit f5dab38038
6 changed files with 517 additions and 311 deletions

View File

@ -77,7 +77,56 @@ extension ResourceDto {
// MARK: - BookmarkEntity to Domain Mapping
extension BookmarkEntity {
func toDomain() -> Bookmark? {
guard let id = self.id,
let title = self.title,
let url = self.url,
let href = self.href,
let created = self.created,
let update = self.update,
let siteName = self.siteName,
let site = self.site else {
return nil
}
let resources = BookmarkResources(
article: nil,
icon: nil,
image: nil,
log: nil,
props: nil,
thumbnail: nil
)
return Bookmark(
id: id,
title: title,
url: url,
href: href,
description: self.desc ?? "",
authors: self.authors?.components(separatedBy: ",") ?? [],
created: created,
published: self.published,
updated: update,
siteName: siteName,
site: site,
readingTime: Int(self.readingTime),
wordCount: Int(self.wordCount),
hasArticle: self.hasArticle,
isArchived: self.isArchived,
isDeleted: self.hasDeleted,
isMarked: self.isMarked,
labels: [],
lang: self.lang,
loaded: self.loaded,
readProgress: Int(self.readProgress),
documentType: self.documentType ?? "",
state: Int(self.state),
textDirection: self.textDirection ?? "",
type: self.type ?? "",
resources: resources
)
}
}
// MARK: - Domain to BookmarkEntity Mapping

View File

@ -1,21 +1,17 @@
import Foundation
import CoreData
import Kingfisher
class BookmarksRepository: PBookmarksRepository {
private var api: PAPI
private let coreDataManager = CoreDataManager.shared
private let logger = Logger.sync
init(api: PAPI) {
self.api = api
}
func fetchBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil, tag: String? = nil) async throws -> BookmarksPage {
let bookmarkDtos = try await api.getBookmarks(state: state, limit: limit, offset: offset, search: search, type: type, tag: tag)
return bookmarkDtos.toDomain()
}
func fetchBookmark(id: String) async throws -> BookmarkDetail {
let bookmarkDetailDto = try await api.getBookmark(id: id)
return BookmarkDetail(
@ -39,32 +35,32 @@ class BookmarksRepository: PBookmarksRepository {
readProgress: bookmarkDetailDto.readProgress
)
}
func fetchBookmarkArticle(id: String) async throws -> String {
return try await api.getBookmarkArticle(id: id)
}
func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String {
let dto = CreateBookmarkRequestDto(
url: createRequest.url,
title: createRequest.title,
labels: createRequest.labels
)
let response = try await api.createBookmark(createRequest: dto)
// Prüfe ob die Erstellung erfolgreich war
guard response.status == 0 || response.status == 202 else {
throw CreateBookmarkError.serverError(response.message)
}
return response.message
}
func deleteBookmark(id: String) async throws {
try await api.deleteBookmark(id: id)
}
func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws {
let dto = UpdateBookmarkRequestDto(
addLabels: updateRequest.addLabels,
@ -77,295 +73,11 @@ class BookmarksRepository: PBookmarksRepository {
removeLabels: updateRequest.removeLabels,
title: updateRequest.title
)
try await api.updateBookmark(id: id, updateRequest: dto)
}
func searchBookmarks(search: String) async throws -> BookmarksPage {
try await api.searchBookmarks(search: search).toDomain()
}
// MARK: - Offline Cache Methods
func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws {
// Check if already cached
if hasCachedArticle(id: bookmark.id) {
logger.debug("Bookmark \(bookmark.id) is already cached, skipping")
return
}
let context = coreDataManager.context
try await context.perform { [weak self] in
guard let self = self else { return }
// Find or create BookmarkEntity
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", bookmark.id)
fetchRequest.fetchLimit = 1
let existingEntities = try context.fetch(fetchRequest)
let entity = existingEntities.first ?? BookmarkEntity(context: context)
// Populate entity from bookmark using existing mapper
bookmark.updateEntity(entity)
// Set cache-specific fields
entity.htmlContent = html
entity.cachedDate = Date()
entity.lastAccessDate = Date()
entity.cacheSize = Int64(html.utf8.count)
// Extract and save image URLs if needed
if saveImages {
let imageURLs = self.extractImageURLsFromHTML(html: html)
if !imageURLs.isEmpty {
entity.imageURLs = imageURLs.joined(separator: ",")
self.logger.debug("Found \(imageURLs.count) images for bookmark \(bookmark.id)")
}
}
try context.save()
self.logger.info("Cached bookmark \(bookmark.id) with HTML (\(html.utf8.count) bytes)")
}
// Prefetch images with Kingfisher (outside of CoreData context)
if saveImages, let entity = try? await getCachedEntity(id: bookmark.id), let imageURLsString = entity.imageURLs {
let imageURLs = imageURLsString.split(separator: ",").compactMap { URL(string: String($0)) }
if !imageURLs.isEmpty {
await prefetchImagesWithKingfisher(imageURLs: imageURLs)
}
}
}
func getCachedArticle(id: String) -> String? {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@ AND htmlContent != nil", id)
fetchRequest.fetchLimit = 1
do {
let results = try coreDataManager.context.fetch(fetchRequest)
if let entity = results.first {
// Update last access date
entity.lastAccessDate = Date()
coreDataManager.save()
logger.debug("Retrieved cached article for bookmark \(id)")
return entity.htmlContent
}
} catch {
logger.error("Error fetching cached article: \(error.localizedDescription)")
}
return nil
}
func hasCachedArticle(id: String) -> Bool {
return getCachedArticle(id: id) != nil
}
func getCachedBookmarks() async throws -> [Bookmark] {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: false)]
let context = coreDataManager.context
return try await context.perform {
let entities = try context.fetch(fetchRequest)
self.logger.debug("Found \(entities.count) cached bookmarks")
// Convert entities to Bookmark domain objects
return entities.compactMap { entity -> Bookmark? in
guard let id = entity.id,
let title = entity.title,
let url = entity.url,
let href = entity.href,
let created = entity.created,
let update = entity.update,
let siteName = entity.siteName,
let site = entity.site else {
return nil
}
// Create BookmarkResources (simplified for now)
let resources = BookmarkResources(
article: nil,
icon: nil,
image: nil,
log: nil,
props: nil,
thumbnail: nil
)
return Bookmark(
id: id,
title: title,
url: url,
href: href,
description: entity.desc ?? "",
authors: entity.authors?.components(separatedBy: ",") ?? [],
created: created,
published: entity.published,
updated: update,
siteName: siteName,
site: site,
readingTime: Int(entity.readingTime),
wordCount: Int(entity.wordCount),
hasArticle: entity.hasArticle,
isArchived: entity.isArchived,
isDeleted: entity.hasDeleted,
isMarked: entity.isMarked,
labels: [],
lang: entity.lang,
loaded: entity.loaded,
readProgress: Int(entity.readProgress),
documentType: entity.documentType ?? "",
state: Int(entity.state),
textDirection: entity.textDirection ?? "",
type: entity.type ?? "",
resources: resources
)
}
}
}
func getCachedArticlesCount() -> Int {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
do {
let count = try coreDataManager.context.count(for: fetchRequest)
return count
} catch {
logger.error("Error counting cached articles: \(error.localizedDescription)")
return 0
}
}
func getCacheSize() -> String {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
do {
let entities = try coreDataManager.context.fetch(fetchRequest)
let totalBytes = entities.reduce(0) { $0 + $1.cacheSize }
return ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .file)
} catch {
logger.error("Error calculating cache size: \(error.localizedDescription)")
return "0 KB"
}
}
func clearCache() async throws {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
let context = coreDataManager.context
try await context.perform { [weak self] in
guard let self = self else { return }
let entities = try context.fetch(fetchRequest)
for entity in entities {
entity.htmlContent = nil
entity.cachedDate = nil
entity.lastAccessDate = nil
entity.imageURLs = nil
entity.cacheSize = 0
}
try context.save()
self.logger.info("Cleared cache for \(entities.count) articles")
}
// Optional: Also clear Kingfisher cache
// KingfisherManager.shared.cache.clearDiskCache()
// KingfisherManager.shared.cache.clearMemoryCache()
}
func cleanupOldestCachedArticles(keepCount: Int) async throws {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: true)]
let context = coreDataManager.context
try await context.perform { [weak self] in
guard let self = self else { return }
let allEntities = try context.fetch(fetchRequest)
// Delete oldest articles if we exceed keepCount
if allEntities.count > keepCount {
let entitiesToDelete = allEntities.prefix(allEntities.count - keepCount)
for entity in entitiesToDelete {
entity.htmlContent = nil
entity.cachedDate = nil
entity.lastAccessDate = nil
entity.imageURLs = nil
entity.cacheSize = 0
}
try context.save()
self.logger.info("Cleaned up \(entitiesToDelete.count) oldest cached articles (keeping \(keepCount))")
}
}
}
// MARK: - Private Helper Methods
private func extractImageURLsFromHTML(html: String) -> [String] {
var imageURLs: [String] = []
// Simple regex pattern for img tags
let pattern = #"<img[^>]+src=\"([^\"]+)\""#
if let regex = try? NSRegularExpression(pattern: pattern, options: []) {
let nsString = html as NSString
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length))
for result in results {
if result.numberOfRanges >= 2 {
let urlRange = result.range(at: 1)
if let url = nsString.substring(with: urlRange) as String? {
// Only include absolute URLs (http/https)
if url.hasPrefix("http") {
imageURLs.append(url)
}
}
}
}
}
logger.debug("Extracted \(imageURLs.count) image URLs from HTML")
return imageURLs
}
private func prefetchImagesWithKingfisher(imageURLs: [URL]) async {
guard !imageURLs.isEmpty else { return }
logger.info("Starting Kingfisher prefetch for \(imageURLs.count) images")
// Use Kingfisher's prefetcher with low priority
let prefetcher = ImagePrefetcher(urls: imageURLs) { [weak self] skippedResources, failedResources, completedResources in
self?.logger.info("Prefetch completed: \(completedResources.count)/\(imageURLs.count) images cached")
if !failedResources.isEmpty {
self?.logger.warning("Failed to cache \(failedResources.count) images")
}
}
// Optional: Set download priority to low for background downloads
// prefetcher.options = [.downloadPriority(.low)]
prefetcher.start()
}
private func getCachedEntity(id: String) async throws -> BookmarkEntity? {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
fetchRequest.fetchLimit = 1
let context = coreDataManager.context
return try await context.perform {
let results = try context.fetch(fetchRequest)
return results.first
}
}
}

View File

@ -0,0 +1,272 @@
//
// OfflineCacheRepository.swift
// readeck
//
// Created by Claude on 17.11.25.
//
import Foundation
import CoreData
import Kingfisher
class OfflineCacheRepository: POfflineCacheRepository {
// MARK: - Dependencies
private let coreDataManager = CoreDataManager.shared
private let logger = Logger.sync
// MARK: - Cache Operations
func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws {
if hasCachedArticle(id: bookmark.id) {
logger.debug("Bookmark \(bookmark.id) is already cached, skipping")
return
}
try await saveBookmarkToCache(bookmark: bookmark, html: html, saveImages: saveImages)
if saveImages {
await prefetchImagesForBookmark(id: bookmark.id)
}
}
func getCachedArticle(id: String) -> String? {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@ AND htmlContent != nil", id)
fetchRequest.fetchLimit = 1
do {
let results = try coreDataManager.context.fetch(fetchRequest)
if let entity = results.first {
// Update last access date
entity.lastAccessDate = Date()
coreDataManager.save()
logger.debug("Retrieved cached article for bookmark \(id)")
return entity.htmlContent
}
} catch {
logger.error("Error fetching cached article: \(error.localizedDescription)")
}
return nil
}
func hasCachedArticle(id: String) -> Bool {
return getCachedArticle(id: id) != nil
}
func getCachedBookmarks() async throws -> [Bookmark] {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: false)]
let context = coreDataManager.context
return try await context.perform {
let entities = try context.fetch(fetchRequest)
self.logger.debug("Found \(entities.count) cached bookmarks")
// Convert entities to Bookmark domain objects using mapper
return entities.compactMap { $0.toDomain() }
}
}
// MARK: - Cache Statistics
func getCachedArticlesCount() -> Int {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
do {
let count = try coreDataManager.context.count(for: fetchRequest)
return count
} catch {
logger.error("Error counting cached articles: \(error.localizedDescription)")
return 0
}
}
func getCacheSize() -> String {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
do {
let entities = try coreDataManager.context.fetch(fetchRequest)
let totalBytes = entities.reduce(0) { $0 + $1.cacheSize }
return ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .file)
} catch {
logger.error("Error calculating cache size: \(error.localizedDescription)")
return "0 KB"
}
}
// MARK: - Cache Management
func clearCache() async throws {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
let context = coreDataManager.context
try await context.perform { [weak self] in
guard let self = self else { return }
let entities = try context.fetch(fetchRequest)
for entity in entities {
entity.htmlContent = nil
entity.cachedDate = nil
entity.lastAccessDate = nil
entity.imageURLs = nil
entity.cacheSize = 0
}
try context.save()
self.logger.info("Cleared cache for \(entities.count) articles")
}
// Optional: Also clear Kingfisher cache
// KingfisherManager.shared.cache.clearDiskCache()
// KingfisherManager.shared.cache.clearMemoryCache()
}
func cleanupOldestCachedArticles(keepCount: Int) async throws {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: true)]
let context = coreDataManager.context
try await context.perform { [weak self] in
guard let self = self else { return }
let allEntities = try context.fetch(fetchRequest)
// Delete oldest articles if we exceed keepCount
if allEntities.count > keepCount {
let entitiesToDelete = allEntities.prefix(allEntities.count - keepCount)
for entity in entitiesToDelete {
entity.htmlContent = nil
entity.cachedDate = nil
entity.lastAccessDate = nil
entity.imageURLs = nil
entity.cacheSize = 0
}
try context.save()
self.logger.info("Cleaned up \(entitiesToDelete.count) oldest cached articles (keeping \(keepCount))")
}
}
}
// MARK: - Private Helper Methods
private func saveBookmarkToCache(bookmark: Bookmark, html: String, saveImages: Bool) async throws {
let context = coreDataManager.context
try await context.perform { [weak self] in
guard let self = self else { return }
let entity = try self.findOrCreateEntity(for: bookmark.id, in: context)
bookmark.updateEntity(entity)
self.updateEntityWithCacheData(entity: entity, html: html, saveImages: saveImages)
try context.save()
self.logger.info("Cached bookmark \(bookmark.id) with HTML (\(html.utf8.count) bytes)")
}
}
private func findOrCreateEntity(for bookmarkId: String, in context: NSManagedObjectContext) throws -> BookmarkEntity {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", bookmarkId)
fetchRequest.fetchLimit = 1
let existingEntities = try context.fetch(fetchRequest)
return existingEntities.first ?? BookmarkEntity(context: context)
}
private func updateEntityWithCacheData(entity: BookmarkEntity, html: String, saveImages: Bool) {
entity.htmlContent = html
entity.cachedDate = Date()
entity.lastAccessDate = Date()
entity.cacheSize = Int64(html.utf8.count)
if saveImages {
let imageURLs = extractImageURLsFromHTML(html: html)
if !imageURLs.isEmpty {
entity.imageURLs = imageURLs.joined(separator: ",")
logger.debug("Found \(imageURLs.count) images for bookmark \(entity.id ?? "unknown")")
}
}
}
private func prefetchImagesForBookmark(id: String) async {
guard let entity = try? await getCachedEntity(id: id),
let imageURLsString = entity.imageURLs else {
return
}
let imageURLs = imageURLsString
.split(separator: ",")
.compactMap { URL(string: String($0)) }
if !imageURLs.isEmpty {
await prefetchImagesWithKingfisher(imageURLs: imageURLs)
}
}
private func extractImageURLsFromHTML(html: String) -> [String] {
var imageURLs: [String] = []
// Simple regex pattern for img tags
let pattern = #"<img[^>]+src=\"([^\"]+)\""#
if let regex = try? NSRegularExpression(pattern: pattern, options: []) {
let nsString = html as NSString
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length))
for result in results {
if result.numberOfRanges >= 2 {
let urlRange = result.range(at: 1)
if let url = nsString.substring(with: urlRange) as String? {
// Only include absolute URLs (http/https)
if url.hasPrefix("http") {
imageURLs.append(url)
}
}
}
}
}
logger.debug("Extracted \(imageURLs.count) image URLs from HTML")
return imageURLs
}
private func prefetchImagesWithKingfisher(imageURLs: [URL]) async {
guard !imageURLs.isEmpty else { return }
logger.info("Starting Kingfisher prefetch for \(imageURLs.count) images")
// Use Kingfisher's prefetcher with low priority
let prefetcher = ImagePrefetcher(urls: imageURLs) { [weak self] skippedResources, failedResources, completedResources in
self?.logger.info("Prefetch completed: \(completedResources.count)/\(imageURLs.count) images cached")
if !failedResources.isEmpty {
self?.logger.warning("Failed to cache \(failedResources.count) images")
}
}
// Optional: Set download priority to low for background downloads
// prefetcher.options = [.downloadPriority(.low)]
prefetcher.start()
}
private func getCachedEntity(id: String) async throws -> BookmarkEntity? {
let fetchRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
fetchRequest.fetchLimit = 1
let context = coreDataManager.context
return try await context.perform {
let results = try context.fetch(fetchRequest)
return results.first
}
}
}

View File

@ -6,7 +6,7 @@
//
protocol PBookmarksRepository {
// Existing Bookmark methods
// Bookmark API methods
func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage
func fetchBookmark(id: String) async throws -> BookmarkDetail
func fetchBookmarkArticle(id: String) async throws -> String
@ -14,14 +14,4 @@ protocol PBookmarksRepository {
func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws
func deleteBookmark(id: String) async throws
func searchBookmarks(search: String) async throws -> BookmarksPage
// Offline Cache methods
func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws
func getCachedArticle(id: String) -> String?
func hasCachedArticle(id: String) -> Bool
func getCachedBookmarks() async throws -> [Bookmark]
func getCachedArticlesCount() -> Int
func getCacheSize() -> String
func clearCache() async throws
func cleanupOldestCachedArticles(keepCount: Int) async throws
}

View File

@ -0,0 +1,24 @@
//
// POfflineCacheRepository.swift
// readeck
//
// Created by Claude on 17.11.25.
//
import Foundation
protocol POfflineCacheRepository {
// Cache operations
func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws
func hasCachedArticle(id: String) -> Bool
func getCachedArticle(id: String) -> String?
func getCachedBookmarks() async throws -> [Bookmark]
// Cache statistics
func getCachedArticlesCount() -> Int
func getCacheSize() -> String
// Cache management
func clearCache() async throws
func cleanupOldestCachedArticles(keepCount: Int) async throws
}

View File

@ -0,0 +1,159 @@
//
// OfflineCacheSyncUseCase.swift
// readeck
//
// Created by Claude on 17.11.25.
//
import Foundation
import Combine
// MARK: - Protocol
protocol POfflineCacheSyncUseCase {
var isSyncing: AnyPublisher<Bool, Never> { get }
var syncProgress: AnyPublisher<String?, Never> { get }
func syncOfflineArticles(settings: OfflineSettings) async
func getCachedArticlesCount() -> Int
func getCacheSize() -> String
}
// MARK: - Implementation
@MainActor
final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
// MARK: - Dependencies
private let offlineCacheRepository: POfflineCacheRepository
private let bookmarksRepository: PBookmarksRepository
private let settingsRepository: PSettingsRepository
// MARK: - Published State
@Published private var _isSyncing = false
@Published private var _syncProgress: String?
var isSyncing: AnyPublisher<Bool, Never> {
$_isSyncing.eraseToAnyPublisher()
}
var syncProgress: AnyPublisher<String?, Never> {
$_syncProgress.eraseToAnyPublisher()
}
// MARK: - Initialization
init(
offlineCacheRepository: POfflineCacheRepository,
bookmarksRepository: PBookmarksRepository,
settingsRepository: PSettingsRepository
) {
self.offlineCacheRepository = offlineCacheRepository
self.bookmarksRepository = bookmarksRepository
self.settingsRepository = settingsRepository
}
// MARK: - Public Methods
func syncOfflineArticles(settings: OfflineSettings) async {
guard settings.enabled else {
Logger.sync.info("Offline sync skipped: disabled in settings")
return
}
_isSyncing = true
Logger.sync.info("🔄 Starting offline sync (max: \(settings.maxUnreadArticlesInt) articles, images: \(settings.saveImages))")
do {
// Fetch unread bookmarks from API
let page = try await bookmarksRepository.fetchBookmarks(
state: .unread,
limit: settings.maxUnreadArticlesInt,
offset: 0,
search: nil,
type: nil,
tag: nil
)
let bookmarks = page.bookmarks
Logger.sync.info("📚 Fetched \(bookmarks.count) unread bookmarks")
var successCount = 0
var skippedCount = 0
var errorCount = 0
// Process each bookmark
for (index, bookmark) in bookmarks.enumerated() {
let progress = "\(index + 1)/\(bookmarks.count)"
// Check cache status
if offlineCacheRepository.hasCachedArticle(id: bookmark.id) {
Logger.sync.debug("⏭️ Skipping '\(bookmark.title)' (already cached)")
skippedCount += 1
_syncProgress = "⏭️ Artikel \(progress) bereits gecacht..."
continue
}
// Update progress
let imagesSuffix = settings.saveImages ? " + Bilder" : ""
_syncProgress = "📥 Artikel \(progress)\(imagesSuffix)..."
Logger.sync.info("📥 Caching '\(bookmark.title)'")
do {
// Fetch article HTML from API
let html = try await bookmarksRepository.fetchBookmarkArticle(id: bookmark.id)
// Cache with metadata
try await offlineCacheRepository.cacheBookmarkWithMetadata(
bookmark: bookmark,
html: html,
saveImages: settings.saveImages
)
successCount += 1
Logger.sync.info("✅ Cached '\(bookmark.title)'")
} catch {
errorCount += 1
Logger.sync.error("❌ Failed to cache '\(bookmark.title)': \(error.localizedDescription)")
}
}
// Cleanup old articles (FIFO)
try await offlineCacheRepository.cleanupOldestCachedArticles(keepCount: settings.maxUnreadArticlesInt)
// Update last sync date in settings
var updatedSettings = settings
updatedSettings.lastSyncDate = Date()
try await settingsRepository.saveOfflineSettings(updatedSettings)
// Final status
let statusMessage = "✅ Synchronisiert: \(successCount), Übersprungen: \(skippedCount), Fehler: \(errorCount)"
Logger.sync.info(statusMessage)
_syncProgress = statusMessage
// Clear progress message after 3 seconds
try? await Task.sleep(nanoseconds: 3_000_000_000)
_syncProgress = nil
} catch {
Logger.sync.error("❌ Offline sync failed: \(error.localizedDescription)")
_syncProgress = "❌ Synchronisierung fehlgeschlagen"
// Clear error message after 5 seconds
try? await Task.sleep(nanoseconds: 5_000_000_000)
_syncProgress = nil
}
_isSyncing = false
}
func getCachedArticlesCount() -> Int {
offlineCacheRepository.getCachedArticlesCount()
}
func getCacheSize() -> String {
offlineCacheRepository.getCacheSize()
}
}