Add foundation layer for offline article caching
Implement data layer infrastructure for Offline Reading feature (Stage 1): - Add OfflineSettings model with 4-hour sync interval - Extend BookmarkEntity with cache fields (htmlContent, cachedDate, imageURLs, etc.) - Add offline cache methods to BookmarksRepository with Kingfisher image prefetching - Extend SettingsRepository with offline settings persistence - Add PSettingsRepository protocol with offline methods - Implement FIFO cleanup for cached articles
This commit is contained in:
parent
e4121aa066
commit
c4cd3a0dc3
@ -1,7 +1,11 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Kingfisher
|
||||
|
||||
class BookmarksRepository: PBookmarksRepository {
|
||||
private var api: PAPI
|
||||
private let coreDataManager = CoreDataManager.shared
|
||||
private let logger = Logger.sync
|
||||
|
||||
init(api: PAPI) {
|
||||
self.api = api
|
||||
@ -80,4 +84,288 @@ class BookmarksRepository: PBookmarksRepository {
|
||||
func searchBookmarks(search: String) async throws -> BookmarksPage {
|
||||
try await api.searchBookmarks(search: search).toDomain()
|
||||
}
|
||||
|
||||
// MARK: - Offline Cache Methods
|
||||
|
||||
func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws {
|
||||
// Check if already cached
|
||||
if hasCachedArticle(id: bookmark.id) {
|
||||
logger.debug("Bookmark \(bookmark.id) is already cached, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
let context = coreDataManager.context
|
||||
|
||||
try await context.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Find or create BookmarkEntity
|
||||
let fetchRequest: NSFetchRequest<BookmarkEntity> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +1,6 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
protocol PSettingsRepository {
|
||||
func saveSettings(_ settings: Settings) async throws
|
||||
func loadSettings() async throws -> Settings?
|
||||
func clearSettings() async throws
|
||||
func saveToken(_ token: String) async throws
|
||||
func saveUsername(_ username: String) async throws
|
||||
func savePassword(_ password: String) async throws
|
||||
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
|
||||
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
|
||||
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
|
||||
func loadCardLayoutStyle() async throws -> CardLayoutStyle
|
||||
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws
|
||||
func loadTagSortOrder() async throws -> TagSortOrder
|
||||
var hasFinishedSetup: Bool { get }
|
||||
}
|
||||
|
||||
class SettingsRepository: PSettingsRepository {
|
||||
private let coreDataManager = CoreDataManager.shared
|
||||
private let userDefault = UserDefaults.standard
|
||||
@ -286,4 +270,28 @@ class SettingsRepository: PSettingsRepository {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Offline Settings
|
||||
|
||||
private let offlineSettingsKey = "offlineSettings"
|
||||
private let logger = Logger.data
|
||||
|
||||
func loadOfflineSettings() async throws -> OfflineSettings {
|
||||
guard let data = userDefault.data(forKey: offlineSettingsKey) else {
|
||||
logger.info("No offline settings found, returning defaults")
|
||||
return OfflineSettings() // Default settings
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let settings = try decoder.decode(OfflineSettings.self, from: data)
|
||||
logger.debug("Loaded offline settings: enabled=\(settings.enabled), max=\(settings.maxUnreadArticlesInt)")
|
||||
return settings
|
||||
}
|
||||
|
||||
func saveOfflineSettings(_ settings: OfflineSettings) async throws {
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(settings)
|
||||
userDefault.set(data, forKey: offlineSettingsKey)
|
||||
logger.info("Saved offline settings: enabled=\(settings.enabled), max=\(settings.maxUnreadArticlesInt)")
|
||||
}
|
||||
}
|
||||
|
||||
30
readeck/Domain/Model/OfflineSettings.swift
Normal file
30
readeck/Domain/Model/OfflineSettings.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// OfflineSettings.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Claude on 08.11.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct OfflineSettings: Codable {
|
||||
var enabled: Bool = true
|
||||
var maxUnreadArticles: Double = 20 // Double für Slider (Default: 20 Artikel)
|
||||
var saveImages: Bool = false
|
||||
var lastSyncDate: Date?
|
||||
|
||||
var maxUnreadArticlesInt: Int {
|
||||
Int(maxUnreadArticles)
|
||||
}
|
||||
|
||||
var shouldSyncOnAppStart: Bool {
|
||||
guard enabled else { return false }
|
||||
|
||||
// Sync if never synced before
|
||||
guard let lastSync = lastSyncDate else { return true }
|
||||
|
||||
// Sync if more than 4 hours since last sync
|
||||
let fourHoursAgo = Date().addingTimeInterval(-4 * 60 * 60)
|
||||
return lastSync < fourHoursAgo
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
protocol PBookmarksRepository {
|
||||
// Existing Bookmark methods
|
||||
func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage
|
||||
func fetchBookmark(id: String) async throws -> BookmarkDetail
|
||||
func fetchBookmarkArticle(id: String) async throws -> String
|
||||
@ -13,4 +14,14 @@ protocol PBookmarksRepository {
|
||||
func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws
|
||||
func deleteBookmark(id: String) async throws
|
||||
func searchBookmarks(search: String) async throws -> BookmarksPage
|
||||
|
||||
// Offline Cache methods
|
||||
func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws
|
||||
func getCachedArticle(id: String) -> String?
|
||||
func hasCachedArticle(id: String) -> Bool
|
||||
func getCachedBookmarks() async throws -> [Bookmark]
|
||||
func getCachedArticlesCount() -> Int
|
||||
func getCacheSize() -> String
|
||||
func clearCache() async throws
|
||||
func cleanupOldestCachedArticles(keepCount: Int) async throws
|
||||
}
|
||||
|
||||
29
readeck/Domain/Protocols/PSettingsRepository.swift
Normal file
29
readeck/Domain/Protocols/PSettingsRepository.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// PSettingsRepository.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Claude on 08.11.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol PSettingsRepository {
|
||||
// Existing Settings methods
|
||||
func saveSettings(_ settings: Settings) async throws
|
||||
func loadSettings() async throws -> Settings?
|
||||
func clearSettings() async throws
|
||||
func saveToken(_ token: String) async throws
|
||||
func saveUsername(_ username: String) async throws
|
||||
func savePassword(_ password: String) async throws
|
||||
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
|
||||
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
|
||||
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws
|
||||
func loadCardLayoutStyle() async throws -> CardLayoutStyle
|
||||
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws
|
||||
func loadTagSortOrder() async throws -> TagSortOrder
|
||||
var hasFinishedSetup: Bool { get }
|
||||
|
||||
// Offline Settings methods
|
||||
func loadOfflineSettings() async throws -> OfflineSettings
|
||||
func saveOfflineSettings(_ settings: OfflineSettings) async throws
|
||||
}
|
||||
@ -8,16 +8,21 @@
|
||||
</entity>
|
||||
<entity name="BookmarkEntity" representedClassName="BookmarkEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="authors" optional="YES" attributeType="String"/>
|
||||
<attribute name="cachedDate" optional="YES" attributeType="Date" usesScalarValueType="NO" indexed="YES"/>
|
||||
<attribute name="cacheSize" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="created" optional="YES" attributeType="String"/>
|
||||
<attribute name="desc" optional="YES" attributeType="String"/>
|
||||
<attribute name="documentType" optional="YES" attributeType="String"/>
|
||||
<attribute name="hasArticle" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="hasDeleted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="href" optional="YES" attributeType="String"/>
|
||||
<attribute name="htmlContent" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" optional="YES" attributeType="String"/>
|
||||
<attribute name="imageURLs" optional="YES" attributeType="String"/>
|
||||
<attribute name="isArchived" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="isMarked" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="lang" optional="YES" attributeType="String"/>
|
||||
<attribute name="lastAccessDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="loaded" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="published" optional="YES" attributeType="String"/>
|
||||
<attribute name="readingTime" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user