refactor: Implement state machine architecture for offline sync
- Replace multiple boolean properties with single OfflineBookmarkSyncState enum - Add Use Case pattern for OfflineSyncManager with dependency injection - Simplify LocalBookmarksSyncView using state-driven UI with external bindings - Convert OfflineBookmarksViewModel to use @Observable instead of ObservableObject - Move credentials from Core Data to Keychain for better persistence - Implement comprehensive database migration for App Group containers - Add structured logging throughout sync operations and API calls Architecture improvements follow MVVM principles with clean separation of concerns.
This commit is contained in:
parent
ef13faeff7
commit
ffb41347af
@ -114,15 +114,36 @@
|
|||||||
},
|
},
|
||||||
"Cancel" : {
|
"Cancel" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Category-specific Levels" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Clear cache" : {
|
"Clear cache" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Close" : {
|
"Close" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Configure log levels and categories" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Critical" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Data Management" : {
|
"Data Management" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Debug" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"DEBUG BUILD" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Debug Settings" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Delete" : {
|
"Delete" : {
|
||||||
|
|
||||||
@ -171,30 +192,54 @@
|
|||||||
},
|
},
|
||||||
"General" : {
|
"General" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Global Level" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Global Minimum Level" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Global Settings" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"https://example.com" : {
|
"https://example.com" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"https://readeck.example.com" : {
|
"https://readeck.example.com" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Include Source Location" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Info" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Jump to last read position (%lld%%)" : {
|
"Jump to last read position (%lld%%)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Key" : {
|
"Key" : {
|
||||||
"extractionState" : "manual"
|
"extractionState" : "manual"
|
||||||
|
},
|
||||||
|
"Level for %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Loading %@" : {
|
"Loading %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Loading article..." : {
|
"Loading article..." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Logging Configuration" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Login & Save" : {
|
"Login & Save" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Logout" : {
|
"Logout" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Logs below this level will be filtered out globally" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Manage Labels" : {
|
"Manage Labels" : {
|
||||||
|
|
||||||
@ -222,6 +267,9 @@
|
|||||||
},
|
},
|
||||||
"No results" : {
|
"No results" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Notice" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"OK" : {
|
"OK" : {
|
||||||
|
|
||||||
@ -277,9 +325,15 @@
|
|||||||
},
|
},
|
||||||
"Remove" : {
|
"Remove" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Reset" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Reset settings" : {
|
"Reset settings" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Reset to Defaults" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Restore" : {
|
"Restore" : {
|
||||||
|
|
||||||
@ -329,10 +383,13 @@
|
|||||||
"Settings" : {
|
"Settings" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Speed" : {
|
"Show Performance Logs" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Successfully logged in" : {
|
"Show Timestamps" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Speed" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Sync interval" : {
|
"Sync interval" : {
|
||||||
@ -361,6 +418,9 @@
|
|||||||
},
|
},
|
||||||
"Version %@" : {
|
"Version %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Warning" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Your current server connection and login credentials." : {
|
"Your current server connection and login credentials." : {
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,8 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
@Published var isServerReachable: Bool = true
|
@Published var isServerReachable: Bool = true
|
||||||
let extensionContext: NSExtensionContext?
|
let extensionContext: NSExtensionContext?
|
||||||
|
|
||||||
|
private let logger = Logger.viewModel
|
||||||
|
|
||||||
// Computed properties for pagination
|
// Computed properties for pagination
|
||||||
var availableLabels: [BookmarkLabelDto] {
|
var availableLabels: [BookmarkLabelDto] {
|
||||||
return labels.filter { !selectedLabels.contains($0.name) }
|
return labels.filter { !selectedLabels.contains($0.name) }
|
||||||
@ -43,20 +45,29 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
|
|
||||||
init(extensionContext: NSExtensionContext?) {
|
init(extensionContext: NSExtensionContext?) {
|
||||||
self.extensionContext = extensionContext
|
self.extensionContext = extensionContext
|
||||||
|
logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)")
|
||||||
extractSharedContent()
|
extractSharedContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
func onAppear() {
|
func onAppear() {
|
||||||
|
logger.debug("ShareBookmarkViewModel appeared")
|
||||||
checkServerReachability()
|
checkServerReachability()
|
||||||
loadLabels()
|
loadLabels()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkServerReachability() {
|
private func checkServerReachability() {
|
||||||
|
let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger)
|
||||||
isServerReachable = ServerConnectivity.isServerReachableSync()
|
isServerReachable = ServerConnectivity.isServerReachableSync()
|
||||||
|
logger.info("Server reachability checked: \(isServerReachable)")
|
||||||
|
measurement.end()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func extractSharedContent() {
|
private func extractSharedContent() {
|
||||||
guard let extensionContext = extensionContext else { return }
|
logger.debug("Starting to extract shared content")
|
||||||
|
guard let extensionContext = extensionContext else {
|
||||||
|
logger.warning("No extension context available for content extraction")
|
||||||
|
return
|
||||||
|
}
|
||||||
for item in extensionContext.inputItems {
|
for item in extensionContext.inputItems {
|
||||||
guard let inputItem = item as? NSExtensionItem else { continue }
|
guard let inputItem = item as? NSExtensionItem else { continue }
|
||||||
for attachment in inputItem.attachments ?? [] {
|
for attachment in inputItem.attachments ?? [] {
|
||||||
@ -65,6 +76,9 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let url = url as? URL {
|
if let url = url as? URL {
|
||||||
self?.url = url.absoluteString
|
self?.url = url.absoluteString
|
||||||
|
self?.logger.info("Extracted URL from shared content: \(url.absoluteString)")
|
||||||
|
} else if let error = error {
|
||||||
|
self?.logger.error("Failed to extract URL: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -74,6 +88,9 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let text = text as? String, let url = URL(string: text) {
|
if let text = text as? String, let url = URL(string: text) {
|
||||||
self?.url = url.absoluteString
|
self?.url = url.absoluteString
|
||||||
|
self?.logger.info("Extracted URL from shared text: \(url.absoluteString)")
|
||||||
|
} else if let error = error {
|
||||||
|
self?.logger.error("Failed to extract text: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -83,10 +100,12 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadLabels() {
|
func loadLabels() {
|
||||||
|
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
|
||||||
|
logger.debug("Starting to load labels")
|
||||||
Task {
|
Task {
|
||||||
// Check if server is reachable
|
// Check if server is reachable
|
||||||
let serverReachable = ServerConnectivity.isServerReachableSync()
|
let serverReachable = ServerConnectivity.isServerReachableSync()
|
||||||
print("DEBUG: Server reachable: \(serverReachable)")
|
logger.debug("Server reachable for labels: \(serverReachable)")
|
||||||
|
|
||||||
if serverReachable {
|
if serverReachable {
|
||||||
// Load from API
|
// Load from API
|
||||||
@ -96,7 +115,8 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
let sorted = loaded.sorted { $0.count > $1.count }
|
let sorted = loaded.sorted { $0.count > $1.count }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.labels = Array(sorted)
|
self.labels = Array(sorted)
|
||||||
print("DEBUG: Loaded \(loaded.count) labels from API")
|
self.logger.info("Loaded \(loaded.count) labels from API")
|
||||||
|
measurement.end()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Load from local database
|
// Load from local database
|
||||||
@ -107,51 +127,83 @@ class ShareBookmarkViewModel: ObservableObject {
|
|||||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.labels = localLabels
|
self.labels = localLabels
|
||||||
|
self.logger.info("Loaded \(localLabels.count) labels from local database")
|
||||||
|
measurement.end()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
|
logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)")
|
||||||
guard let url = url, !url.isEmpty else {
|
guard let url = url, !url.isEmpty else {
|
||||||
|
logger.warning("Save attempted without valid URL")
|
||||||
statusMessage = ("No URL found.", true, "❌")
|
statusMessage = ("No URL found.", true, "❌")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isSaving = true
|
isSaving = true
|
||||||
|
logger.debug("Set saving state to true")
|
||||||
|
|
||||||
// Check server connectivity
|
// Check server connectivity
|
||||||
if ServerConnectivity.isServerReachableSync() {
|
let serverReachable = ServerConnectivity.isServerReachableSync()
|
||||||
|
logger.debug("Server connectivity for save: \(serverReachable)")
|
||||||
|
if serverReachable {
|
||||||
// Online - try to save via API
|
// Online - try to save via API
|
||||||
|
logger.info("Attempting to save bookmark via API")
|
||||||
Task {
|
Task {
|
||||||
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
|
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
|
||||||
|
self?.logger.info("API save completed - Success: \(!error), Message: \(message)")
|
||||||
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||||
self?.isSaving = false
|
self?.isSaving = false
|
||||||
if !error {
|
if !error {
|
||||||
|
self?.logger.debug("Bookmark saved successfully, completing extension request")
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
self?.completeExtensionRequest()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
self?.logger.error("Failed to save bookmark via API: \(message)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Server not reachable - save locally
|
// Server not reachable - save locally
|
||||||
|
logger.info("Server not reachable, attempting local save")
|
||||||
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
|
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
|
||||||
url: url,
|
url: url,
|
||||||
title: title,
|
title: title,
|
||||||
tags: Array(selectedLabels)
|
tags: Array(selectedLabels)
|
||||||
)
|
)
|
||||||
|
logger.info("Local save result: \(success)")
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.isSaving = false
|
self.isSaving = false
|
||||||
if success {
|
if success {
|
||||||
|
self.logger.info("Bookmark saved locally successfully")
|
||||||
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
|
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||||
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
self.completeExtensionRequest()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
self.logger.error("Failed to save bookmark locally")
|
||||||
self.statusMessage = ("Failed to save locally.", true, "❌")
|
self.statusMessage = ("Failed to save locally.", true, "❌")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func completeExtensionRequest() {
|
||||||
|
logger.debug("Completing extension request")
|
||||||
|
guard let context = extensionContext else {
|
||||||
|
logger.warning("Extension context not available for completion")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.completeRequest(returningItems: []) { [weak self] error in
|
||||||
|
if error {
|
||||||
|
self?.logger.error("Extension completion failed: \(error)")
|
||||||
|
} else {
|
||||||
|
self?.logger.info("Extension request completed successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class SimpleAPI {
|
class SimpleAPI {
|
||||||
|
private static let logger = Logger.network
|
||||||
|
|
||||||
// MARK: - API Methods
|
// MARK: - API Methods
|
||||||
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
|
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
|
||||||
|
logger.info("Adding bookmark: \(url)")
|
||||||
guard let token = KeychainHelper.shared.loadToken() else {
|
guard let token = KeychainHelper.shared.loadToken() else {
|
||||||
showStatus("No token found. Please log in via the main app.", true)
|
showStatus("No token found. Please log in via the main app.", true)
|
||||||
return
|
return
|
||||||
@ -28,25 +31,35 @@ class SimpleAPI {
|
|||||||
do {
|
do {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
logger.error("Invalid server response for bookmark creation")
|
||||||
showStatus("Invalid server response.", true)
|
showStatus("Invalid server response.", true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "POST", url: "/api/bookmarks", statusCode: httpResponse.statusCode)
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
|
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
||||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
|
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
|
||||||
|
logger.info("Bookmark created successfully: \(resp.message)")
|
||||||
showStatus("Saved: \(resp.message)", false)
|
showStatus("Saved: \(resp.message)", false)
|
||||||
} else {
|
} else {
|
||||||
|
logger.info("Bookmark created successfully")
|
||||||
showStatus("Bookmark saved!", false)
|
showStatus("Bookmark saved!", false)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
logger.logNetworkError(method: "POST", url: "/api/bookmarks", error: error)
|
||||||
showStatus("Network error: \(error.localizedDescription)", true)
|
showStatus("Network error: \(error.localizedDescription)", true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getBookmarkLabels(showStatus: @escaping (String, Bool) -> Void) async -> [BookmarkLabelDto]? {
|
static func getBookmarkLabels(showStatus: @escaping (String, Bool) -> Void) async -> [BookmarkLabelDto]? {
|
||||||
|
logger.info("Fetching bookmark labels")
|
||||||
guard let token = KeychainHelper.shared.loadToken() else {
|
guard let token = KeychainHelper.shared.loadToken() else {
|
||||||
showStatus("No token found. Please log in via the main app.", true)
|
showStatus("No token found. Please log in via the main app.", true)
|
||||||
return nil
|
return nil
|
||||||
@ -66,17 +79,25 @@ class SimpleAPI {
|
|||||||
do {
|
do {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
logger.error("Invalid server response for labels request")
|
||||||
showStatus("Invalid server response.", true)
|
showStatus("Invalid server response.", true)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "GET", url: "/api/bookmarks/labels", statusCode: httpResponse.statusCode)
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
|
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
||||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let labels = try JSONDecoder().decode([BookmarkLabelDto].self, from: data)
|
let labels = try JSONDecoder().decode([BookmarkLabelDto].self, from: data)
|
||||||
|
logger.info("Successfully fetched \(labels.count) bookmark labels")
|
||||||
return labels
|
return labels
|
||||||
} catch {
|
} catch {
|
||||||
|
logger.logNetworkError(method: "GET", url: "/api/bookmarks/labels", error: error)
|
||||||
showStatus("Network error: \(error.localizedDescription)", true)
|
showStatus("Network error: \(error.localizedDescription)", true)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,8 +23,9 @@ protocol PAPI {
|
|||||||
class API: PAPI {
|
class API: PAPI {
|
||||||
let tokenProvider: TokenProvider
|
let tokenProvider: TokenProvider
|
||||||
private var cachedBaseURL: String?
|
private var cachedBaseURL: String?
|
||||||
|
private let logger = Logger.network
|
||||||
|
|
||||||
init(tokenProvider: TokenProvider = CoreDataTokenProvider()) {
|
init(tokenProvider: TokenProvider = KeychainTokenProvider()) {
|
||||||
self.tokenProvider = tokenProvider
|
self.tokenProvider = tokenProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,7 +74,6 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
print("Server Error: \(httpResponse.statusCode) - \(String(data: data, encoding: .utf8) ?? "No response body")")
|
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,7 +114,6 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
print("Server Error: \(httpResponse.statusCode) - \(String(data: data, encoding: .utf8) ?? "No response body")")
|
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,7 +158,11 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func login(endpoint: String, username: String, password: String) async throws -> UserDto {
|
func login(endpoint: String, username: String, password: String) async throws -> UserDto {
|
||||||
guard let url = URL(string: endpoint + "/api/auth") else { throw APIError.invalidURL }
|
logger.info("Attempting login for user: \(username) at endpoint: \(endpoint)")
|
||||||
|
guard let url = URL(string: endpoint + "/api/auth") else {
|
||||||
|
logger.error("Invalid URL for login endpoint: \(endpoint)")
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password)
|
let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password)
|
||||||
let requestData = try JSONEncoder().encode(loginRequest)
|
let requestData = try JSONEncoder().encode(loginRequest)
|
||||||
@ -168,20 +171,27 @@ class API: PAPI {
|
|||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = requestData
|
request.httpBody = requestData
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "POST", url: url.absoluteString)
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
logger.error("Invalid HTTP response for login request")
|
||||||
throw APIError.invalidResponse
|
throw APIError.invalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
logger.logNetworkError(method: "POST", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "POST", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
||||||
|
logger.info("Login successful for user: \(username)")
|
||||||
return try JSONDecoder().decode(UserDto.self, from: data)
|
return try JSONDecoder().decode(UserDto.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil, tag: String? = nil) async throws -> BookmarksPageDto {
|
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil, tag: String? = nil) async throws -> BookmarksPageDto {
|
||||||
|
logger.debug("Fetching bookmarks with state: \(state?.rawValue ?? "all"), limit: \(limit ?? 0), offset: \(offset ?? 0)")
|
||||||
var endpoint = "/api/bookmarks"
|
var endpoint = "/api/bookmarks"
|
||||||
var queryItems: [URLQueryItem] = []
|
var queryItems: [URLQueryItem] = []
|
||||||
|
|
||||||
@ -227,11 +237,16 @@ class API: PAPI {
|
|||||||
endpoint += "?\(queryString)"
|
endpoint += "?\(queryString)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "GET", url: await self.baseURL + (endpoint.hasPrefix("/api") ? endpoint : "/api\(endpoint)"))
|
||||||
|
|
||||||
let (bookmarks, response) = try await makeJSONRequestWithHeaders(
|
let (bookmarks, response) = try await makeJSONRequestWithHeaders(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
responseType: [BookmarkDto].self
|
responseType: [BookmarkDto].self
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "GET", url: await self.baseURL + (endpoint.hasPrefix("/api") ? endpoint : "/api\(endpoint)"), statusCode: response.statusCode)
|
||||||
|
logger.info("Fetched \(bookmarks.count) bookmarks")
|
||||||
|
|
||||||
// Header auslesen
|
// Header auslesen
|
||||||
let currentPage = response.value(forHTTPHeaderField: "Current-Page").flatMap { Int($0) }
|
let currentPage = response.value(forHTTPHeaderField: "Current-Page").flatMap { Int($0) }
|
||||||
let totalCount = response.value(forHTTPHeaderField: "Total-Count").flatMap { Int($0) }
|
let totalCount = response.value(forHTTPHeaderField: "Total-Count").flatMap { Int($0) }
|
||||||
@ -249,38 +264,60 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getBookmark(id: String) async throws -> BookmarkDetailDto {
|
func getBookmark(id: String) async throws -> BookmarkDetailDto {
|
||||||
return try await makeJSONRequest(
|
logger.debug("Fetching bookmark: \(id)")
|
||||||
endpoint: "/api/bookmarks/\(id)",
|
let endpoint = "/api/bookmarks/\(id)"
|
||||||
|
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
|
||||||
|
|
||||||
|
let result = try await makeJSONRequest(
|
||||||
|
endpoint: endpoint,
|
||||||
responseType: BookmarkDetailDto.self
|
responseType: BookmarkDetailDto.self
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info("Successfully fetched bookmark: \(id)")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artikel als String laden statt als JSON
|
// Artikel als String laden statt als JSON
|
||||||
func getBookmarkArticle(id: String) async throws -> String {
|
func getBookmarkArticle(id: String) async throws -> String {
|
||||||
return try await makeStringRequest(
|
logger.debug("Fetching article for bookmark: \(id)")
|
||||||
endpoint: "/api/bookmarks/\(id)/article"
|
let endpoint = "/api/bookmarks/\(id)/article"
|
||||||
|
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
|
||||||
|
|
||||||
|
let result = try await makeStringRequest(
|
||||||
|
endpoint: endpoint
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info("Successfully fetched article for bookmark: \(id)")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto {
|
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto {
|
||||||
|
logger.info("Creating bookmark for URL: \(createRequest.url)")
|
||||||
let requestData = try JSONEncoder().encode(createRequest)
|
let requestData = try JSONEncoder().encode(createRequest)
|
||||||
|
let endpoint = "/api/bookmarks"
|
||||||
|
logger.logNetworkRequest(method: "POST", url: await self.baseURL + endpoint)
|
||||||
|
|
||||||
return try await makeJSONRequest(
|
let result = try await makeJSONRequest(
|
||||||
endpoint: "/api/bookmarks",
|
endpoint: endpoint,
|
||||||
method: .POST,
|
method: .POST,
|
||||||
body: requestData,
|
body: requestData,
|
||||||
responseType: CreateBookmarkResponseDto.self
|
responseType: CreateBookmarkResponseDto.self
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info("Successfully created bookmark: \(result.status)")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws {
|
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws {
|
||||||
|
logger.info("Updating bookmark: \(id)")
|
||||||
let requestData = try JSONEncoder().encode(updateRequest)
|
let requestData = try JSONEncoder().encode(updateRequest)
|
||||||
|
|
||||||
// PATCH Request ohne Response-Body erwarten
|
// Use makeJSONRequest but ignore the response since PATCH returns no body
|
||||||
let baseURL = await self.baseURL
|
let baseURL = await self.baseURL
|
||||||
let fullEndpoint = "/api/bookmarks/\(id)"
|
let fullEndpoint = "/api/bookmarks/\(id)"
|
||||||
|
|
||||||
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
|
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
|
||||||
|
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -295,24 +332,32 @@ class API: PAPI {
|
|||||||
|
|
||||||
request.httpBody = requestData
|
request.httpBody = requestData
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "PATCH", url: url.absoluteString)
|
||||||
|
|
||||||
let (_, response) = try await URLSession.shared.data(for: request)
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
logger.error("Invalid HTTP response for PATCH \(url.absoluteString)")
|
||||||
throw APIError.invalidResponse
|
throw APIError.invalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
print("Server Error: \(httpResponse.statusCode)")
|
logger.logNetworkError(method: "PATCH", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "PATCH", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
||||||
|
logger.info("Successfully updated bookmark: \(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteBookmark(id: String) async throws {
|
func deleteBookmark(id: String) async throws {
|
||||||
// DELETE Request ohne Response-Body erwarten
|
logger.info("Deleting bookmark: \(id)")
|
||||||
|
|
||||||
let baseURL = await self.baseURL
|
let baseURL = await self.baseURL
|
||||||
let fullEndpoint = "/api/bookmarks/\(id)"
|
let fullEndpoint = "/api/bookmarks/\(id)"
|
||||||
|
|
||||||
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
|
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
|
||||||
|
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -324,25 +369,37 @@ class API: PAPI {
|
|||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString)
|
||||||
|
|
||||||
let (_, response) = try await URLSession.shared.data(for: request)
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
logger.error("Invalid HTTP response for DELETE \(url.absoluteString)")
|
||||||
throw APIError.invalidResponse
|
throw APIError.invalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
print("Server Error: \(httpResponse.statusCode)")
|
logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||||
throw APIError.serverError(httpResponse.statusCode)
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
||||||
|
logger.info("Successfully deleted bookmark: \(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchBookmarks(search: String) async throws -> BookmarksPageDto {
|
func searchBookmarks(search: String) async throws -> BookmarksPageDto {
|
||||||
|
logger.debug("Searching bookmarks with query: \(search)")
|
||||||
let endpoint = "/api/bookmarks?search=\(search.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")"
|
let endpoint = "/api/bookmarks?search=\(search.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")"
|
||||||
|
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
|
||||||
|
|
||||||
let (bookmarks, response) = try await makeJSONRequestWithHeaders(
|
let (bookmarks, response) = try await makeJSONRequestWithHeaders(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
responseType: [BookmarkDto].self
|
responseType: [BookmarkDto].self
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint, statusCode: response.statusCode)
|
||||||
|
logger.info("Found \(bookmarks.count) bookmarks matching search: \(search)")
|
||||||
|
|
||||||
let currentPage = response.value(forHTTPHeaderField: "Current-Page").flatMap { Int($0) }
|
let currentPage = response.value(forHTTPHeaderField: "Current-Page").flatMap { Int($0) }
|
||||||
let totalCount = response.value(forHTTPHeaderField: "Total-Count").flatMap { Int($0) }
|
let totalCount = response.value(forHTTPHeaderField: "Total-Count").flatMap { Int($0) }
|
||||||
let totalPages = response.value(forHTTPHeaderField: "Total-Pages").flatMap { Int($0) }
|
let totalPages = response.value(forHTTPHeaderField: "Total-Pages").flatMap { Int($0) }
|
||||||
@ -359,10 +416,17 @@ class API: PAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getBookmarkLabels() async throws -> [BookmarkLabelDto] {
|
func getBookmarkLabels() async throws -> [BookmarkLabelDto] {
|
||||||
return try await makeJSONRequest(
|
logger.debug("Fetching bookmark labels")
|
||||||
endpoint: "/api/bookmarks/labels",
|
let endpoint = "/api/bookmarks/labels"
|
||||||
|
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
|
||||||
|
|
||||||
|
let result = try await makeJSONRequest(
|
||||||
|
endpoint: endpoint,
|
||||||
responseType: [BookmarkLabelDto].self
|
responseType: [BookmarkLabelDto].self
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info("Successfully fetched \(result.count) bookmark labels")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,22 +4,43 @@ import Foundation
|
|||||||
class CoreDataManager {
|
class CoreDataManager {
|
||||||
static let shared = CoreDataManager()
|
static let shared = CoreDataManager()
|
||||||
|
|
||||||
|
private var isInMemoryStore = false
|
||||||
|
private let logger = Logger.data
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
lazy var persistentContainer: NSPersistentContainer = {
|
lazy var persistentContainer: NSPersistentContainer = {
|
||||||
let container = NSPersistentContainer(name: "readeck")
|
// Try to find the model in the main bundle first, then in extension bundle
|
||||||
|
guard let modelURL = Bundle.main.url(forResource: "readeck", withExtension: "momd") ??
|
||||||
|
Bundle(for: CoreDataManager.self).url(forResource: "readeck", withExtension: "momd") else {
|
||||||
|
logger.error("Could not find Core Data model file")
|
||||||
|
fatalError("Core Data model 'readeck.xcdatamodeld' not found in bundle")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) else {
|
||||||
|
logger.error("Could not load Core Data model from URL: \(modelURL)")
|
||||||
|
fatalError("Failed to load Core Data model")
|
||||||
|
}
|
||||||
|
|
||||||
|
let container = NSPersistentContainer(name: "readeck", managedObjectModel: managedObjectModel)
|
||||||
|
|
||||||
// Use App Group container for shared access with extensions
|
// Use App Group container for shared access with extensions
|
||||||
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.readeck.app")?.appendingPathComponent("readeck.sqlite")
|
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.readeck.app")?.appendingPathComponent("readeck.sqlite")
|
||||||
|
|
||||||
if let storeURL = storeURL {
|
if let storeURL = storeURL {
|
||||||
|
// Migrate existing database from app container to app group if needed
|
||||||
|
migrateStoreToAppGroupIfNeeded(targetURL: storeURL)
|
||||||
|
|
||||||
let storeDescription = NSPersistentStoreDescription(url: storeURL)
|
let storeDescription = NSPersistentStoreDescription(url: storeURL)
|
||||||
container.persistentStoreDescriptions = [storeDescription]
|
container.persistentStoreDescriptions = [storeDescription]
|
||||||
}
|
}
|
||||||
|
|
||||||
container.loadPersistentStores { _, error in
|
container.loadPersistentStores { [weak self] _, error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
fatalError("Core Data error: \(error)")
|
self?.logger.error("Core Data failed to load persistent store: \(error)", file: #file, function: #function, line: #line)
|
||||||
|
self?.setupInMemoryStore(container: container)
|
||||||
|
} else {
|
||||||
|
self?.logger.info("Core Data persistent store loaded successfully")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return container
|
return container
|
||||||
@ -33,9 +54,126 @@ class CoreDataManager {
|
|||||||
if context.hasChanges {
|
if context.hasChanges {
|
||||||
do {
|
do {
|
||||||
try context.save()
|
try context.save()
|
||||||
|
logger.debug("Core Data context saved successfully")
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save Core Data context: \(error)")
|
logger.error("Failed to save Core Data context: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setupInMemoryStore(container: NSPersistentContainer) {
|
||||||
|
logger.warning("Setting up in-memory Core Data store as fallback")
|
||||||
|
isInMemoryStore = true
|
||||||
|
|
||||||
|
let inMemoryDescription = NSPersistentStoreDescription()
|
||||||
|
inMemoryDescription.type = NSInMemoryStoreType
|
||||||
|
container.persistentStoreDescriptions = [inMemoryDescription]
|
||||||
|
|
||||||
|
container.loadPersistentStores { [weak self] _, error in
|
||||||
|
if let error = error {
|
||||||
|
self?.logger.error("Failed to setup in-memory store: \(error.localizedDescription)")
|
||||||
|
// Continue with empty container - app will work with reduced functionality
|
||||||
|
} else {
|
||||||
|
self?.logger.info("In-memory Core Data store setup successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func migrateStoreToAppGroupIfNeeded(targetURL: URL) {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
|
||||||
|
// Check if store already exists in app group
|
||||||
|
if fileManager.fileExists(atPath: targetURL.path) {
|
||||||
|
logger.info("Database already exists in app group container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try multiple possible old locations for database
|
||||||
|
var searchPaths: [URL] = []
|
||||||
|
|
||||||
|
// 1. App's documents directory (most common old location)
|
||||||
|
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||||
|
searchPaths.append(documentsURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. App's library directory
|
||||||
|
if let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first {
|
||||||
|
searchPaths.append(libraryURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. App's support directory
|
||||||
|
if let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
||||||
|
searchPaths.append(supportURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundOldStore = false
|
||||||
|
|
||||||
|
for searchPath in searchPaths {
|
||||||
|
let oldStoreURL = searchPath.appendingPathComponent("readeck.sqlite")
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: oldStoreURL.path) {
|
||||||
|
logger.info("Found existing database at: \(oldStoreURL.path)")
|
||||||
|
foundOldStore = true
|
||||||
|
|
||||||
|
if migrateFromPath(oldStoreURL: oldStoreURL, targetURL: targetURL) {
|
||||||
|
break // Successfully migrated, stop searching
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundOldStore {
|
||||||
|
logger.info("No existing database found in any search location - starting fresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func migrateFromPath(oldStoreURL: URL, targetURL: URL) -> Bool {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let oldStoreWAL = oldStoreURL.appendingPathExtension("wal")
|
||||||
|
let oldStoreSHM = oldStoreURL.appendingPathExtension("shm")
|
||||||
|
|
||||||
|
logger.info("Migrating existing database from: \(oldStoreURL.path)")
|
||||||
|
logger.info("Migrating existing database to: \(targetURL.path)")
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Create app group directory if it doesn't exist
|
||||||
|
let appGroupDirectory = targetURL.deletingLastPathComponent()
|
||||||
|
try fileManager.createDirectory(at: appGroupDirectory, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
// Copy main database file
|
||||||
|
try fileManager.copyItem(at: oldStoreURL, to: targetURL)
|
||||||
|
logger.info("Main database file migrated successfully")
|
||||||
|
|
||||||
|
// Copy WAL file if it exists
|
||||||
|
if fileManager.fileExists(atPath: oldStoreWAL.path) {
|
||||||
|
let targetWAL = targetURL.appendingPathExtension("wal")
|
||||||
|
try fileManager.copyItem(at: oldStoreWAL, to: targetWAL)
|
||||||
|
logger.info("WAL file migrated successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy SHM file if it exists
|
||||||
|
if fileManager.fileExists(atPath: oldStoreSHM.path) {
|
||||||
|
let targetSHM = targetURL.appendingPathExtension("shm")
|
||||||
|
try fileManager.copyItem(at: oldStoreSHM, to: targetSHM)
|
||||||
|
logger.info("SHM file migrated successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old files after successful migration
|
||||||
|
try fileManager.removeItem(at: oldStoreURL)
|
||||||
|
if fileManager.fileExists(atPath: oldStoreWAL.path) {
|
||||||
|
try fileManager.removeItem(at: oldStoreWAL)
|
||||||
|
}
|
||||||
|
if fileManager.fileExists(atPath: oldStoreSHM.path) {
|
||||||
|
try fileManager.removeItem(at: oldStoreSHM)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Database migration completed successfully")
|
||||||
|
return true
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to migrate database from \(oldStoreURL.path): \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,33 @@ class KeychainHelper {
|
|||||||
loadString(forKey: "readeck_endpoint")
|
loadString(forKey: "readeck_endpoint")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func saveUsername(_ username: String) -> Bool {
|
||||||
|
saveString(username, forKey: "readeck_username")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadUsername() -> String? {
|
||||||
|
loadString(forKey: "readeck_username")
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func savePassword(_ password: String) -> Bool {
|
||||||
|
saveString(password, forKey: "readeck_password")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPassword() -> String? {
|
||||||
|
loadString(forKey: "readeck_password")
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func clearCredentials() -> Bool {
|
||||||
|
let tokenCleared = saveString("", forKey: "readeck_token")
|
||||||
|
let endpointCleared = saveString("", forKey: "readeck_endpoint")
|
||||||
|
let usernameCleared = saveString("", forKey: "readeck_username")
|
||||||
|
let passwordCleared = saveString("", forKey: "readeck_password")
|
||||||
|
return tokenCleared && endpointCleared && usernameCleared && passwordCleared
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private generic helpers
|
// MARK: - Private generic helpers
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func saveString(_ value: String, forKey key: String) -> Bool {
|
private func saveString(_ value: String, forKey key: String) -> Bool {
|
||||||
|
|||||||
@ -37,51 +37,49 @@ protocol PSettingsRepository {
|
|||||||
class SettingsRepository: PSettingsRepository {
|
class SettingsRepository: PSettingsRepository {
|
||||||
private let coreDataManager = CoreDataManager.shared
|
private let coreDataManager = CoreDataManager.shared
|
||||||
private let userDefault = UserDefaults.standard
|
private let userDefault = UserDefaults.standard
|
||||||
|
private let keychainHelper = KeychainHelper.shared
|
||||||
|
|
||||||
func saveSettings(_ settings: Settings) async throws {
|
func saveSettings(_ settings: Settings) async throws {
|
||||||
|
// Save credentials to keychain
|
||||||
|
if let endpoint = settings.endpoint, !endpoint.isEmpty {
|
||||||
|
keychainHelper.saveEndpoint(endpoint)
|
||||||
|
}
|
||||||
|
if let username = settings.username, !username.isEmpty {
|
||||||
|
keychainHelper.saveUsername(username)
|
||||||
|
}
|
||||||
|
if let password = settings.password, !password.isEmpty {
|
||||||
|
keychainHelper.savePassword(password)
|
||||||
|
}
|
||||||
|
if let token = settings.token, !token.isEmpty {
|
||||||
|
keychainHelper.saveToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save UI preferences to Core Data
|
||||||
let context = coreDataManager.context
|
let context = coreDataManager.context
|
||||||
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
context.perform {
|
context.perform {
|
||||||
do {
|
do {
|
||||||
// Vorhandene Einstellungen löschen
|
|
||||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||||
if let existingSettings = try context.fetch(fetchRequest).first {
|
let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
|
||||||
|
|
||||||
if let endpoint = settings.endpoint, !endpoint.isEmpty {
|
if let fontFamily = settings.fontFamily {
|
||||||
existingSettings.endpoint = endpoint
|
existingSettings.fontFamily = fontFamily.rawValue
|
||||||
}
|
|
||||||
|
|
||||||
if let username = settings.username, !username.isEmpty {
|
|
||||||
existingSettings.username = username
|
|
||||||
}
|
|
||||||
|
|
||||||
if let password = settings.password, !password.isEmpty {
|
|
||||||
existingSettings.password = password
|
|
||||||
}
|
|
||||||
|
|
||||||
if let token = settings.token, !token.isEmpty {
|
|
||||||
existingSettings.token = token
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fontFamily = settings.fontFamily {
|
|
||||||
existingSettings.fontFamily = fontFamily.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fontSize = settings.fontSize {
|
|
||||||
existingSettings.fontSize = fontSize.rawValue
|
|
||||||
}
|
|
||||||
if let enableTTS = settings.enableTTS {
|
|
||||||
existingSettings.enableTTS = enableTTS
|
|
||||||
}
|
|
||||||
|
|
||||||
if let theme = settings.theme {
|
|
||||||
existingSettings.theme = theme.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
try context.save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let fontSize = settings.fontSize {
|
||||||
|
existingSettings.fontSize = fontSize.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if let enableTTS = settings.enableTTS {
|
||||||
|
existingSettings.enableTTS = enableTTS
|
||||||
|
}
|
||||||
|
|
||||||
|
if let theme = settings.theme {
|
||||||
|
existingSettings.theme = theme.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
try context.save()
|
||||||
continuation.resume()
|
continuation.resume()
|
||||||
} catch {
|
} catch {
|
||||||
continuation.resume(throwing: error)
|
continuation.resume(throwing: error)
|
||||||
@ -100,22 +98,26 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
fetchRequest.fetchLimit = 1
|
fetchRequest.fetchLimit = 1
|
||||||
|
|
||||||
let settingEntities = try context.fetch(fetchRequest)
|
let settingEntities = try context.fetch(fetchRequest)
|
||||||
|
let settingEntity = settingEntities.first
|
||||||
|
|
||||||
if let settingEntity = settingEntities.first {
|
// Load credentials from keychain only
|
||||||
let settings = Settings(
|
let endpoint = self.keychainHelper.loadEndpoint()
|
||||||
endpoint: settingEntity.endpoint ?? "",
|
let username = self.keychainHelper.loadUsername()
|
||||||
username: settingEntity.username ?? "",
|
let password = self.keychainHelper.loadPassword()
|
||||||
password: settingEntity.password ?? "",
|
let token = self.keychainHelper.loadToken()
|
||||||
token: settingEntity.token,
|
|
||||||
fontFamily: FontFamily(rawValue: settingEntity.fontFamily ?? FontFamily.system.rawValue),
|
// Load UI preferences from Core Data
|
||||||
fontSize: FontSize(rawValue: settingEntity.fontSize ?? FontSize.medium.rawValue),
|
let settings = Settings(
|
||||||
enableTTS: settingEntity.enableTTS,
|
endpoint: endpoint,
|
||||||
theme: Theme(rawValue: settingEntity.theme ?? Theme.system.rawValue)
|
username: username,
|
||||||
)
|
password: password,
|
||||||
continuation.resume(returning: settings)
|
token: token,
|
||||||
} else {
|
fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
|
||||||
continuation.resume(returning: nil)
|
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
|
||||||
}
|
enableTTS: settingEntity?.enableTTS,
|
||||||
|
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue)
|
||||||
|
)
|
||||||
|
continuation.resume(returning: settings)
|
||||||
} catch {
|
} catch {
|
||||||
continuation.resume(throwing: error)
|
continuation.resume(throwing: error)
|
||||||
}
|
}
|
||||||
@ -124,6 +126,10 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func clearSettings() async throws {
|
func clearSettings() async throws {
|
||||||
|
// Clear credentials from keychain
|
||||||
|
keychainHelper.clearCredentials()
|
||||||
|
|
||||||
|
// Also clear from Core Data
|
||||||
let context = coreDataManager.context
|
let context = coreDataManager.context
|
||||||
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
@ -146,128 +152,39 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveToken(_ token: String) async throws {
|
func saveToken(_ token: String) async throws {
|
||||||
let context = coreDataManager.context
|
// Save to keychain only
|
||||||
|
keychainHelper.saveToken(token)
|
||||||
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
// Wenn ein Token gespeichert wird, Setup als abgeschlossen markieren
|
||||||
context.perform {
|
if !token.isEmpty {
|
||||||
do {
|
self.hasFinishedSetup = true
|
||||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
// Notification senden, dass sich der Setup-Status geändert hat
|
||||||
fetchRequest.fetchLimit = 1
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||||
let settingEntities = try context.fetch(fetchRequest)
|
|
||||||
|
|
||||||
if let settingEntity = settingEntities.first {
|
|
||||||
settingEntity.token = token
|
|
||||||
} else {
|
|
||||||
let settingEntity = SettingEntity(context: context)
|
|
||||||
settingEntity.token = token
|
|
||||||
}
|
|
||||||
|
|
||||||
try context.save()
|
|
||||||
|
|
||||||
// Wenn ein Token gespeichert wird, Setup als abgeschlossen markieren
|
|
||||||
if !token.isEmpty {
|
|
||||||
self.hasFinishedSetup = true
|
|
||||||
// Notification senden, dass sich der Setup-Status geändert hat
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
continuation.resume()
|
|
||||||
} catch {
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws {
|
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws {
|
||||||
let context = coreDataManager.context
|
keychainHelper.saveEndpoint(endpoint)
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
keychainHelper.saveUsername(username)
|
||||||
context.perform {
|
keychainHelper.savePassword(password)
|
||||||
do {
|
keychainHelper.saveToken(token)
|
||||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
|
||||||
fetchRequest.fetchLimit = 1
|
if !token.isEmpty {
|
||||||
let settingEntities = try context.fetch(fetchRequest)
|
self.hasFinishedSetup = true
|
||||||
let settingEntity: SettingEntity
|
DispatchQueue.main.async {
|
||||||
if let existing = settingEntities.first {
|
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||||
settingEntity = existing
|
|
||||||
} else {
|
|
||||||
settingEntity = SettingEntity(context: context)
|
|
||||||
}
|
|
||||||
settingEntity.endpoint = endpoint
|
|
||||||
settingEntity.username = username
|
|
||||||
settingEntity.password = password
|
|
||||||
settingEntity.token = token
|
|
||||||
try context.save()
|
|
||||||
// Wenn ein Token gespeichert wird, Setup als abgeschlossen markieren
|
|
||||||
if !token.isEmpty {
|
|
||||||
self.hasFinishedSetup = true
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continuation.resume()
|
|
||||||
} catch {
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveUsername(_ username: String) async throws {
|
func saveUsername(_ username: String) async throws {
|
||||||
let context = coreDataManager.context
|
keychainHelper.saveUsername(username)
|
||||||
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
|
||||||
context.perform {
|
|
||||||
do {
|
|
||||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
|
||||||
fetchRequest.fetchLimit = 1
|
|
||||||
|
|
||||||
let settingEntities = try context.fetch(fetchRequest)
|
|
||||||
|
|
||||||
if let settingEntity = settingEntities.first {
|
|
||||||
settingEntity.username = username
|
|
||||||
} else {
|
|
||||||
let settingEntity = SettingEntity(context: context)
|
|
||||||
settingEntity.username = username
|
|
||||||
}
|
|
||||||
|
|
||||||
try context.save()
|
|
||||||
continuation.resume()
|
|
||||||
} catch {
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func savePassword(_ password: String) async throws {
|
func savePassword(_ password: String) async throws {
|
||||||
let context = coreDataManager.context
|
keychainHelper.savePassword(password)
|
||||||
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
|
||||||
context.perform {
|
|
||||||
do {
|
|
||||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
|
||||||
fetchRequest.fetchLimit = 1
|
|
||||||
|
|
||||||
let settingEntities = try context.fetch(fetchRequest)
|
|
||||||
|
|
||||||
if let settingEntity = settingEntities.first {
|
|
||||||
settingEntity.password = password
|
|
||||||
} else {
|
|
||||||
let settingEntity = SettingEntity(context: context)
|
|
||||||
settingEntity.password = password
|
|
||||||
}
|
|
||||||
|
|
||||||
try context.save()
|
|
||||||
continuation.resume()
|
|
||||||
} catch {
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws {
|
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws {
|
||||||
|
|||||||
@ -7,66 +7,22 @@ protocol TokenProvider {
|
|||||||
func clearToken() async
|
func clearToken() async
|
||||||
}
|
}
|
||||||
|
|
||||||
class CoreDataTokenProvider: TokenProvider {
|
class KeychainTokenProvider: TokenProvider {
|
||||||
private let settingsRepository = SettingsRepository()
|
|
||||||
private var cachedSettings: Settings?
|
|
||||||
private var isLoaded = false
|
|
||||||
private let keychainHelper = KeychainHelper.shared
|
private let keychainHelper = KeychainHelper.shared
|
||||||
|
|
||||||
private func loadSettingsIfNeeded() async {
|
|
||||||
guard isLoaded == false || cachedSettings == nil else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
cachedSettings = try await settingsRepository.loadSettings()
|
|
||||||
isLoaded = true
|
|
||||||
} catch {
|
|
||||||
print("Failed to load settings: \(error)")
|
|
||||||
cachedSettings = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getToken() async -> String? {
|
func getToken() async -> String? {
|
||||||
await loadSettingsIfNeeded()
|
return keychainHelper.loadToken()
|
||||||
return cachedSettings?.token
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEndpoint() async -> String? {
|
func getEndpoint() async -> String? {
|
||||||
await loadSettingsIfNeeded()
|
return keychainHelper.loadEndpoint()
|
||||||
// Basis-URL ohne /api Suffix, da es in der API-Klasse hinzugefügt wird
|
|
||||||
return cachedSettings?.endpoint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setToken(_ token: String) async {
|
func setToken(_ token: String) async {
|
||||||
await loadSettingsIfNeeded()
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await settingsRepository.saveToken(token)
|
|
||||||
saveTokenToKeychain(token: token)
|
|
||||||
if cachedSettings != nil {
|
|
||||||
cachedSettings!.token = token
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
print("Failed to save token: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearToken() async {
|
|
||||||
do {
|
|
||||||
try await settingsRepository.clearSettings()
|
|
||||||
cachedSettings = nil
|
|
||||||
saveTokenToKeychain(token: "")
|
|
||||||
} catch {
|
|
||||||
print("Failed to clear settings: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Keychain Support
|
|
||||||
|
|
||||||
func saveTokenToKeychain(token: String) {
|
|
||||||
keychainHelper.saveToken(token)
|
keychainHelper.saveToken(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadTokenFromKeychain() -> String? {
|
func clearToken() async {
|
||||||
keychainHelper.loadToken()
|
keychainHelper.clearCredentials()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
readeck/Domain/Model/OfflineBookmarkSyncState.swift
Normal file
62
readeck/Domain/Model/OfflineBookmarkSyncState.swift
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum OfflineBookmarkSyncState: Equatable {
|
||||||
|
case idle
|
||||||
|
case pending(count: Int)
|
||||||
|
case syncing(count: Int, status: String?)
|
||||||
|
case success(syncedCount: Int)
|
||||||
|
case error(String)
|
||||||
|
|
||||||
|
var localBookmarkCount: Int {
|
||||||
|
switch self {
|
||||||
|
case .idle:
|
||||||
|
return 0
|
||||||
|
case .pending(let count):
|
||||||
|
return count
|
||||||
|
case .syncing(let count, _):
|
||||||
|
return count
|
||||||
|
case .success:
|
||||||
|
return 0
|
||||||
|
case .error:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isSyncing: Bool {
|
||||||
|
switch self {
|
||||||
|
case .syncing:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncStatus: String? {
|
||||||
|
switch self {
|
||||||
|
case .syncing(_, let status):
|
||||||
|
return status
|
||||||
|
case .error(let message):
|
||||||
|
return message
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var showSuccessMessage: Bool {
|
||||||
|
switch self {
|
||||||
|
case .success:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncedBookmarkCount: Int {
|
||||||
|
switch self {
|
||||||
|
case .success(let count):
|
||||||
|
return count
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
readeck/Domain/UseCase/OfflineBookmarkSyncUseCase.swift
Normal file
30
readeck/Domain/UseCase/OfflineBookmarkSyncUseCase.swift
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
protocol POfflineBookmarkSyncUseCase {
|
||||||
|
var isSyncing: AnyPublisher<Bool, Never> { get }
|
||||||
|
var syncStatus: AnyPublisher<String?, Never> { get }
|
||||||
|
|
||||||
|
func getOfflineBookmarksCount() -> Int
|
||||||
|
func syncOfflineBookmarks() async
|
||||||
|
}
|
||||||
|
|
||||||
|
class OfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase {
|
||||||
|
private let syncManager = OfflineSyncManager.shared
|
||||||
|
|
||||||
|
var isSyncing: AnyPublisher<Bool, Never> {
|
||||||
|
syncManager.$isSyncing.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncStatus: AnyPublisher<String?, Never> {
|
||||||
|
syncManager.$syncStatus.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOfflineBookmarksCount() -> Int {
|
||||||
|
return syncManager.getOfflineBookmarksCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncOfflineBookmarks() async {
|
||||||
|
await syncManager.syncOfflineBookmarks()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,8 +13,5 @@ class SaveServerSettingsUseCase: PSaveServerSettingsUseCase {
|
|||||||
|
|
||||||
func execute(endpoint: String, username: String, password: String, token: String) async throws {
|
func execute(endpoint: String, username: String, password: String, token: String) async throws {
|
||||||
try await repository.saveServerSettings(endpoint: endpoint, username: username, password: password, token: token)
|
try await repository.saveServerSettings(endpoint: endpoint, username: username, password: password, token: token)
|
||||||
KeychainHelper.shared.saveToken(token)
|
|
||||||
KeychainHelper.shared.saveEndpoint(endpoint)
|
|
||||||
print("token saved", KeychainHelper.shared.loadToken())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol PSaveSettingsUseCase {
|
protocol PSaveSettingsUseCase {
|
||||||
func execute(endpoint: String, username: String, password: String) async throws
|
|
||||||
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws
|
|
||||||
func execute(token: String) async throws
|
|
||||||
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws
|
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws
|
||||||
func execute(enableTTS: Bool) async throws
|
func execute(enableTTS: Bool) async throws
|
||||||
func execute(theme: Theme) async throws
|
func execute(theme: Theme) async throws
|
||||||
@ -16,35 +13,6 @@ class SaveSettingsUseCase: PSaveSettingsUseCase {
|
|||||||
self.settingsRepository = settingsRepository
|
self.settingsRepository = settingsRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func execute(endpoint: String, username: String, password: String) async throws {
|
|
||||||
try await settingsRepository.saveSettings(
|
|
||||||
.init(
|
|
||||||
endpoint: endpoint,
|
|
||||||
username: username,
|
|
||||||
password: password
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws {
|
|
||||||
try await settingsRepository.saveSettings(
|
|
||||||
.init(
|
|
||||||
endpoint: endpoint,
|
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
hasFinishedSetup: hasFinishedSetup
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func execute(token: String) async throws {
|
|
||||||
try await settingsRepository.saveSettings(
|
|
||||||
.init(
|
|
||||||
token: token
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {
|
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {
|
||||||
try await settingsRepository.saveSettings(
|
try await settingsRepository.saveSettings(
|
||||||
.init(
|
.init(
|
||||||
|
|||||||
@ -248,3 +248,15 @@ extension Dictionary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Build Detection
|
||||||
|
|
||||||
|
extension Bundle {
|
||||||
|
var isDebugBuild: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
return true
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct LocalBookmarksSyncView: View {
|
|
||||||
@StateObject private var syncManager = OfflineSyncManager.shared
|
|
||||||
@StateObject private var serverConnectivity = ServerConnectivity.shared
|
|
||||||
@State private var showSuccessMessage = false
|
|
||||||
@State private var syncedBookmarkCount = 0
|
|
||||||
|
|
||||||
let localBookmarkCount: Int
|
|
||||||
|
|
||||||
init(bookmarkCount: Int) {
|
|
||||||
self.localBookmarkCount = bookmarkCount
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
if showSuccessMessage {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(.green)
|
|
||||||
.imageScale(.small)
|
|
||||||
|
|
||||||
Text("\(syncedBookmarkCount) bookmark\(syncedBookmarkCount == 1 ? "" : "s") synced successfully")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.green)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
.cornerRadius(8)
|
|
||||||
.padding(.horizontal)
|
|
||||||
.onAppear {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
|
||||||
withAnimation {
|
|
||||||
showSuccessMessage = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if localBookmarkCount > 0 || syncManager.isSyncing {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: syncManager.isSyncing ? "arrow.triangle.2.circlepath" : "externaldrive.badge.wifi")
|
|
||||||
.foregroundColor(syncManager.isSyncing ? .blue : .blue)
|
|
||||||
.imageScale(.medium)
|
|
||||||
|
|
||||||
if syncManager.isSyncing {
|
|
||||||
Text("Syncing with server...")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
} else {
|
|
||||||
Text("\(localBookmarkCount) bookmark\(localBookmarkCount == 1 ? "" : "s") waiting for sync")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if !syncManager.isSyncing && localBookmarkCount > 0 {
|
|
||||||
Button {
|
|
||||||
syncedBookmarkCount = localBookmarkCount // Store count before sync
|
|
||||||
Task {
|
|
||||||
await syncManager.syncOfflineBookmarks()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "icloud.and.arrow.up")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let status = syncManager.syncStatus {
|
|
||||||
Text(status)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
.cornerRadius(8)
|
|
||||||
.padding(.horizontal)
|
|
||||||
.animation(.easeInOut, value: syncManager.isSyncing)
|
|
||||||
.animation(.easeInOut, value: syncManager.syncStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: syncManager.isSyncing) { _ in
|
|
||||||
if !syncManager.isSyncing {
|
|
||||||
// Show success message if all bookmarks are synced
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
let currentCount = syncManager.getOfflineBookmarksCount()
|
|
||||||
if currentCount == 0 {
|
|
||||||
withAnimation {
|
|
||||||
showSuccessMessage = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,12 +17,13 @@ protocol UseCaseFactory {
|
|||||||
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
|
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
|
||||||
func makeGetLabelsUseCase() -> PGetLabelsUseCase
|
func makeGetLabelsUseCase() -> PGetLabelsUseCase
|
||||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
||||||
|
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class DefaultUseCaseFactory: UseCaseFactory {
|
class DefaultUseCaseFactory: UseCaseFactory {
|
||||||
private let tokenProvider = CoreDataTokenProvider()
|
private let tokenProvider = KeychainTokenProvider()
|
||||||
private lazy var api: PAPI = API(tokenProvider: tokenProvider)
|
private lazy var api: PAPI = API(tokenProvider: tokenProvider)
|
||||||
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
|
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
|
||||||
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
|
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
|
||||||
@ -89,7 +90,7 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeGetLabelsUseCase() -> PGetLabelsUseCase {
|
func makeGetLabelsUseCase() -> PGetLabelsUseCase {
|
||||||
let api = API(tokenProvider: CoreDataTokenProvider())
|
let api = API(tokenProvider: KeychainTokenProvider())
|
||||||
let labelsRepository = LabelsRepository(api: api)
|
let labelsRepository = LabelsRepository(api: api)
|
||||||
return GetLabelsUseCase(labelsRepository: labelsRepository)
|
return GetLabelsUseCase(labelsRepository: labelsRepository)
|
||||||
}
|
}
|
||||||
@ -97,4 +98,8 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase {
|
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase {
|
||||||
return AddTextToSpeechQueueUseCase()
|
return AddTextToSpeechQueueUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase {
|
||||||
|
return OfflineBookmarkSyncUseCase()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,8 +6,13 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
class MockUseCaseFactory: UseCaseFactory {
|
class MockUseCaseFactory: UseCaseFactory {
|
||||||
|
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
|
||||||
|
MockOfflineBookmarkSyncUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
func makeLoginUseCase() -> any PLoginUseCase {
|
func makeLoginUseCase() -> any PLoginUseCase {
|
||||||
MockLoginUserCase()
|
MockLoginUserCase()
|
||||||
}
|
}
|
||||||
@ -181,6 +186,24 @@ class MockAddTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase {
|
|||||||
func execute(bookmarkDetail: BookmarkDetail) {}
|
func execute(bookmarkDetail: BookmarkDetail) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MockOfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase {
|
||||||
|
var isSyncing: AnyPublisher<Bool, Never> {
|
||||||
|
Just(false).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncStatus: AnyPublisher<String?, Never> {
|
||||||
|
Just(nil).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOfflineBookmarksCount() -> Int {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncOfflineBookmarks() async {
|
||||||
|
// Mock implementation - do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Bookmark {
|
extension Bookmark {
|
||||||
static let mock: Bookmark = .init(
|
static let mock: Bookmark = .init(
|
||||||
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
|
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)
|
||||||
|
|||||||
130
readeck/UI/Menu/LocalBookmarksSyncView.swift
Normal file
130
readeck/UI/Menu/LocalBookmarksSyncView.swift
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LocalBookmarksSyncView: View {
|
||||||
|
let state: OfflineBookmarkSyncState
|
||||||
|
let onSyncTapped: () async -> Void
|
||||||
|
|
||||||
|
init(state: OfflineBookmarkSyncState, onSyncTapped: @escaping () async -> Void) {
|
||||||
|
self.state = state
|
||||||
|
self.onSyncTapped = onSyncTapped
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch state {
|
||||||
|
case .idle:
|
||||||
|
EmptyView()
|
||||||
|
|
||||||
|
case .pending(let count):
|
||||||
|
pendingView(count: count)
|
||||||
|
|
||||||
|
case .syncing(let count, let status):
|
||||||
|
syncingView(count: count, status: status)
|
||||||
|
|
||||||
|
case .success(let syncedCount):
|
||||||
|
successView(syncedCount: syncedCount)
|
||||||
|
|
||||||
|
case .error(let message):
|
||||||
|
errorView(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func pendingView(count: Int) -> some View {
|
||||||
|
syncContainerView {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "externaldrive.badge.wifi")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.imageScale(.medium)
|
||||||
|
|
||||||
|
Text("\(count) bookmark\(count == 1 ? "" : "s") waiting for sync")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await onSyncTapped() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "icloud.and.arrow.up")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func syncingView(count: Int, status: String?) -> some View {
|
||||||
|
syncContainerView {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.imageScale(.medium)
|
||||||
|
|
||||||
|
Text("Syncing with server...")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let status = status {
|
||||||
|
HStack {
|
||||||
|
Text(status)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func successView(syncedCount: Int) -> some View {
|
||||||
|
syncContainerView {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
.imageScale(.small)
|
||||||
|
|
||||||
|
Text("\(syncedCount) bookmark\(syncedCount == 1 ? "" : "s") synced successfully")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func errorView(message: String) -> some View {
|
||||||
|
syncContainerView {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.imageScale(.small)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func syncContainerView<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||||
|
content()
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.animation(.easeInOut, value: state)
|
||||||
|
}
|
||||||
|
}
|
||||||
131
readeck/UI/Menu/OfflineBookmarksViewModel.swift
Normal file
131
readeck/UI/Menu/OfflineBookmarksViewModel.swift
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class OfflineBookmarksViewModel {
|
||||||
|
var state: OfflineBookmarkSyncState = .idle
|
||||||
|
|
||||||
|
private let syncUseCase: POfflineBookmarkSyncUseCase
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var successTimer: Timer?
|
||||||
|
|
||||||
|
init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) {
|
||||||
|
self.syncUseCase = syncUseCase
|
||||||
|
setupBindings()
|
||||||
|
updateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupBindings() {
|
||||||
|
// Observe sync state changes
|
||||||
|
syncUseCase.isSyncing
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] isSyncing in
|
||||||
|
self?.handleSyncStateChange(isSyncing: isSyncing)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Observe sync status changes
|
||||||
|
syncUseCase.syncStatus
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] status in
|
||||||
|
self?.handleSyncStatusChange(status: status)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Update count on app lifecycle events
|
||||||
|
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.updateState()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.updateState()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateState() {
|
||||||
|
let count = syncUseCase.getOfflineBookmarksCount()
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case .idle:
|
||||||
|
if count > 0 {
|
||||||
|
state = .pending(count: count)
|
||||||
|
}
|
||||||
|
case .pending:
|
||||||
|
if count > 0 {
|
||||||
|
state = .pending(count: count)
|
||||||
|
} else {
|
||||||
|
state = .idle
|
||||||
|
}
|
||||||
|
case .syncing:
|
||||||
|
// Keep syncing state, will be updated by handleSyncStateChange
|
||||||
|
break
|
||||||
|
case .success:
|
||||||
|
// Success state is temporary, handled by timer
|
||||||
|
break
|
||||||
|
case .error:
|
||||||
|
// Update count even in error state
|
||||||
|
if count > 0 {
|
||||||
|
state = .pending(count: count)
|
||||||
|
} else {
|
||||||
|
state = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncOfflineBookmarks() async {
|
||||||
|
guard case .pending(let count) = state else { return }
|
||||||
|
|
||||||
|
state = .syncing(count: count, status: nil)
|
||||||
|
await syncUseCase.syncOfflineBookmarks()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleSyncStateChange(isSyncing: Bool) {
|
||||||
|
if isSyncing {
|
||||||
|
// If we're not already in syncing state, transition to it
|
||||||
|
if case .pending(let count) = state {
|
||||||
|
state = .syncing(count: count, status: nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Sync completed
|
||||||
|
Task { @MainActor in
|
||||||
|
// Small delay to ensure count is updated
|
||||||
|
try await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
|
||||||
|
let currentCount = syncUseCase.getOfflineBookmarksCount()
|
||||||
|
|
||||||
|
if case .syncing(let originalCount, _) = state {
|
||||||
|
if currentCount == 0 {
|
||||||
|
// Success - all bookmarks synced
|
||||||
|
state = .success(syncedCount: originalCount)
|
||||||
|
|
||||||
|
// Auto-hide success message after 2 seconds
|
||||||
|
successTimer?.invalidate()
|
||||||
|
successTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||||
|
self?.state = .idle
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Some bookmarks remain
|
||||||
|
state = .pending(count: currentCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleSyncStatusChange(status: String?) {
|
||||||
|
if case .syncing(let count, _) = state {
|
||||||
|
state = .syncing(count: count, status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
successTimer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ struct PadSidebarView: View {
|
|||||||
@State private var selectedTag: BookmarkLabel?
|
@State private var selectedTag: BookmarkLabel?
|
||||||
@EnvironmentObject var playerUIState: PlayerUIState
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
@EnvironmentObject var appSettings: AppSettings
|
@EnvironmentObject var appSettings: AppSettings
|
||||||
|
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
|
||||||
|
|
||||||
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
|
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
|
||||||
|
|
||||||
@ -37,6 +38,20 @@ struct PadSidebarView: View {
|
|||||||
.listRowBackground(Color(R.color.menu_sidebar_bg))
|
.listRowBackground(Color(R.color.menu_sidebar_bg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if case .idle = offlineBookmarksViewModel.state {
|
||||||
|
// Don't show anything for idle state
|
||||||
|
} else {
|
||||||
|
Section {
|
||||||
|
VStack {
|
||||||
|
LocalBookmarksSyncView(state: offlineBookmarksViewModel.state) {
|
||||||
|
await offlineBookmarksViewModel.syncOfflineBookmarks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color(R.color.menu_sidebar_bg))
|
.listRowBackground(Color(R.color.menu_sidebar_bg))
|
||||||
.background(Color(R.color.menu_sidebar_bg))
|
.background(Color(R.color.menu_sidebar_bg))
|
||||||
|
|||||||
@ -13,8 +13,7 @@ struct PhoneTabView: View {
|
|||||||
|
|
||||||
@State private var selectedMoreTab: SidebarTab? = nil
|
@State private var selectedMoreTab: SidebarTab? = nil
|
||||||
@State private var selectedTabIndex: Int = 1
|
@State private var selectedTabIndex: Int = 1
|
||||||
@StateObject private var syncManager = OfflineSyncManager.shared
|
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
|
||||||
@State private var phoneTabLocalBookmarkCount = 0
|
|
||||||
|
|
||||||
@EnvironmentObject var appSettings: AppSettings
|
@EnvironmentObject var appSettings: AppSettings
|
||||||
|
|
||||||
@ -25,31 +24,9 @@ struct PhoneTabView: View {
|
|||||||
moreTabContent
|
moreTabContent
|
||||||
}
|
}
|
||||||
.accentColor(.accentColor)
|
.accentColor(.accentColor)
|
||||||
.onAppear {
|
|
||||||
updateLocalBookmarkCount()
|
|
||||||
}
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
|
|
||||||
updateLocalBookmarkCount()
|
|
||||||
}
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
|
||||||
updateLocalBookmarkCount()
|
|
||||||
}
|
|
||||||
.onChange(of: syncManager.isSyncing) {
|
|
||||||
if !syncManager.isSyncing {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
updateLocalBookmarkCount()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateLocalBookmarkCount() {
|
|
||||||
let count = syncManager.getOfflineBookmarksCount()
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.phoneTabLocalBookmarkCount = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Tab Content
|
// MARK: - Tab Content
|
||||||
@ -78,7 +55,7 @@ struct PhoneTabView: View {
|
|||||||
.tabItem {
|
.tabItem {
|
||||||
Label("More", systemImage: "ellipsis")
|
Label("More", systemImage: "ellipsis")
|
||||||
}
|
}
|
||||||
.badge(phoneTabLocalBookmarkCount > 0 ? phoneTabLocalBookmarkCount : 0)
|
.badge(offlineBookmarksViewModel.state.localBookmarkCount > 0 ? offlineBookmarksViewModel.state.localBookmarkCount : 0)
|
||||||
.tag(mainTabs.count)
|
.tag(mainTabs.count)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
||||||
@ -106,10 +83,14 @@ struct PhoneTabView: View {
|
|||||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||||
}
|
}
|
||||||
|
|
||||||
if phoneTabLocalBookmarkCount > 0 {
|
if case .idle = offlineBookmarksViewModel.state {
|
||||||
|
// Don't show anything for idle state
|
||||||
|
} else {
|
||||||
Section {
|
Section {
|
||||||
VStack {
|
VStack {
|
||||||
LocalBookmarksSyncView(bookmarkCount: phoneTabLocalBookmarkCount)
|
LocalBookmarksSyncView(state: offlineBookmarksViewModel.state) {
|
||||||
|
await offlineBookmarksViewModel.syncOfflineBookmarks()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets())
|
||||||
|
|||||||
131
readeck/UI/Settings/LoggingConfigurationView.swift
Normal file
131
readeck/UI/Settings/LoggingConfigurationView.swift
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
//
|
||||||
|
// LoggingConfigurationView.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 16.08.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import os
|
||||||
|
|
||||||
|
struct LoggingConfigurationView: View {
|
||||||
|
@StateObject private var logConfig = LogConfiguration.shared
|
||||||
|
private let logger = Logger.ui
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Global Settings")) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Global Minimum Level")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Picker("Global Level", selection: $logConfig.globalMinLevel) {
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
|
HStack {
|
||||||
|
Text(level.emoji)
|
||||||
|
Text(level.rawValue == 0 ? "Debug" :
|
||||||
|
level.rawValue == 1 ? "Info" :
|
||||||
|
level.rawValue == 2 ? "Notice" :
|
||||||
|
level.rawValue == 3 ? "Warning" :
|
||||||
|
level.rawValue == 4 ? "Error" : "Critical")
|
||||||
|
}
|
||||||
|
.tag(level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(SegmentedPickerStyle())
|
||||||
|
|
||||||
|
Text("Logs below this level will be filtered out globally")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs)
|
||||||
|
Toggle("Show Timestamps", isOn: $logConfig.showTimestamps)
|
||||||
|
Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Category-specific Levels")) {
|
||||||
|
ForEach(LogCategory.allCases, id: \.self) { category in
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text(category.rawValue)
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Text(levelName(for: logConfig.getLevel(for: category)))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Level for \(category.rawValue)", selection: Binding(
|
||||||
|
get: { logConfig.getLevel(for: category) },
|
||||||
|
set: { logConfig.setLevel($0, for: category) }
|
||||||
|
)) {
|
||||||
|
ForEach(LogLevel.allCases, id: \.self) { level in
|
||||||
|
HStack {
|
||||||
|
Text(level.emoji)
|
||||||
|
Text(levelName(for: level))
|
||||||
|
}
|
||||||
|
.tag(level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(SegmentedPickerStyle())
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Reset")) {
|
||||||
|
Button("Reset to Defaults") {
|
||||||
|
resetToDefaults()
|
||||||
|
}
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(footer: Text("Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).")) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Logging Configuration")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
logger.debug("Opened logging configuration view")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "Debug"
|
||||||
|
case 1: return "Info"
|
||||||
|
case 2: return "Notice"
|
||||||
|
case 3: return "Warning"
|
||||||
|
case 4: return "Error"
|
||||||
|
case 5: return "Critical"
|
||||||
|
default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetToDefaults() {
|
||||||
|
logger.info("Resetting logging configuration to defaults")
|
||||||
|
|
||||||
|
// Reset all category levels (this will use globalMinLevel as fallback)
|
||||||
|
for category in LogCategory.allCases {
|
||||||
|
logConfig.setLevel(.debug, for: category)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset global settings
|
||||||
|
logConfig.globalMinLevel = .debug
|
||||||
|
logConfig.showPerformanceLogs = true
|
||||||
|
logConfig.showTimestamps = true
|
||||||
|
logConfig.includeSourceLocation = true
|
||||||
|
|
||||||
|
logger.info("Logging configuration reset to defaults")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
LoggingConfigurationView()
|
||||||
|
}
|
||||||
@ -26,6 +26,11 @@ struct SettingsContainerView: View {
|
|||||||
|
|
||||||
SettingsServerView()
|
SettingsServerView()
|
||||||
.cardStyle()
|
.cardStyle()
|
||||||
|
|
||||||
|
// Debug-only Logging Configuration
|
||||||
|
if Bundle.main.isDebugBuild {
|
||||||
|
debugSettingsSection
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
@ -39,6 +44,52 @@ struct SettingsContainerView: View {
|
|||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var debugSettingsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "ant.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Text("Debug Settings")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Text("DEBUG BUILD")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.orange.opacity(0.2))
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
LoggingConfigurationView()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "doc.text.magnifyingglass")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.frame(width: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Logging Configuration")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("Configure log levels and categories")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cardStyle()
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func AppInfo() -> some View {
|
func AppInfo() -> some View {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
|
|||||||
@ -92,17 +92,6 @@ struct SettingsServerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection Status
|
|
||||||
if viewModel.isLoggedIn {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(.green)
|
|
||||||
Text("Successfully logged in")
|
|
||||||
.foregroundColor(.green)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
if let errorMessage = viewModel.errorMessage {
|
if let errorMessage = viewModel.errorMessage {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="24F74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="24G84" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="ArticleURLEntity" representedClassName="ArticleURLEntity" syncable="YES" codeGenerationType="class">
|
<entity name="ArticleURLEntity" representedClassName="ArticleURLEntity" syncable="YES" codeGenerationType="class">
|
||||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
<attribute name="tags" optional="YES" attributeType="String"/>
|
<attribute name="tags" optional="YES" attributeType="String"/>
|
||||||
@ -52,13 +52,10 @@
|
|||||||
</entity>
|
</entity>
|
||||||
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
|
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
|
||||||
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="enableTTS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="endpoint" optional="YES" attributeType="String"/>
|
|
||||||
<attribute name="fontFamily" optional="YES" attributeType="String"/>
|
<attribute name="fontFamily" optional="YES" attributeType="String"/>
|
||||||
<attribute name="fontSize" optional="YES" attributeType="String"/>
|
<attribute name="fontSize" optional="YES" attributeType="String"/>
|
||||||
<attribute name="password" optional="YES" attributeType="String"/>
|
|
||||||
<attribute name="theme" optional="YES" attributeType="String"/>
|
<attribute name="theme" optional="YES" attributeType="String"/>
|
||||||
<attribute name="token" optional="YES" attributeType="String"/>
|
<attribute name="token" optional="YES" attributeType="String"/>
|
||||||
<attribute name="username" optional="YES" attributeType="String"/>
|
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class">
|
<entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class">
|
||||||
<attribute name="name" optional="YES" attributeType="String"/>
|
<attribute name="name" optional="YES" attributeType="String"/>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user