Compare commits

..

No commits in common. "f40c5597f3e09820d09bf3f1ec172a67ffb3f55e" and "5b520995acf5458ff2f3035562b51957b6699f28" have entirely different histories.

9 changed files with 52 additions and 184 deletions

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
import CoreData import CoreData
class OfflineBookmarkManager: @unchecked Sendable { class OfflineBookmarkManager {
static let shared = OfflineBookmarkManager() static let shared = OfflineBookmarkManager()
private init() {} private init() {}
@ -17,31 +17,27 @@ class OfflineBookmarkManager: @unchecked Sendable {
func saveOfflineBookmark(url: String, title: String = "", tags: [String] = []) -> Bool { func saveOfflineBookmark(url: String, title: String = "", tags: [String] = []) -> Bool {
let tagsString = tags.joined(separator: ",") let tagsString = tags.joined(separator: ",")
// Check if URL already exists offline
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "url == %@", url)
do { do {
try context.safePerform { [weak self] in let existingEntities = try context.fetch(fetchRequest)
guard let self = self else { return } if let existingEntity = existingEntities.first {
// Update existing entry
// Check if URL already exists offline existingEntity.tags = tagsString
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest() existingEntity.title = title
fetchRequest.predicate = NSPredicate(format: "url == %@", url) } else {
// Create new entry
let existingEntities = try self.context.fetch(fetchRequest) let entity = ArticleURLEntity(context: context)
if let existingEntity = existingEntities.first { entity.id = UUID()
// Update existing entry entity.url = url
existingEntity.tags = tagsString entity.title = title
existingEntity.title = title entity.tags = tagsString
} else {
// Create new entry
let entity = ArticleURLEntity(context: self.context)
entity.id = UUID()
entity.url = url
entity.title = title
entity.tags = tagsString
}
try self.context.save()
print("Bookmark saved offline: \(url)")
} }
try context.save()
print("Bookmark saved offline: \(url)")
return true return true
} catch { } catch {
print("Failed to save offline bookmark: \(error)") print("Failed to save offline bookmark: \(error)")
@ -50,14 +46,11 @@ class OfflineBookmarkManager: @unchecked Sendable {
} }
func getTags() -> [String] { func getTags() -> [String] {
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
do { do {
return try context.safePerform { [weak self] in let tagEntities = try context.fetch(fetchRequest)
guard let self = self else { return [] } return tagEntities.compactMap { $0.name }.sorted()
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
let tagEntities = try self.context.fetch(fetchRequest)
return tagEntities.compactMap { $0.name }.sorted()
}
} catch { } catch {
print("Failed to fetch tags: \(error)") print("Failed to fetch tags: \(error)")
return [] return []

View File

@ -81,7 +81,6 @@
membershipExceptions = ( membershipExceptions = (
Assets.xcassets, Assets.xcassets,
Data/CoreData/CoreDataManager.swift, Data/CoreData/CoreDataManager.swift,
"Data/Extensions/NSManagedObjectContext+SafeFetch.swift",
Data/KeychainHelper.swift, Data/KeychainHelper.swift,
Domain/Model/Bookmark.swift, Domain/Model/Bookmark.swift,
Domain/Model/BookmarkLabel.swift, Domain/Model/BookmarkLabel.swift,
@ -436,7 +435,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist; INFOPLIST_FILE = URLShare/Info.plist;
@ -469,7 +468,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist; INFOPLIST_FILE = URLShare/Info.plist;
@ -624,7 +623,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@ -668,7 +667,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;

View File

@ -1,5 +1,5 @@
{ {
"originHash" : "3d745f8bc704b9a02b7c5a0c9f0ca6d05865f6fa0a02ec3b2734e9c398279457", "originHash" : "23641a762ee1f352c85f7c3a1e980d54670907541f34888222e901374fcaa088",
"pins" : [ "pins" : [
{ {
"identity" : "kingfisher", "identity" : "kingfisher",

View File

@ -1,77 +0,0 @@
//
// NSManagedObjectContext+SafeFetch.swift
// readeck
//
// Created by Ilyas Hallak on 25.07.25.
//
// SPDX-License-Identifier: MIT
//
// This file is part of the readeck project and is licensed under the MIT License.
//
import CoreData
import Foundation
extension NSManagedObjectContext {
/// Thread-safe fetch that automatically wraps the operation in performAndWait
func safeFetch<T: NSManagedObject>(_ request: NSFetchRequest<T>) throws -> [T] {
var results: [T] = []
var fetchError: Error?
performAndWait {
do {
results = try self.fetch(request)
} catch {
fetchError = error
}
}
if let error = fetchError {
throw error
}
return results
}
/// Thread-safe perform operation with return value
func safePerform<T>(_ operation: @escaping @Sendable () throws -> T) throws -> T {
var result: T?
var operationError: Error?
performAndWait {
do {
result = try operation()
} catch {
operationError = error
}
}
if let error = operationError {
throw error
}
guard let unwrappedResult = result else {
throw NSError(domain: "SafePerformError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Operation returned nil"])
}
return unwrappedResult
}
/// Thread-safe perform operation without return value
func safePerform(_ operation: @escaping () throws -> Void) throws {
var operationError: Error?
performAndWait {
do {
try operation()
} catch {
operationError = error
}
}
if let error = operationError {
throw error
}
}
}

View File

@ -2,7 +2,7 @@ import Foundation
import CoreData import CoreData
import SwiftUI import SwiftUI
class OfflineSyncManager: ObservableObject, @unchecked Sendable { class OfflineSyncManager: ObservableObject {
static let shared = OfflineSyncManager() static let shared = OfflineSyncManager()
@Published var isSyncing = false @Published var isSyncing = false
@ -99,9 +99,10 @@ class OfflineSyncManager: ObservableObject, @unchecked Sendable {
} }
private func getOfflineBookmarks() -> [ArticleURLEntity] { private func getOfflineBookmarks() -> [ArticleURLEntity] {
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
do { do {
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest() return try coreDataManager.context.fetch(fetchRequest)
return try coreDataManager.context.safeFetch(fetchRequest)
} catch { } catch {
print("Failed to fetch offline bookmarks: \(error)") print("Failed to fetch offline bookmarks: \(error)")
return [] return []
@ -109,16 +110,8 @@ class OfflineSyncManager: ObservableObject, @unchecked Sendable {
} }
private func deleteOfflineBookmark(_ entity: ArticleURLEntity) { private func deleteOfflineBookmark(_ entity: ArticleURLEntity) {
do { coreDataManager.context.delete(entity)
try coreDataManager.context.safePerform { [weak self] in coreDataManager.save()
guard let self = self else { return }
self.coreDataManager.context.delete(entity)
self.coreDataManager.save()
}
} catch {
print("Failed to delete offline bookmark: \(error)")
}
} }
// MARK: - Auto Sync on Server Connectivity Changes // MARK: - Auto Sync on Server Connectivity Changes

View File

@ -42,15 +42,6 @@ class SettingsRepository: PSettingsRepository {
private let userDefault = UserDefaults.standard private let userDefault = UserDefaults.standard
private let keychainHelper = KeychainHelper.shared private let keychainHelper = KeychainHelper.shared
var hasFinishedSetup: Bool {
get {
return userDefault.value(forKey: "hasFinishedSetup") as? Bool ?? false
}
set {
userDefault.set(newValue, forKey: "hasFinishedSetup")
}
}
func saveSettings(_ settings: Settings) async throws { func saveSettings(_ settings: Settings) async throws {
// Save credentials to keychain // Save credentials to keychain
if let endpoint = settings.endpoint, !endpoint.isEmpty { if let endpoint = settings.endpoint, !endpoint.isEmpty {
@ -215,6 +206,15 @@ class SettingsRepository: PSettingsRepository {
} }
} }
var hasFinishedSetup: Bool {
get {
return userDefault.value(forKey: "hasFinishedSetup") as? Bool ?? false
}
set {
userDefault.set(newValue, forKey: "hasFinishedSetup")
}
}
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws { func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws {
let context = coreDataManager.context let context = coreDataManager.context

View File

@ -190,7 +190,7 @@ struct BookmarkDetailView: View {
let offset = geo.frame(in: .global).minY let offset = geo.frame(in: .global).minY
ZStack(alignment: .top) { ZStack(alignment: .top) {
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
.aspectRatio(contentMode: .fill) .scaledToFit()
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0)) .frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
.clipped() .clipped()
.offset(y: (offset > 0 ? -offset : 0)) .offset(y: (offset > 0 ? -offset : 0))

View File

@ -88,8 +88,7 @@ struct BookmarksView: View {
private var shouldShowCenteredState: Bool { private var shouldShowCenteredState: Bool {
let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
let hasError = viewModel.errorMessage != nil return isEmpty && (viewModel.isLoading || viewModel.errorMessage != nil)
return (isEmpty && viewModel.isLoading) || hasError
} }
// MARK: - View Components // MARK: - View Components
@ -135,16 +134,16 @@ struct BookmarksView: View {
@ViewBuilder @ViewBuilder
private func errorView(message: String) -> some View { private func errorView(message: String) -> some View {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: viewModel.isNetworkError ? "wifi.slash" : "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48)) .font(.system(size: 48))
.foregroundColor(.orange) .foregroundColor(.orange)
VStack(spacing: 8) { VStack(spacing: 8) {
Text(viewModel.isNetworkError ? "No internet connection" : "Unable to load bookmarks") Text("Unable to load bookmarks")
.font(.headline) .font(.headline)
.foregroundColor(.primary) .foregroundColor(.primary)
Text(viewModel.isNetworkError ? "Please check your internet connection and try again" : message) Text(message)
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -152,7 +151,7 @@ struct BookmarksView: View {
Button("Try Again") { Button("Try Again") {
Task { Task {
await viewModel.retryLoading() await viewModel.loadBookmarks(state: state, type: type, tag: tag)
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)

View File

@ -13,7 +13,6 @@ class BookmarksViewModel {
var isLoading = false var isLoading = false
var isInitialLoading = true var isInitialLoading = true
var errorMessage: String? var errorMessage: String?
var isNetworkError = false
var currentState: BookmarkState = .unread var currentState: BookmarkState = .unread
var currentType = [BookmarkType.article] var currentType = [BookmarkType.article]
var currentTag: String? = nil var currentTag: String? = nil
@ -124,22 +123,8 @@ class BookmarksViewModel {
) )
bookmarks = newBookmarks bookmarks = newBookmarks
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // check if more data is available hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // check if more data is available
isNetworkError = false
} catch { } catch {
// Check if it's a network error errorMessage = "Error loading bookmarks"
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
isNetworkError = true
errorMessage = "No internet connection"
default:
isNetworkError = false
errorMessage = "Error loading bookmarks"
}
} else {
isNetworkError = false
errorMessage = "Error loading bookmarks"
}
// Don't clear bookmarks on error - keep existing data visible // Don't clear bookmarks on error - keep existing data visible
} }
@ -166,20 +151,7 @@ class BookmarksViewModel {
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks) bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks)
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages
} catch { } catch {
// Check if it's a network error errorMessage = "Error loading more bookmarks"
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
isNetworkError = true
errorMessage = "No internet connection"
default:
isNetworkError = false
errorMessage = "Error loading more bookmarks"
}
} else {
isNetworkError = false
errorMessage = "Error loading more bookmarks"
}
} }
isLoading = false isLoading = false
@ -190,13 +162,6 @@ class BookmarksViewModel {
await loadBookmarks(state: currentState) await loadBookmarks(state: currentState)
} }
@MainActor
func retryLoading() async {
errorMessage = nil
isNetworkError = false
await loadBookmarks(state: currentState, type: currentType, tag: currentTag)
}
@MainActor @MainActor
func toggleArchive(bookmark: Bookmark) async { func toggleArchive(bookmark: Bookmark) async {
do { do {
@ -293,10 +258,6 @@ class BookmarksViewModel {
private func executeDelete(bookmark: Bookmark) async { private func executeDelete(bookmark: Bookmark) async {
do { do {
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id) try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
// If delete succeeds, remove bookmark from the list
await MainActor.run {
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
}
} catch { } catch {
// If delete fails, restore the bookmark // If delete fails, restore the bookmark
await MainActor.run { await MainActor.run {