diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 0591b93..b05065f 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -25,9 +25,32 @@ }, "%lld articles in the queue" : { + }, + "%lld bookmark%@ synced successfully" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld bookmark%2$@ synced successfully" + } + } + } + }, + "%lld bookmark%@ waiting for sync" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld bookmark%2$@ waiting for sync" + } + } + } }, "%lld min" : { + }, + "%lld minutes" : { + }, "%lld." : { @@ -79,15 +102,27 @@ }, "Are you sure you want to log out? This will delete all your login credentials and return you to setup." : { + }, + "Automatic sync" : { + + }, + "Automatically mark articles as read" : { + }, "Available tags" : { }, "Cancel" : { + }, + "Clear cache" : { + }, "Close" : { + }, + "Data Management" : { + }, "Delete" : { @@ -190,6 +225,9 @@ }, "OK" : { + }, + "Open external links in in-app Safari" : { + }, "Optional: Custom title" : { @@ -233,15 +271,24 @@ } } } + }, + "Reading Settings" : { + }, "Remove" : { + }, + "Reset settings" : { + }, "Restore" : { }, "Resume listening" : { + }, + "Safari Reader Mode" : { + }, "Save bookmark" : { @@ -275,6 +322,9 @@ }, "Server Endpoint" : { + }, + "Server not reachable - saving locally" : { + }, "Settings" : { @@ -284,6 +334,15 @@ }, "Successfully logged in" : { + }, + "Sync interval" : { + + }, + "Sync Settings" : { + + }, + "Syncing with server..." : { + }, "Theme" : { diff --git a/URLShare/OfflineBookmarkManager.swift b/URLShare/OfflineBookmarkManager.swift new file mode 100644 index 0000000..c7a7dce --- /dev/null +++ b/URLShare/OfflineBookmarkManager.swift @@ -0,0 +1,60 @@ +import Foundation +import CoreData + +class OfflineBookmarkManager { + static let shared = OfflineBookmarkManager() + + private init() {} + + // MARK: - Core Data Stack for Share Extension + + var context: NSManagedObjectContext { + return CoreDataManager.shared.context + } + + // MARK: - Offline Storage Methods + + func saveOfflineBookmark(url: String, title: String = "", tags: [String] = []) -> Bool { + let tagsString = tags.joined(separator: ",") + + // Check if URL already exists offline + let fetchRequest: NSFetchRequest = ArticleURLEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "url == %@", url) + + do { + let existingEntities = try context.fetch(fetchRequest) + if let existingEntity = existingEntities.first { + // Update existing entry + existingEntity.tags = tagsString + existingEntity.title = title + } else { + // Create new entry + let entity = ArticleURLEntity(context: context) + entity.id = UUID() + entity.url = url + entity.title = title + entity.tags = tagsString + } + + try context.save() + print("Bookmark saved offline: \(url)") + return true + } catch { + print("Failed to save offline bookmark: \(error)") + return false + } + } + + func getTags() -> [String] { + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + + do { + let tagEntities = try context.fetch(fetchRequest) + return tagEntities.compactMap { $0.name }.sorted() + } catch { + print("Failed to fetch tags: \(error)") + return [] + } + } + +} diff --git a/URLShare/ServerConnectivity.swift b/URLShare/ServerConnectivity.swift new file mode 100644 index 0000000..dd104f0 --- /dev/null +++ b/URLShare/ServerConnectivity.swift @@ -0,0 +1,62 @@ +import Foundation +import Network + +class ServerConnectivity: ObservableObject { + @Published var isServerReachable = false + + static let shared = ServerConnectivity() + + private init() {} + + // Check if the Readeck server endpoint is reachable + static func isServerReachable() async -> Bool { + guard let endpoint = KeychainHelper.shared.loadEndpoint(), + !endpoint.isEmpty, + let url = URL(string: endpoint + "/api/health") else { + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 5.0 // 5 second timeout + + do { + let (_, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse { + return httpResponse.statusCode == 200 + } + } catch { + print("Server connectivity check failed: \(error)") + } + + return false + } + + // Alternative check using ping-style endpoint + static func isServerReachableSync() -> Bool { + guard let endpoint = KeychainHelper.shared.loadEndpoint(), + !endpoint.isEmpty, + let url = URL(string: endpoint) else { + return false + } + + let semaphore = DispatchSemaphore(value: 0) + var isReachable = false + + var request = URLRequest(url: url) + request.httpMethod = "HEAD" // Just check if server responds + request.timeoutInterval = 3.0 + + let task = URLSession.shared.dataTask(with: request) { _, response, error in + if let httpResponse = response as? HTTPURLResponse { + isReachable = httpResponse.statusCode < 500 // Accept any response that's not server error + } + semaphore.signal() + } + + task.resume() + _ = semaphore.wait(timeout: .now() + 3.0) + + return isReachable + } +} \ No newline at end of file diff --git a/URLShare/ShareBookmarkView.swift b/URLShare/ShareBookmarkView.swift index fac1a38..8a712cc 100644 --- a/URLShare/ShareBookmarkView.swift +++ b/URLShare/ShareBookmarkView.swift @@ -15,6 +15,7 @@ struct ShareBookmarkView: View { ScrollView { VStack(spacing: 0) { logoSection + serverStatusSection urlSection tagManagementSection .id(AddBookmarkFieldFocus.labels) @@ -70,6 +71,26 @@ struct ShareBookmarkView: View { .opacity(0.9) } + @ViewBuilder + private var serverStatusSection: some View { + if !viewModel.isServerReachable { + HStack(spacing: 8) { + Image(systemName: "wifi.exclamationmark") + .foregroundColor(.orange) + Text("Server not reachable - saving locally") + .font(.caption) + .foregroundColor(.orange) + } + .padding(.top, 8) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.1)) + .cornerRadius(8) + .padding(.horizontal, 16) + .padding(.top, 8) + } + } + @ViewBuilder private var urlSection: some View { if let url = viewModel.url { @@ -113,7 +134,7 @@ struct ShareBookmarkView: View { @ViewBuilder private var tagManagementSection: some View { - if !viewModel.labels.isEmpty { + if !viewModel.labels.isEmpty || !viewModel.isServerReachable { TagManagementView( allLabels: convertToBookmarkLabels(viewModel.labels), selectedLabels: viewModel.selectedLabels, diff --git a/URLShare/ShareBookmarkViewModel.swift b/URLShare/ShareBookmarkViewModel.swift index 9df436c..4e0e113 100644 --- a/URLShare/ShareBookmarkViewModel.swift +++ b/URLShare/ShareBookmarkViewModel.swift @@ -1,6 +1,7 @@ import Foundation import SwiftUI import UniformTypeIdentifiers +import CoreData class ShareBookmarkViewModel: ObservableObject { @Published var url: String? @@ -10,6 +11,7 @@ class ShareBookmarkViewModel: ObservableObject { @Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil @Published var isSaving: Bool = false @Published var searchText: String = "" + @Published var isServerReachable: Bool = true let extensionContext: NSExtensionContext? // Computed properties for pagination @@ -27,7 +29,7 @@ class ShareBookmarkViewModel: ObservableObject { } var availableLabelPages: [[BookmarkLabelDto]] { - let pageSize = Constants.Labels.pageSize + let pageSize = 12 // Extension can't access Constants.Labels.pageSize let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels if labelsToShow.count <= pageSize { @@ -45,9 +47,14 @@ class ShareBookmarkViewModel: ObservableObject { } func onAppear() { + checkServerReachability() loadLabels() } + private func checkServerReachability() { + isServerReachable = ServerConnectivity.isServerReachableSync() + } + private func extractSharedContent() { guard let extensionContext = extensionContext else { return } for item in extensionContext.inputItems { @@ -77,12 +84,30 @@ class ShareBookmarkViewModel: ObservableObject { func loadLabels() { Task { - let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in - self?.statusMessage = (message, error, error ? "❌" : "✅") - } ?? [] - let sorted = loaded.sorted { $0.count > $1.count } - await MainActor.run { - self.labels = Array(sorted) + // Check if server is reachable + let serverReachable = ServerConnectivity.isServerReachableSync() + print("DEBUG: Server reachable: \(serverReachable)") + + if serverReachable { + // Load from API + let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in + self?.statusMessage = (message, error, error ? "❌" : "✅") + } ?? [] + let sorted = loaded.sorted { $0.count > $1.count } + await MainActor.run { + self.labels = Array(sorted) + print("DEBUG: Loaded \(loaded.count) labels from API") + } + } else { + // Load from local database + let localTags = OfflineBookmarkManager.shared.getTags() + let localLabels = localTags.enumerated().map { index, tagName in + BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)") + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + await MainActor.run { + self.labels = localLabels + } } } } @@ -93,16 +118,40 @@ class ShareBookmarkViewModel: ObservableObject { return } isSaving = true - Task { - await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in - self?.statusMessage = (message, error, error ? "❌" : "✅") - self?.isSaving = false - if !error { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + + // Check server connectivity + if ServerConnectivity.isServerReachableSync() { + // Online - try to save via API + Task { + await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in + self?.statusMessage = (message, error, error ? "❌" : "✅") + self?.isSaving = false + if !error { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } } } } + } else { + // Server not reachable - save locally + let success = OfflineBookmarkManager.shared.saveOfflineBookmark( + url: url, + title: title, + tags: Array(selectedLabels) + ) + + DispatchQueue.main.async { + self.isSaving = false + if success { + 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) + } + } else { + self.statusMessage = ("Failed to save locally.", true, "❌") + } + } } } } diff --git a/URLShare/URLShare.entitlements b/URLShare/URLShare.entitlements index 1e5156a..9c01cc7 100644 --- a/URLShare/URLShare.entitlements +++ b/URLShare/URLShare.entitlements @@ -2,6 +2,10 @@ + com.apple.security.application-groups + + group.readeck.app + keychain-access-groups $(AppIdentifierPrefix)de.ilyashallak.readeck diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index f96a4c5..e8aeaed 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ Data/KeychainHelper.swift, Domain/Model/Bookmark.swift, Domain/Model/BookmarkLabel.swift, + Logger.swift, readeck.xcdatamodeld, Splash.storyboard, UI/Components/Constants.swift, @@ -435,7 +436,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -468,7 +469,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -623,7 +624,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; @@ -667,7 +668,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; diff --git a/readeck/Data/CoreData/CoreDataManager.swift b/readeck/Data/CoreData/CoreDataManager.swift index ff566fe..e9b991c 100644 --- a/readeck/Data/CoreData/CoreDataManager.swift +++ b/readeck/Data/CoreData/CoreDataManager.swift @@ -8,6 +8,15 @@ class CoreDataManager { lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "readeck") + + // 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 { + let storeDescription = NSPersistentStoreDescription(url: storeURL) + container.persistentStoreDescriptions = [storeDescription] + } + container.loadPersistentStores { _, error in if let error = error { fatalError("Core Data error: \(error)") diff --git a/readeck/Data/Mappers/BookmarkEntityMapper.swift b/readeck/Data/Mappers/BookmarkEntityMapper.swift new file mode 100644 index 0000000..a13d681 --- /dev/null +++ b/readeck/Data/Mappers/BookmarkEntityMapper.swift @@ -0,0 +1,213 @@ +import Foundation +import CoreData + + +// MARK: - DTO -> Entity + +extension BookmarkDto { + func toEntity(context: NSManagedObjectContext) -> BookmarkEntity { + let entity = BookmarkEntity(context: context) + entity.title = self.title + entity.url = self.url + entity.authors = self.authors.first + entity.desc = self.description + entity.created = self.created + + entity.siteName = self.siteName + entity.site = self.site + entity.authors = self.authors.first // TODO: support multiple authors + entity.published = self.published + entity.created = self.created + entity.update = self.updated + entity.readingTime = Int16(self.readingTime ?? 0) + entity.readProgress = Int16(self.readProgress) + entity.wordCount = Int64(self.wordCount ?? 0) + entity.isArchived = self.isArchived + entity.isMarked = self.isMarked + entity.hasArticle = self.hasArticle + entity.loaded = self.loaded + entity.hasDeleted = self.isDeleted + entity.documentType = self.documentType + entity.href = self.href + entity.lang = self.lang + entity.textDirection = self.textDirection + entity.type = self.type + entity.state = Int16(self.state) + + // entity.resources = self.resources.toEntity(context: context) + + return entity + } +} + +extension BookmarkResourcesDto { + func toEntity(context: NSManagedObjectContext) -> BookmarkResourcesEntity { + let entity = BookmarkResourcesEntity(context: context) + + entity.article = self.article?.toEntity(context: context) + entity.icon = self.icon?.toEntity(context: context) + entity.image = self.image?.toEntity(context: context) + entity.log = self.log?.toEntity(context: context) + entity.props = self.props?.toEntity(context: context) + entity.thumbnail = self.thumbnail?.toEntity(context: context) + + return entity + } +} + +extension ImageResourceDto { + func toEntity(context: NSManagedObjectContext) -> ImageResourceEntity { + let entity = ImageResourceEntity(context: context) + entity.src = self.src + entity.width = Int64(self.width) + entity.height = Int64(self.height) + return entity + } +} + +extension ResourceDto { + func toEntity(context: NSManagedObjectContext) -> ResourceEntity { + let entity = ResourceEntity(context: context) + entity.src = self.src + return entity + } +} + +// ------------------------------------------------ + +// MARK: - BookmarkEntity to Domain Mapping +extension BookmarkEntity { + +} + +// MARK: - Domain to BookmarkEntity Mapping +extension Bookmark { + func toEntity(context: NSManagedObjectContext) -> BookmarkEntity { + let entity = BookmarkEntity(context: context) + entity.populateFrom(bookmark: self) + return entity + } + + func updateEntity(_ entity: BookmarkEntity) { + entity.populateFrom(bookmark: self) + } +} + +extension Resource { + func toEntity(context: NSManagedObjectContext) -> ResourceEntity { + let entity = ResourceEntity(context: context) + entity.populateFrom(resource: self) + return entity + } +} + +// MARK: - Private Helper Methods +private extension BookmarkEntity { + func populateFrom(bookmark: Bookmark) { + self.id = bookmark.id + self.title = bookmark.title + self.url = bookmark.url + self.desc = bookmark.description + self.siteName = bookmark.siteName + self.site = bookmark.site + self.authors = bookmark.authors.first // TODO: support multiple authors + self.published = bookmark.published + self.created = bookmark.created + self.update = bookmark.updated + self.readingTime = Int16(bookmark.readingTime ?? 0) + self.readProgress = Int16(bookmark.readProgress) + self.wordCount = Int64(bookmark.wordCount ?? 0) + self.isArchived = bookmark.isArchived + self.isMarked = bookmark.isMarked + self.hasArticle = bookmark.hasArticle + self.loaded = bookmark.loaded + self.hasDeleted = bookmark.isDeleted + self.documentType = bookmark.documentType + self.href = bookmark.href + self.lang = bookmark.lang + self.textDirection = bookmark.textDirection + self.type = bookmark.type + self.state = Int16(bookmark.state) + } +} + +// MARK: - BookmarkState Mapping +private extension BookmarkState { + static func fromRawValue(_ value: Int) -> BookmarkState { + switch value { + case 0: return .unread + case 1: return .favorite + case 2: return .archived + default: return .unread + } + } +} + +private extension BookmarkResourcesEntity { + func populateFrom(bookmarkResources: BookmarkResources) { + + } +} + +private extension ImageResourceEntity { + func populateFrom(imageResource: ImageResource) { + self.src = imageResource.src + self.height = Int64(imageResource.height) + self.width = Int64(imageResource.width) + } +} + +private extension ResourceEntity { + func populateFrom(resource: Resource) { + self.src = resource.src + } +} + +// MARK: - Date Conversion Helpers +private extension String { + func toDate() -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: self) ?? + ISO8601DateFormatter().date(from: self) + } +} + +private extension Date { + func toISOString() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: self) + } +} + +// MARK: - Array Mapping Extensions +extension Array where Element == BookmarkEntity { + func toDomain() -> [Bookmark] { + return [] // self.map { $0.toDomain() } + } +} + +extension Array where Element == Bookmark { + func toEntities(context: NSManagedObjectContext) -> [BookmarkEntity] { + return self.map { $0.toEntity(context: context) } + } +} +/* +extension BookmarkEntity { + func toDomain() -> Bookmark { + return Bookmark(id: id ?? "", title: title ?? "", url: url!, href: href ?? "", description: description, authors: [authors ?? ""], created: created ?? "", published: published, updated: update!, siteName: siteName ?? "", site: site!, readingTime: Int(readingTime), wordCount: Int(wordCount), hasArticle: hasArticle, isArchived: isArchived, isDeleted: isDeleted, isMarked: isMarked, labels: [], lang: lang, loaded: loaded, readProgress: Int(readProgress), documentType: documentType ?? "", state: Int(state), textDirection: textDirection ?? "", type: type ?? "", resources: resources.toDomain()) + ) + } +} + +extension BookmarkResourcesEntity { + func toDomain() -> BookmarkResources { + return BookmarkResources(article: ar, icon: <#T##ImageResource?#>, image: <#T##ImageResource?#>, log: <#T##Resource?#>, props: <#T##Resource?#>, thumbnail: <#T##ImageResource?#> + } +} + +extension ImageResourceEntity { + +} +*/ diff --git a/readeck/Data/Mappers/TagEntityMapper.swift b/readeck/Data/Mappers/TagEntityMapper.swift new file mode 100644 index 0000000..0e1ea77 --- /dev/null +++ b/readeck/Data/Mappers/TagEntityMapper.swift @@ -0,0 +1,19 @@ +// +// TagEntityMapper.swift +// readeck +// +// Created by Ilyas Hallak on 11.08.25. +// + +import Foundation +import CoreData + +extension BookmarkLabelDto { + + @discardableResult + func toEntity(context: NSManagedObjectContext) -> TagEntity { + let entity = TagEntity(context: context) + entity.name = name + return entity + } +} diff --git a/readeck/Data/Repository/LabelsRepository.swift b/readeck/Data/Repository/LabelsRepository.swift index cb39dad..94a3d22 100644 --- a/readeck/Data/Repository/LabelsRepository.swift +++ b/readeck/Data/Repository/LabelsRepository.swift @@ -1,14 +1,34 @@ import Foundation +import CoreData class LabelsRepository: PLabelsRepository { private let api: PAPI + private let coreDataManager = CoreDataManager.shared + init(api: PAPI) { self.api = api } func getLabels() async throws -> [BookmarkLabel] { let dtos = try await api.getBookmarkLabels() + try? await saveLabels(dtos) return dtos.map { $0.toDomain() } } + + func saveLabels(_ dtos: [BookmarkLabelDto]) async throws { + for dto in dtos { + if !tagExists(name: dto.name) { + dto.toEntity(context: coreDataManager.context) + } + } + try coreDataManager.context.save() + } + + private func tagExists(name: String) -> Bool { + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "name == %@", name) + + return (try? coreDataManager.context.fetch(fetchRequest).isEmpty == false) ?? false + } } diff --git a/readeck/Data/Repository/OfflineSyncManager.swift b/readeck/Data/Repository/OfflineSyncManager.swift new file mode 100644 index 0000000..0ca8d93 --- /dev/null +++ b/readeck/Data/Repository/OfflineSyncManager.swift @@ -0,0 +1,135 @@ +import Foundation +import CoreData +import SwiftUI + +class OfflineSyncManager: ObservableObject { + static let shared = OfflineSyncManager() + + @Published var isSyncing = false + @Published var syncStatus: String? + + private let coreDataManager = CoreDataManager.shared + private let api: PAPI + + init(api: PAPI = API()) { + self.api = api + } + + // MARK: - Sync Methods + + func syncOfflineBookmarks() async { + // First check if server is reachable + guard await ServerConnectivity.isServerReachable() else { + await MainActor.run { + isSyncing = false + syncStatus = "Server not reachable. Cannot sync." + } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.syncStatus = nil + } + return + } + + await MainActor.run { + isSyncing = true + syncStatus = "Syncing bookmarks with server..." + } + + let offlineBookmarks = getOfflineBookmarks() + + guard !offlineBookmarks.isEmpty else { + await MainActor.run { + isSyncing = false + syncStatus = "No bookmarks to sync" + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.syncStatus = nil + } + return + } + + var successCount = 0 + var failedCount = 0 + + for bookmark in offlineBookmarks { + guard let url = bookmark.url else { + failedCount += 1 + continue + } + + let tags = bookmark.tags?.components(separatedBy: ",").filter { !$0.isEmpty } ?? [] + let title = bookmark.title ?? "" + + do { + // Try to upload via API + let dto = CreateBookmarkRequestDto(url: url, title: title, labels: tags.isEmpty ? nil : tags) + _ = try await api.createBookmark(createRequest: dto) + + // If successful, delete from offline storage + deleteOfflineBookmark(bookmark) + successCount += 1 + + await MainActor.run { + syncStatus = "Synced \(successCount) bookmarks..." + } + + } catch { + print("Failed to sync bookmark: \(url) - \(error)") + failedCount += 1 + } + } + + await MainActor.run { + isSyncing = false + if failedCount == 0 { + syncStatus = "✅ Successfully synced \(successCount) bookmarks" + } else { + syncStatus = "⚠️ Synced \(successCount), failed \(failedCount) bookmarks" + } + } + + // Clear status after a few seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.syncStatus = nil + } + } + + func getOfflineBookmarksCount() -> Int { + return getOfflineBookmarks().count + } + + private func getOfflineBookmarks() -> [ArticleURLEntity] { + let fetchRequest: NSFetchRequest = ArticleURLEntity.fetchRequest() + + do { + return try coreDataManager.context.fetch(fetchRequest) + } catch { + print("Failed to fetch offline bookmarks: \(error)") + return [] + } + } + + private func deleteOfflineBookmark(_ entity: ArticleURLEntity) { + coreDataManager.context.delete(entity) + coreDataManager.save() + } + + // MARK: - Auto Sync on Server Connectivity Changes + + func startAutoSync() { + // Monitor server connectivity and auto-sync when server becomes reachable + NotificationCenter.default.addObserver( + forName: NSNotification.Name("ServerDidBecomeAvailable"), + object: nil, + queue: .main + ) { [weak self] _ in + Task { + await self?.syncOfflineBookmarks() + } + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} diff --git a/readeck/Data/Utils/NetworkConnectivity.swift b/readeck/Data/Utils/NetworkConnectivity.swift new file mode 100644 index 0000000..b330cb7 --- /dev/null +++ b/readeck/Data/Utils/NetworkConnectivity.swift @@ -0,0 +1,92 @@ +import Foundation +import Network + +class ServerConnectivity: ObservableObject { + private let monitor = NWPathMonitor() + private let queue = DispatchQueue.global(qos: .background) + + @Published var isServerReachable = false + + static let shared = ServerConnectivity() + + private init() { + startMonitoring() + } + + private func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + if path.status == .satisfied { + // Network is available, now check server + Task { + let serverReachable = await ServerConnectivity.isServerReachable() + DispatchQueue.main.async { + let wasReachable = self?.isServerReachable ?? false + self?.isServerReachable = serverReachable + + // Notify when server becomes available + if !wasReachable && serverReachable { + NotificationCenter.default.post(name: NSNotification.Name("ServerDidBecomeAvailable"), object: nil) + } + } + } + } else { + DispatchQueue.main.async { + self?.isServerReachable = false + } + } + } + monitor.start(queue: queue) + } + + deinit { + monitor.cancel() + } + + // Check if the Readeck server endpoint is reachable + static func isServerReachable() async -> Bool { + guard let endpoint = KeychainHelper.shared.loadEndpoint(), + !endpoint.isEmpty, + let url = URL(string: endpoint + "/api/health") else { + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 5.0 // 5 second timeout + + do { + let (_, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse { + return httpResponse.statusCode == 200 + } + } catch { + // Fallback: try basic endpoint if health endpoint doesn't exist + return await isBasicEndpointReachable() + } + + return false + } + + private static func isBasicEndpointReachable() async -> Bool { + guard let endpoint = KeychainHelper.shared.loadEndpoint(), + !endpoint.isEmpty, + let url = URL(string: endpoint) else { + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "HEAD" + request.timeoutInterval = 3.0 + + do { + let (_, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse { + return httpResponse.statusCode < 500 + } + } catch { + print("Server connectivity check failed: \(error)") + } + + return false + } +} diff --git a/readeck/Domain/Protocols/PLabelsRepository.swift b/readeck/Domain/Protocols/PLabelsRepository.swift index e172af9..4ad6cf6 100644 --- a/readeck/Domain/Protocols/PLabelsRepository.swift +++ b/readeck/Domain/Protocols/PLabelsRepository.swift @@ -2,4 +2,5 @@ import Foundation protocol PLabelsRepository { func getLabels() async throws -> [BookmarkLabel] -} + func saveLabels(_ dtos: [BookmarkLabelDto]) async throws +} diff --git a/readeck/Logger.swift b/readeck/Logger.swift new file mode 100644 index 0000000..bf0e08d --- /dev/null +++ b/readeck/Logger.swift @@ -0,0 +1,250 @@ +// +// Logger.swift +// readeck +// +// Created by Ilyas Hallak on 16.08.25. +// + +import Foundation +import os + +// MARK: - Log Configuration + +enum LogLevel: Int, CaseIterable { + case debug = 0 + case info = 1 + case notice = 2 + case warning = 3 + case error = 4 + case critical = 5 + + var emoji: String { + switch self { + case .debug: return "🔍" + case .info: return "ℹ️" + case .notice: return "📢" + case .warning: return "⚠️" + case .error: return "❌" + case .critical: return "💥" + } + } +} + +enum LogCategory: String, CaseIterable { + case network = "Network" + case ui = "UI" + case data = "Data" + case auth = "Authentication" + case performance = "Performance" + case general = "General" + case manual = "Manual" + case viewModel = "ViewModel" +} + +class LogConfiguration: ObservableObject { + static let shared = LogConfiguration() + + @Published private var categoryLevels: [LogCategory: LogLevel] = [:] + @Published var globalMinLevel: LogLevel = .debug + @Published var showPerformanceLogs = true + @Published var showTimestamps = true + @Published var includeSourceLocation = true + + private init() { + loadConfiguration() + } + + func setLevel(_ level: LogLevel, for category: LogCategory) { + categoryLevels[category] = level + saveConfiguration() + } + + func getLevel(for category: LogCategory) -> LogLevel { + return categoryLevels[category] ?? globalMinLevel + } + + func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool { + let categoryLevel = getLevel(for: category) + return level.rawValue >= categoryLevel.rawValue + } + + private func loadConfiguration() { + // Load from UserDefaults + if let data = UserDefaults.standard.data(forKey: "LogConfiguration"), + let config = try? JSONDecoder().decode([String: Int].self, from: data) { + for (categoryString, levelInt) in config { + if let category = LogCategory(rawValue: categoryString), + let level = LogLevel(rawValue: levelInt) { + categoryLevels[category] = level + } + } + } + + globalMinLevel = LogLevel(rawValue: UserDefaults.standard.integer(forKey: "LogGlobalLevel")) ?? .debug + showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance") + showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps") + includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation") + } + + private func saveConfiguration() { + let config = categoryLevels.mapKeys { $0.rawValue }.mapValues { $0.rawValue } + if let data = try? JSONEncoder().encode(config) { + UserDefaults.standard.set(data, forKey: "LogConfiguration") + } + + UserDefaults.standard.set(globalMinLevel.rawValue, forKey: "LogGlobalLevel") + UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance") + UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps") + UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation") + } +} + +struct Logger { + private let logger: os.Logger + private let category: LogCategory + private let config = LogConfiguration.shared + + init(subsystem: String = Bundle.main.bundleIdentifier ?? "com.romm.app", category: LogCategory) { + self.logger = os.Logger(subsystem: subsystem, category: category.rawValue) + self.category = category + } + + // MARK: - Log Levels + + func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.debug, for: category) else { return } + let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line) + logger.debug("\(formattedMessage)") + } + + func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.info, for: category) else { return } + let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line) + logger.info("\(formattedMessage)") + } + + func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.notice, for: category) else { return } + let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line) + logger.notice("\(formattedMessage)") + } + + func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.warning, for: category) else { return } + let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line) + logger.warning("\(formattedMessage)") + } + + func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.error, for: category) else { return } + let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line) + logger.error("\(formattedMessage)") + } + + func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.critical, for: category) else { return } + let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line) + logger.critical("\(formattedMessage)") + } + + // MARK: - Convenience Methods + + func logNetworkRequest(method: String, url: String, statusCode: Int? = nil) { + guard config.shouldLog(.info, for: category) else { return } + if let statusCode = statusCode { + info("🌐 \(method) \(url) - Status: \(statusCode)") + } else { + info("🌐 \(method) \(url)") + } + } + + func logNetworkError(method: String, url: String, error: Error) { + guard config.shouldLog(.error, for: category) else { return } + self.error("❌ \(method) \(url) - Error: \(error.localizedDescription)") + } + + func logPerformance(_ operation: String, duration: TimeInterval) { + guard config.showPerformanceLogs && config.shouldLog(.info, for: category) else { return } + info("⏱️ \(operation) completed in \(String(format: "%.3f", duration))s") + } + + // MARK: - Private Helpers + + private func formatMessage(_ message: String, level: LogLevel, file: String, function: String, line: Int) -> String { + var components: [String] = [] + + if config.showTimestamps { + let timestamp = DateFormatter.logTimestamp.string(from: Date()) + components.append(timestamp) + } + + components.append(level.emoji) + components.append("[\(category.rawValue)]") + + if config.includeSourceLocation { + components.append("[\(sourceFileName(filePath: file)):\(line)]") + components.append(function) + } + + components.append("-") + components.append(message) + + return components.joined(separator: " ") + } + + private func sourceFileName(filePath: String) -> String { + return URL(fileURLWithPath: filePath).lastPathComponent.replacingOccurrences(of: ".swift", with: "") + } +} + +// MARK: - Category-specific Loggers + +extension Logger { + static let network = Logger(category: .network) + static let ui = Logger(category: .ui) + static let data = Logger(category: .data) + static let auth = Logger(category: .auth) + static let performance = Logger(category: .performance) + static let general = Logger(category: .general) + static let manual = Logger(category: .manual) + static let viewModel = Logger(category: .viewModel) +} + +// MARK: - Performance Measurement Helper + +struct PerformanceMeasurement { + private let startTime = CFAbsoluteTimeGetCurrent() + private let operation: String + private let logger: Logger + + init(operation: String, logger: Logger = .performance) { + self.operation = operation + self.logger = logger + logger.debug("🚀 Starting \(operation)") + } + + func end() { + let duration = CFAbsoluteTimeGetCurrent() - startTime + logger.logPerformance(operation, duration: duration) + } +} + +// MARK: - DateFormatter Extension + +extension DateFormatter { + static let logTimestamp: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + + return formatter + }() +} + +// MARK: - Dictionary Extension + +extension Dictionary { + func mapKeys(_ transform: (Key) throws -> T) rethrows -> [T: Value] { + return try Dictionary(uniqueKeysWithValues: map { (try transform($0.key), $0.value) }) + } +} + diff --git a/readeck/UI/Bookmarks/BookmarksView.swift b/readeck/UI/Bookmarks/BookmarksView.swift index d58e11a..e651e65 100644 --- a/readeck/UI/Bookmarks/BookmarksView.swift +++ b/readeck/UI/Bookmarks/BookmarksView.swift @@ -39,7 +39,7 @@ struct BookmarksView: View { var body: some View { ZStack { - if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true { + if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true { VStack(spacing: 20) { Spacer() diff --git a/readeck/UI/Components/LocalBookmarksSyncView.swift b/readeck/UI/Components/LocalBookmarksSyncView.swift new file mode 100644 index 0000000..7d23e92 --- /dev/null +++ b/readeck/UI/Components/LocalBookmarksSyncView.swift @@ -0,0 +1,105 @@ +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 + } + } + } + } + } + } +} diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index f62ed1a..95fa233 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -13,59 +13,119 @@ 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 @EnvironmentObject var appSettings: AppSettings var body: some View { GlobalPlayerContainerView { TabView(selection: $selectedTabIndex) { - ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in - NavigationStack { - tabView(for: tab) - } - .tabItem { - Label(tab.label, systemImage: tab.systemImage) - } - .tag(idx) - } - - NavigationStack { - List(moreTabs, id: \.self) { tab in - - NavigationLink { - tabView(for: tab) - .navigationTitle(tab.label) - .onDisappear { - // tags and search handle navigation by own - if tab != .tags && tab != .search { - selectedMoreTab = nil - } - } - } label: { - Label(tab.label, systemImage: tab.systemImage) - } - .listRowBackground(Color(R.color.bookmark_list_bg)) - } - .navigationTitle("More") - .scrollContentBackground(.hidden) - .background(Color(R.color.bookmark_list_bg)) - - if appSettings.enableTTS { - PlayerQueueResumeButton() - .padding(.top, 16) - } - } - .tabItem { - Label("More", systemImage: "ellipsis") - } - .tag(mainTabs.count) - .onAppear { - if selectedTabIndex == mainTabs.count && selectedMoreTab != nil { - selectedMoreTab = nil + mainTabsContent + 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() } } } - .accentColor(.accentColor) + } + } + + private func updateLocalBookmarkCount() { + let count = syncManager.getOfflineBookmarksCount() + DispatchQueue.main.async { + self.phoneTabLocalBookmarkCount = count + } + } + + + // MARK: - Tab Content + + @ViewBuilder + private var mainTabsContent: some View { + ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in + NavigationStack { + tabView(for: tab) + } + .tabItem { + Label(tab.label, systemImage: tab.systemImage) + } + .tag(idx) + } + } + + @ViewBuilder + private var moreTabContent: some View { + NavigationStack { + VStack(spacing: 0) { + moreTabsList + moreTabsFooter + } + } + .tabItem { + Label("More", systemImage: "ellipsis") + } + .badge(phoneTabLocalBookmarkCount > 0 ? phoneTabLocalBookmarkCount : 0) + .tag(mainTabs.count) + .onAppear { + if selectedTabIndex == mainTabs.count && selectedMoreTab != nil { + selectedMoreTab = nil + } + } + } + + @ViewBuilder + private var moreTabsList: some View { + List { + ForEach(moreTabs, id: \.self) { tab in + NavigationLink { + tabView(for: tab) + .navigationTitle(tab.label) + .onDisappear { + // tags and search handle navigation by own + if tab != .tags && tab != .search { + selectedMoreTab = nil + } + } + } label: { + Label(tab.label, systemImage: tab.systemImage) + } + .listRowBackground(Color(R.color.bookmark_list_bg)) + } + + if phoneTabLocalBookmarkCount > 0 { + Section { + VStack { + LocalBookmarksSyncView(bookmarkCount: phoneTabLocalBookmarkCount) + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + } + } + } + .navigationTitle("More") + .scrollContentBackground(.hidden) + .background(Color(R.color.bookmark_list_bg)) + } + + @ViewBuilder + private var moreTabsFooter: some View { + if appSettings.enableTTS { + PlayerQueueResumeButton() + .padding(.top, 16) } } diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 2e225e8..ef045da 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -29,6 +29,8 @@ struct readeckApp: App { #if DEBUG NFX.sharedInstance().start() #endif + // Initialize server connectivity monitoring + _ = ServerConnectivity.shared Task { await loadSetupStatus() } diff --git a/readeck/readeck.entitlements b/readeck/readeck.entitlements index 900d8a5..be09c26 100644 --- a/readeck/readeck.entitlements +++ b/readeck/readeck.entitlements @@ -4,6 +4,10 @@ com.apple.security.app-sandbox + com.apple.security.application-groups + + group.readeck.app + com.apple.security.files.user-selected.read-only keychain-access-groups diff --git a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents index a8ef2ad..c7471dd 100644 --- a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents +++ b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents @@ -1,5 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -10,4 +60,7 @@ + + + \ No newline at end of file