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" : {
|
||||
|
||||
},
|
||||
"Category-specific Levels" : {
|
||||
|
||||
},
|
||||
"Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." : {
|
||||
|
||||
},
|
||||
"Clear cache" : {
|
||||
|
||||
},
|
||||
"Close" : {
|
||||
|
||||
},
|
||||
"Configure log levels and categories" : {
|
||||
|
||||
},
|
||||
"Critical" : {
|
||||
|
||||
},
|
||||
"Data Management" : {
|
||||
|
||||
},
|
||||
"Debug" : {
|
||||
|
||||
},
|
||||
"DEBUG BUILD" : {
|
||||
|
||||
},
|
||||
"Debug Settings" : {
|
||||
|
||||
},
|
||||
"Delete" : {
|
||||
|
||||
@ -171,30 +192,54 @@
|
||||
},
|
||||
"General" : {
|
||||
|
||||
},
|
||||
"Global Level" : {
|
||||
|
||||
},
|
||||
"Global Minimum Level" : {
|
||||
|
||||
},
|
||||
"Global Settings" : {
|
||||
|
||||
},
|
||||
"https://example.com" : {
|
||||
|
||||
},
|
||||
"https://readeck.example.com" : {
|
||||
|
||||
},
|
||||
"Include Source Location" : {
|
||||
|
||||
},
|
||||
"Info" : {
|
||||
|
||||
},
|
||||
"Jump to last read position (%lld%%)" : {
|
||||
|
||||
},
|
||||
"Key" : {
|
||||
"extractionState" : "manual"
|
||||
},
|
||||
"Level for %@" : {
|
||||
|
||||
},
|
||||
"Loading %@" : {
|
||||
|
||||
},
|
||||
"Loading article..." : {
|
||||
|
||||
},
|
||||
"Logging Configuration" : {
|
||||
|
||||
},
|
||||
"Login & Save" : {
|
||||
|
||||
},
|
||||
"Logout" : {
|
||||
|
||||
},
|
||||
"Logs below this level will be filtered out globally" : {
|
||||
|
||||
},
|
||||
"Manage Labels" : {
|
||||
|
||||
@ -222,6 +267,9 @@
|
||||
},
|
||||
"No results" : {
|
||||
|
||||
},
|
||||
"Notice" : {
|
||||
|
||||
},
|
||||
"OK" : {
|
||||
|
||||
@ -277,9 +325,15 @@
|
||||
},
|
||||
"Remove" : {
|
||||
|
||||
},
|
||||
"Reset" : {
|
||||
|
||||
},
|
||||
"Reset settings" : {
|
||||
|
||||
},
|
||||
"Reset to Defaults" : {
|
||||
|
||||
},
|
||||
"Restore" : {
|
||||
|
||||
@ -329,10 +383,13 @@
|
||||
"Settings" : {
|
||||
|
||||
},
|
||||
"Speed" : {
|
||||
"Show Performance Logs" : {
|
||||
|
||||
},
|
||||
"Successfully logged in" : {
|
||||
"Show Timestamps" : {
|
||||
|
||||
},
|
||||
"Speed" : {
|
||||
|
||||
},
|
||||
"Sync interval" : {
|
||||
@ -361,6 +418,9 @@
|
||||
},
|
||||
"Version %@" : {
|
||||
|
||||
},
|
||||
"Warning" : {
|
||||
|
||||
},
|
||||
"Your current server connection and login credentials." : {
|
||||
|
||||
|
||||
@ -14,6 +14,8 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
@Published var isServerReachable: Bool = true
|
||||
let extensionContext: NSExtensionContext?
|
||||
|
||||
private let logger = Logger.viewModel
|
||||
|
||||
// Computed properties for pagination
|
||||
var availableLabels: [BookmarkLabelDto] {
|
||||
return labels.filter { !selectedLabels.contains($0.name) }
|
||||
@ -43,20 +45,29 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
|
||||
init(extensionContext: NSExtensionContext?) {
|
||||
self.extensionContext = extensionContext
|
||||
logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)")
|
||||
extractSharedContent()
|
||||
}
|
||||
|
||||
func onAppear() {
|
||||
logger.debug("ShareBookmarkViewModel appeared")
|
||||
checkServerReachability()
|
||||
loadLabels()
|
||||
}
|
||||
|
||||
private func checkServerReachability() {
|
||||
let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger)
|
||||
isServerReachable = ServerConnectivity.isServerReachableSync()
|
||||
logger.info("Server reachability checked: \(isServerReachable)")
|
||||
measurement.end()
|
||||
}
|
||||
|
||||
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 {
|
||||
guard let inputItem = item as? NSExtensionItem else { continue }
|
||||
for attachment in inputItem.attachments ?? [] {
|
||||
@ -65,6 +76,9 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
DispatchQueue.main.async {
|
||||
if let url = url as? URL {
|
||||
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 {
|
||||
if let text = text as? String, let url = URL(string: text) {
|
||||
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() {
|
||||
let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger)
|
||||
logger.debug("Starting to load labels")
|
||||
Task {
|
||||
// Check if server is reachable
|
||||
let serverReachable = ServerConnectivity.isServerReachableSync()
|
||||
print("DEBUG: Server reachable: \(serverReachable)")
|
||||
logger.debug("Server reachable for labels: \(serverReachable)")
|
||||
|
||||
if serverReachable {
|
||||
// Load from API
|
||||
@ -96,7 +115,8 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
let sorted = loaded.sorted { $0.count > $1.count }
|
||||
await MainActor.run {
|
||||
self.labels = Array(sorted)
|
||||
print("DEBUG: Loaded \(loaded.count) labels from API")
|
||||
self.logger.info("Loaded \(loaded.count) labels from API")
|
||||
measurement.end()
|
||||
}
|
||||
} else {
|
||||
// Load from local database
|
||||
@ -107,51 +127,83 @@ class ShareBookmarkViewModel: ObservableObject {
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
await MainActor.run {
|
||||
self.labels = localLabels
|
||||
self.logger.info("Loaded \(localLabels.count) labels from local database")
|
||||
measurement.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func save() {
|
||||
logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)")
|
||||
guard let url = url, !url.isEmpty else {
|
||||
logger.warning("Save attempted without valid URL")
|
||||
statusMessage = ("No URL found.", true, "❌")
|
||||
return
|
||||
}
|
||||
isSaving = true
|
||||
logger.debug("Set saving state to true")
|
||||
|
||||
// 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
|
||||
logger.info("Attempting to save bookmark via API")
|
||||
Task {
|
||||
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?.isSaving = false
|
||||
if !error {
|
||||
self?.logger.debug("Bookmark saved successfully, completing extension request")
|
||||
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 {
|
||||
// Server not reachable - save locally
|
||||
logger.info("Server not reachable, attempting local save")
|
||||
let success = OfflineBookmarkManager.shared.saveOfflineBookmark(
|
||||
url: url,
|
||||
title: title,
|
||||
tags: Array(selectedLabels)
|
||||
)
|
||||
logger.info("Local save result: \(success)")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isSaving = false
|
||||
if success {
|
||||
self.logger.info("Bookmark saved locally successfully")
|
||||
self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
self.completeExtensionRequest()
|
||||
}
|
||||
} else {
|
||||
self.logger.error("Failed to save bookmark locally")
|
||||
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
|
||||
|
||||
class SimpleAPI {
|
||||
private static let logger = Logger.network
|
||||
|
||||
// MARK: - API Methods
|
||||
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 {
|
||||
showStatus("No token found. Please log in via the main app.", true)
|
||||
return
|
||||
@ -28,25 +31,35 @@ class SimpleAPI {
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logger.error("Invalid server response for bookmark creation")
|
||||
showStatus("Invalid server response.", true)
|
||||
return
|
||||
}
|
||||
|
||||
logger.logNetworkRequest(method: "POST", url: "/api/bookmarks", statusCode: httpResponse.statusCode)
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
||||
return
|
||||
}
|
||||
|
||||
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
|
||||
logger.info("Bookmark created successfully: \(resp.message)")
|
||||
showStatus("Saved: \(resp.message)", false)
|
||||
} else {
|
||||
logger.info("Bookmark created successfully")
|
||||
showStatus("Bookmark saved!", false)
|
||||
}
|
||||
} catch {
|
||||
logger.logNetworkError(method: "POST", url: "/api/bookmarks", error: error)
|
||||
showStatus("Network error: \(error.localizedDescription)", true)
|
||||
}
|
||||
}
|
||||
|
||||
static func getBookmarkLabels(showStatus: @escaping (String, Bool) -> Void) async -> [BookmarkLabelDto]? {
|
||||
logger.info("Fetching bookmark labels")
|
||||
guard let token = KeychainHelper.shared.loadToken() else {
|
||||
showStatus("No token found. Please log in via the main app.", true)
|
||||
return nil
|
||||
@ -66,17 +79,25 @@ class SimpleAPI {
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logger.error("Invalid server response for labels request")
|
||||
showStatus("Invalid server response.", true)
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.logNetworkRequest(method: "GET", url: "/api/bookmarks/labels", statusCode: httpResponse.statusCode)
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
logger.error("Server error \(httpResponse.statusCode): \(msg)")
|
||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
||||
return nil
|
||||
}
|
||||
|
||||
let labels = try JSONDecoder().decode([BookmarkLabelDto].self, from: data)
|
||||
logger.info("Successfully fetched \(labels.count) bookmark labels")
|
||||
return labels
|
||||
} catch {
|
||||
logger.logNetworkError(method: "GET", url: "/api/bookmarks/labels", error: error)
|
||||
showStatus("Network error: \(error.localizedDescription)", true)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -23,8 +23,9 @@ protocol PAPI {
|
||||
class API: PAPI {
|
||||
let tokenProvider: TokenProvider
|
||||
private var cachedBaseURL: String?
|
||||
private let logger = Logger.network
|
||||
|
||||
init(tokenProvider: TokenProvider = CoreDataTokenProvider()) {
|
||||
init(tokenProvider: TokenProvider = KeychainTokenProvider()) {
|
||||
self.tokenProvider = tokenProvider
|
||||
}
|
||||
|
||||
@ -73,7 +74,6 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
print("Server Error: \(httpResponse.statusCode) - \(String(data: data, encoding: .utf8) ?? "No response body")")
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
@ -114,7 +114,6 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
print("Server Error: \(httpResponse.statusCode) - \(String(data: data, encoding: .utf8) ?? "No response body")")
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
@ -159,7 +158,11 @@ class API: PAPI {
|
||||
}
|
||||
|
||||
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 requestData = try JSONEncoder().encode(loginRequest)
|
||||
@ -168,20 +171,27 @@ class API: PAPI {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = requestData
|
||||
|
||||
logger.logNetworkRequest(method: "POST", url: url.absoluteString)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logger.error("Invalid HTTP response for login request")
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
logger.logNetworkError(method: "POST", url: url.absoluteString, error: 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)
|
||||
}
|
||||
|
||||
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 queryItems: [URLQueryItem] = []
|
||||
|
||||
@ -227,11 +237,16 @@ class API: PAPI {
|
||||
endpoint += "?\(queryString)"
|
||||
}
|
||||
|
||||
logger.logNetworkRequest(method: "GET", url: await self.baseURL + (endpoint.hasPrefix("/api") ? endpoint : "/api\(endpoint)"))
|
||||
|
||||
let (bookmarks, response) = try await makeJSONRequestWithHeaders(
|
||||
endpoint: endpoint,
|
||||
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
|
||||
let currentPage = response.value(forHTTPHeaderField: "Current-Page").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 {
|
||||
return try await makeJSONRequest(
|
||||
endpoint: "/api/bookmarks/\(id)",
|
||||
logger.debug("Fetching bookmark: \(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
|
||||
)
|
||||
|
||||
logger.info("Successfully fetched bookmark: \(id)")
|
||||
return result
|
||||
}
|
||||
|
||||
// Artikel als String laden statt als JSON
|
||||
func getBookmarkArticle(id: String) async throws -> String {
|
||||
return try await makeStringRequest(
|
||||
endpoint: "/api/bookmarks/\(id)/article"
|
||||
logger.debug("Fetching article for bookmark: \(id)")
|
||||
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 {
|
||||
logger.info("Creating bookmark for URL: \(createRequest.url)")
|
||||
let requestData = try JSONEncoder().encode(createRequest)
|
||||
let endpoint = "/api/bookmarks"
|
||||
logger.logNetworkRequest(method: "POST", url: await self.baseURL + endpoint)
|
||||
|
||||
return try await makeJSONRequest(
|
||||
endpoint: "/api/bookmarks",
|
||||
let result = try await makeJSONRequest(
|
||||
endpoint: endpoint,
|
||||
method: .POST,
|
||||
body: requestData,
|
||||
responseType: CreateBookmarkResponseDto.self
|
||||
)
|
||||
|
||||
logger.info("Successfully created bookmark: \(result.status)")
|
||||
return result
|
||||
}
|
||||
|
||||
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws {
|
||||
logger.info("Updating bookmark: \(id)")
|
||||
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 fullEndpoint = "/api/bookmarks/\(id)"
|
||||
|
||||
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
|
||||
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
@ -295,24 +332,32 @@ class API: PAPI {
|
||||
|
||||
request.httpBody = requestData
|
||||
|
||||
logger.logNetworkRequest(method: "PATCH", url: url.absoluteString)
|
||||
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logger.error("Invalid HTTP response for PATCH \(url.absoluteString)")
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
logger.logNetworkRequest(method: "PATCH", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
||||
logger.info("Successfully updated bookmark: \(id)")
|
||||
}
|
||||
|
||||
func deleteBookmark(id: String) async throws {
|
||||
// DELETE Request ohne Response-Body erwarten
|
||||
logger.info("Deleting bookmark: \(id)")
|
||||
|
||||
let baseURL = await self.baseURL
|
||||
let fullEndpoint = "/api/bookmarks/\(id)"
|
||||
|
||||
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
|
||||
logger.error("Invalid URL: \(baseURL)\(fullEndpoint)")
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
@ -324,25 +369,37 @@ class API: PAPI {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString)
|
||||
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logger.error("Invalid HTTP response for DELETE \(url.absoluteString)")
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
||||
logger.info("Successfully deleted bookmark: \(id)")
|
||||
}
|
||||
|
||||
func searchBookmarks(search: String) async throws -> BookmarksPageDto {
|
||||
logger.debug("Searching bookmarks with query: \(search)")
|
||||
let endpoint = "/api/bookmarks?search=\(search.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")"
|
||||
logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint)
|
||||
|
||||
let (bookmarks, response) = try await makeJSONRequestWithHeaders(
|
||||
endpoint: endpoint,
|
||||
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 totalCount = response.value(forHTTPHeaderField: "Total-Count").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] {
|
||||
return try await makeJSONRequest(
|
||||
endpoint: "/api/bookmarks/labels",
|
||||
logger.debug("Fetching bookmark 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
|
||||
)
|
||||
|
||||
logger.info("Successfully fetched \(result.count) bookmark labels")
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,22 +4,43 @@ import Foundation
|
||||
class CoreDataManager {
|
||||
static let shared = CoreDataManager()
|
||||
|
||||
private var isInMemoryStore = false
|
||||
private let logger = Logger.data
|
||||
|
||||
private init() {}
|
||||
|
||||
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
|
||||
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.readeck.app")?.appendingPathComponent("readeck.sqlite")
|
||||
|
||||
if let storeURL = storeURL {
|
||||
// Migrate existing database from app container to app group if needed
|
||||
migrateStoreToAppGroupIfNeeded(targetURL: storeURL)
|
||||
|
||||
let storeDescription = NSPersistentStoreDescription(url: storeURL)
|
||||
container.persistentStoreDescriptions = [storeDescription]
|
||||
}
|
||||
|
||||
container.loadPersistentStores { _, error in
|
||||
container.loadPersistentStores { [weak self] _, error in
|
||||
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
|
||||
@ -33,9 +54,126 @@ class CoreDataManager {
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("Core Data context saved successfully")
|
||||
} 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")
|
||||
}
|
||||
|
||||
@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
|
||||
@discardableResult
|
||||
private func saveString(_ value: String, forKey key: String) -> Bool {
|
||||
|
||||
@ -37,51 +37,49 @@ protocol PSettingsRepository {
|
||||
class SettingsRepository: PSettingsRepository {
|
||||
private let coreDataManager = CoreDataManager.shared
|
||||
private let userDefault = UserDefaults.standard
|
||||
private let keychainHelper = KeychainHelper.shared
|
||||
|
||||
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
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
context.perform {
|
||||
do {
|
||||
// Vorhandene Einstellungen löschen
|
||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||
if let existingSettings = try context.fetch(fetchRequest).first {
|
||||
|
||||
if let endpoint = settings.endpoint, !endpoint.isEmpty {
|
||||
existingSettings.endpoint = endpoint
|
||||
}
|
||||
|
||||
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()
|
||||
let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context)
|
||||
|
||||
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()
|
||||
continuation.resume()
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
@ -100,22 +98,26 @@ class SettingsRepository: PSettingsRepository {
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let settingEntities = try context.fetch(fetchRequest)
|
||||
let settingEntity = settingEntities.first
|
||||
|
||||
if let settingEntity = settingEntities.first {
|
||||
let settings = Settings(
|
||||
endpoint: settingEntity.endpoint ?? "",
|
||||
username: settingEntity.username ?? "",
|
||||
password: settingEntity.password ?? "",
|
||||
token: settingEntity.token,
|
||||
fontFamily: FontFamily(rawValue: settingEntity.fontFamily ?? FontFamily.system.rawValue),
|
||||
fontSize: FontSize(rawValue: settingEntity.fontSize ?? FontSize.medium.rawValue),
|
||||
enableTTS: settingEntity.enableTTS,
|
||||
theme: Theme(rawValue: settingEntity.theme ?? Theme.system.rawValue)
|
||||
)
|
||||
continuation.resume(returning: settings)
|
||||
} else {
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
// Load credentials from keychain only
|
||||
let endpoint = self.keychainHelper.loadEndpoint()
|
||||
let username = self.keychainHelper.loadUsername()
|
||||
let password = self.keychainHelper.loadPassword()
|
||||
let token = self.keychainHelper.loadToken()
|
||||
|
||||
// Load UI preferences from Core Data
|
||||
let settings = Settings(
|
||||
endpoint: endpoint,
|
||||
username: username,
|
||||
password: password,
|
||||
token: token,
|
||||
fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue),
|
||||
fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue),
|
||||
enableTTS: settingEntity?.enableTTS,
|
||||
theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue)
|
||||
)
|
||||
continuation.resume(returning: settings)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
@ -124,6 +126,10 @@ class SettingsRepository: PSettingsRepository {
|
||||
}
|
||||
|
||||
func clearSettings() async throws {
|
||||
// Clear credentials from keychain
|
||||
keychainHelper.clearCredentials()
|
||||
|
||||
// Also clear from Core Data
|
||||
let context = coreDataManager.context
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
@ -146,128 +152,39 @@ class SettingsRepository: PSettingsRepository {
|
||||
}
|
||||
|
||||
func saveToken(_ token: String) async throws {
|
||||
let context = coreDataManager.context
|
||||
// Save to keychain only
|
||||
keychainHelper.saveToken(token)
|
||||
|
||||
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.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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws {
|
||||
let context = coreDataManager.context
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
context.perform {
|
||||
do {
|
||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||
fetchRequest.fetchLimit = 1
|
||||
let settingEntities = try context.fetch(fetchRequest)
|
||||
let settingEntity: SettingEntity
|
||||
if let existing = settingEntities.first {
|
||||
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)
|
||||
}
|
||||
keychainHelper.saveEndpoint(endpoint)
|
||||
keychainHelper.saveUsername(username)
|
||||
keychainHelper.savePassword(password)
|
||||
keychainHelper.saveToken(token)
|
||||
|
||||
if !token.isEmpty {
|
||||
self.hasFinishedSetup = true
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveUsername(_ username: String) async throws {
|
||||
let context = coreDataManager.context
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
keychainHelper.saveUsername(username)
|
||||
}
|
||||
|
||||
func savePassword(_ password: String) async throws {
|
||||
let context = coreDataManager.context
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
keychainHelper.savePassword(password)
|
||||
}
|
||||
|
||||
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws {
|
||||
|
||||
@ -7,66 +7,22 @@ protocol TokenProvider {
|
||||
func clearToken() async
|
||||
}
|
||||
|
||||
class CoreDataTokenProvider: TokenProvider {
|
||||
private let settingsRepository = SettingsRepository()
|
||||
private var cachedSettings: Settings?
|
||||
private var isLoaded = false
|
||||
class KeychainTokenProvider: TokenProvider {
|
||||
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? {
|
||||
await loadSettingsIfNeeded()
|
||||
return cachedSettings?.token
|
||||
return keychainHelper.loadToken()
|
||||
}
|
||||
|
||||
func getEndpoint() async -> String? {
|
||||
await loadSettingsIfNeeded()
|
||||
// Basis-URL ohne /api Suffix, da es in der API-Klasse hinzugefügt wird
|
||||
return cachedSettings?.endpoint
|
||||
return keychainHelper.loadEndpoint()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func loadTokenFromKeychain() -> String? {
|
||||
keychainHelper.loadToken()
|
||||
func clearToken() async {
|
||||
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 {
|
||||
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
|
||||
|
||||
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(enableTTS: Bool) async throws
|
||||
func execute(theme: Theme) async throws
|
||||
@ -16,35 +13,6 @@ class SaveSettingsUseCase: PSaveSettingsUseCase {
|
||||
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 {
|
||||
try await settingsRepository.saveSettings(
|
||||
.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 makeGetLabelsUseCase() -> PGetLabelsUseCase
|
||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
|
||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase
|
||||
}
|
||||
|
||||
|
||||
|
||||
class DefaultUseCaseFactory: UseCaseFactory {
|
||||
private let tokenProvider = CoreDataTokenProvider()
|
||||
private let tokenProvider = KeychainTokenProvider()
|
||||
private lazy var api: PAPI = API(tokenProvider: tokenProvider)
|
||||
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
|
||||
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
|
||||
@ -89,7 +90,7 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
||||
}
|
||||
|
||||
func makeGetLabelsUseCase() -> PGetLabelsUseCase {
|
||||
let api = API(tokenProvider: CoreDataTokenProvider())
|
||||
let api = API(tokenProvider: KeychainTokenProvider())
|
||||
let labelsRepository = LabelsRepository(api: api)
|
||||
return GetLabelsUseCase(labelsRepository: labelsRepository)
|
||||
}
|
||||
@ -97,4 +98,8 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
||||
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase {
|
||||
return AddTextToSpeechQueueUseCase()
|
||||
}
|
||||
|
||||
func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase {
|
||||
return OfflineBookmarkSyncUseCase()
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,8 +6,13 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
class MockUseCaseFactory: UseCaseFactory {
|
||||
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
|
||||
MockOfflineBookmarkSyncUseCase()
|
||||
}
|
||||
|
||||
func makeLoginUseCase() -> any PLoginUseCase {
|
||||
MockLoginUserCase()
|
||||
}
|
||||
@ -181,6 +186,24 @@ class MockAddTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase {
|
||||
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 {
|
||||
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)
|
||||
|
||||
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?
|
||||
@EnvironmentObject var playerUIState: PlayerUIState
|
||||
@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]
|
||||
|
||||
@ -37,6 +38,20 @@ struct PadSidebarView: View {
|
||||
.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))
|
||||
.background(Color(R.color.menu_sidebar_bg))
|
||||
|
||||
@ -13,8 +13,7 @@ struct PhoneTabView: View {
|
||||
|
||||
@State private var selectedMoreTab: SidebarTab? = nil
|
||||
@State private var selectedTabIndex: Int = 1
|
||||
@StateObject private var syncManager = OfflineSyncManager.shared
|
||||
@State private var phoneTabLocalBookmarkCount = 0
|
||||
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase())
|
||||
|
||||
@EnvironmentObject var appSettings: AppSettings
|
||||
|
||||
@ -25,31 +24,9 @@ struct PhoneTabView: View {
|
||||
moreTabContent
|
||||
}
|
||||
.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
|
||||
@ -78,7 +55,7 @@ struct PhoneTabView: View {
|
||||
.tabItem {
|
||||
Label("More", systemImage: "ellipsis")
|
||||
}
|
||||
.badge(phoneTabLocalBookmarkCount > 0 ? phoneTabLocalBookmarkCount : 0)
|
||||
.badge(offlineBookmarksViewModel.state.localBookmarkCount > 0 ? offlineBookmarksViewModel.state.localBookmarkCount : 0)
|
||||
.tag(mainTabs.count)
|
||||
.onAppear {
|
||||
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
||||
@ -106,10 +83,14 @@ struct PhoneTabView: View {
|
||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||
}
|
||||
|
||||
if phoneTabLocalBookmarkCount > 0 {
|
||||
if case .idle = offlineBookmarksViewModel.state {
|
||||
// Don't show anything for idle state
|
||||
} else {
|
||||
Section {
|
||||
VStack {
|
||||
LocalBookmarksSyncView(bookmarkCount: phoneTabLocalBookmarkCount)
|
||||
LocalBookmarksSyncView(state: offlineBookmarksViewModel.state) {
|
||||
await offlineBookmarksViewModel.syncOfflineBookmarks()
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.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()
|
||||
.cardStyle()
|
||||
|
||||
// Debug-only Logging Configuration
|
||||
if Bundle.main.isDebugBuild {
|
||||
debugSettingsSection
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGroupedBackground))
|
||||
@ -39,6 +44,52 @@ struct SettingsContainerView: View {
|
||||
.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
|
||||
func AppInfo() -> some View {
|
||||
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
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
HStack {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<?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">
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="tags" optional="YES" attributeType="String"/>
|
||||
@ -52,13 +52,10 @@
|
||||
</entity>
|
||||
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
|
||||
<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="fontSize" optional="YES" attributeType="String"/>
|
||||
<attribute name="password" optional="YES" attributeType="String"/>
|
||||
<attribute name="theme" optional="YES" attributeType="String"/>
|
||||
<attribute name="token" optional="YES" attributeType="String"/>
|
||||
<attribute name="username" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<entity name="TagEntity" representedClassName="TagEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user