From b650471f8b145796c2ff0cc9c6f8fabfc1f6a37f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 9 Aug 2025 13:57:24 +0000 Subject: [PATCH 1/3] Add share extension and app intents for photo processing Co-authored-by: dingoes-51.slots --- NotebookSaver.xcodeproj/project.pbxproj | 103 +++++++++++++++++- NotebookSaver/AppDefaults.swift | 2 +- NotebookSaver/CameraView.swift | 16 +-- NotebookSaver/GeminiService.swift | 8 +- NotebookSaver/ImageTextExtractor.swift | 2 +- NotebookSaver/Info.plist | 4 + NotebookSaver/KeychainService.swift | 22 ++-- NotebookSaver/NotebookSaver.entitlements | 14 +++ NotebookSaver/NotebookSaverApp.swift | 11 +- NotebookSaver/NotebookSaverAppIntents.swift | 12 ++ NotebookSaver/NotebookSaverPipeline.swift | 27 +++++ NotebookSaver/OnboardingView.swift | 6 +- NotebookSaver/ProcessPhotoIntent.swift | 23 ++++ NotebookSaver/README-EXTENSIONS.md | 13 +++ NotebookSaver/SettingsView.swift | 10 +- NotebookSaver/SharedDefaults.swift | 14 +++ NotebookSaver/VisionService.swift | 2 +- NotebookSaverShareExtension/Info.plist | 20 ++++ .../NotebookSaverShareExtension.entitlements | 14 +++ NotebookSaverShareExtension/ShareView.swift | 51 +++++++++ .../ShareViewController.swift | 50 +++++++++ README.md | 22 ++-- 22 files changed, 400 insertions(+), 46 deletions(-) create mode 100644 NotebookSaver/NotebookSaver.entitlements create mode 100644 NotebookSaver/NotebookSaverAppIntents.swift create mode 100644 NotebookSaver/NotebookSaverPipeline.swift create mode 100644 NotebookSaver/ProcessPhotoIntent.swift create mode 100644 NotebookSaver/README-EXTENSIONS.md create mode 100644 NotebookSaver/SharedDefaults.swift create mode 100644 NotebookSaverShareExtension/Info.plist create mode 100644 NotebookSaverShareExtension/NotebookSaverShareExtension.entitlements create mode 100644 NotebookSaverShareExtension/ShareView.swift create mode 100644 NotebookSaverShareExtension/ShareViewController.swift diff --git a/NotebookSaver.xcodeproj/project.pbxproj b/NotebookSaver.xcodeproj/project.pbxproj index feaa06d..3a89908 100644 --- a/NotebookSaver.xcodeproj/project.pbxproj +++ b/NotebookSaver.xcodeproj/project.pbxproj @@ -10,6 +10,9 @@ 7F3764E12DC566A20038F019 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F3764E02DC566A20038F019 /* WidgetKit.framework */; }; 7F3764E32DC566A20038F019 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F3764E22DC566A20038F019 /* SwiftUI.framework */; }; 7F3764F22DC566A40038F019 /* Cat NoteWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7F3764DE2DC566A20038F019 /* Cat NoteWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 7FNEW100000000000000030 /* NotebookSaverShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7FNEW100000000000000001 /* NotebookSaverShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 7FNEW100000000000000031 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FNEW100000000000000002 /* ShareView.swift */; }; + 7FNEW100000000000000032 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FNEW100000000000000003 /* ShareViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -30,6 +33,7 @@ dstSubfolderSpec = 13; files = ( 7F3764F22DC566A40038F019 /* Cat NoteWidgetsExtension.appex in Embed Foundation Extensions */, + 7FNEW100000000000000030 /* NotebookSaverShareExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -37,6 +41,16 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 7FNEW100000000000000001 /* NotebookSaverShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotebookSaverShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 7FNEW100000000000000002 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = ""; }; + 7FNEW100000000000000003 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + 7FNEW100000000000000004 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 7FNEW100000000000000005 /* NotebookSaverShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotebookSaverShareExtension.entitlements; sourceTree = ""; }; + 7FNEW200000000000000001 /* NotebookSaver.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotebookSaver.entitlements; sourceTree = ""; }; + 7FNEW300000000000000001 /* SharedDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedDefaults.swift; sourceTree = ""; }; + 7FNEW300000000000000002 /* NotebookSaverPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotebookSaverPipeline.swift; sourceTree = ""; }; + 7FNEW300000000000000003 /* ProcessPhotoIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessPhotoIntent.swift; sourceTree = ""; }; + 7FNEW300000000000000004 /* NotebookSaverAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotebookSaverAppIntents.swift; sourceTree = ""; }; 7F3764AA2DC45A9C0038F019 /* Cat Scribe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Cat Scribe.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7F3764DE2DC566A20038F019 /* Cat NoteWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Cat NoteWidgetsExtension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 7F3764E02DC566A20038F019 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -68,6 +82,12 @@ ); path = NotebookSaver; sourceTree = ""; + children = ( + 7FNEW300000000000000001 /* SharedDefaults.swift */, + 7FNEW300000000000000002 /* NotebookSaverPipeline.swift */, + 7FNEW300000000000000003 /* ProcessPhotoIntent.swift */, + 7FNEW300000000000000004 /* NotebookSaverAppIntents.swift */, + ); }; 7F3764E42DC566A20038F019 /* NotebookSaverWidgets */ = { isa = PBXFileSystemSynchronizedRootGroup; @@ -80,9 +100,8 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - 7F3764A72DC45A9C0038F019 /* Frameworks */ = { + 7FNEW100000000000000012 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; @@ -103,6 +122,7 @@ isa = PBXGroup; children = ( 7F3764AC2DC45A9C0038F019 /* NotebookSaver */, + 7FNEW100000000000000000 /* NotebookSaverShareExtension */, 7F3764E42DC566A20038F019 /* NotebookSaverWidgets */, 7F3764DF2DC566A20038F019 /* Frameworks */, 7F3764AB2DC45A9C0038F019 /* Products */, @@ -114,6 +134,7 @@ children = ( 7F3764AA2DC45A9C0038F019 /* Cat Scribe.app */, 7F3764DE2DC566A20038F019 /* Cat NoteWidgetsExtension.appex */, + 7FNEW100000000000000001 /* NotebookSaverShareExtension.appex */, ); name = Products; sourceTree = ""; @@ -127,6 +148,17 @@ name = Frameworks; sourceTree = ""; }; + 7FNEW100000000000000000 /* NotebookSaverShareExtension */ = { + isa = PBXGroup; + children = ( + 7FNEW100000000000000002 /* ShareView.swift */, + 7FNEW100000000000000003 /* ShareViewController.swift */, + 7FNEW100000000000000004 /* Info.plist */, + 7FNEW100000000000000005 /* NotebookSaverShareExtension.entitlements */, + ); + path = NotebookSaverShareExtension; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -176,6 +208,21 @@ productReference = 7F3764DE2DC566A20038F019 /* Cat NoteWidgetsExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + 7FNEW100000000000000010 /* NotebookSaverShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7FNEW100000000000000020 /* Build configuration list for PBXNativeTarget "NotebookSaverShareExtension" */; + buildPhases = ( + 7FNEW100000000000000011 /* Sources */, + 7FNEW100000000000000012 /* Frameworks */, + 7FNEW100000000000000013 /* Resources */, + ); + dependencies = ( + ); + name = NotebookSaverShareExtension; + productName = NotebookSaverShareExtension; + productReference = 7FNEW100000000000000001 /* NotebookSaverShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -213,6 +260,7 @@ targets = ( 7F3764A92DC45A9C0038F019 /* Cat Scribe */, 7F3764DD2DC566A20038F019 /* Cat NoteWidgetsExtension */, + 7FNEW100000000000000010 /* NotebookSaverShareExtension */, ); }; /* End PBXProject section */ @@ -232,6 +280,12 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7FNEW100000000000000013 /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -249,6 +303,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7FNEW100000000000000011 /* Sources */ = { + isa = PBXSourcesBuildPhase; + files = ( + 7FNEW100000000000000031 /* ShareView.swift in Sources */, + 7FNEW100000000000000032 /* ShareViewController.swift in Sources */, + ); + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -408,6 +469,7 @@ INFOPLIST_KEY_UIStatusBarHidden = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; IPHONEOS_DEPLOYMENT_TARGET = 18.0; + CODE_SIGN_ENTITLEMENTS = NotebookSaver/NotebookSaver.entitlements; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -451,6 +513,7 @@ INFOPLIST_KEY_UIStatusBarHidden = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; IPHONEOS_DEPLOYMENT_TARGET = 18.0; + CODE_SIGN_ENTITLEMENTS = NotebookSaver/NotebookSaver.entitlements; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -522,6 +585,34 @@ }; name = Release; }; + 7FNEW100000000000000021 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = Q26G342EEL; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NotebookSaverShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks"); + PRODUCT_BUNDLE_IDENTIFIER = com.daviddegner.NotebookSaver.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = 1; + INFOPLIST_KEY_NSExtensionPrincipalClass = "$(PRODUCT_MODULE_NAME).ShareViewController"; + CODE_SIGN_ENTITLEMENTS = NotebookSaverShareExtension/NotebookSaverShareExtension.entitlements; + }; name = Debug; }; + 7FNEW100000000000000022 /* Release */ = {isa = XCBuildConfiguration; buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = Q26G342EEL; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NotebookSaverShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks"); + PRODUCT_BUNDLE_IDENTIFIER = com.daviddegner.NotebookSaver.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = 1; + INFOPLIST_KEY_NSExtensionPrincipalClass = "$(PRODUCT_MODULE_NAME).ShareViewController"; + CODE_SIGN_ENTITLEMENTS = NotebookSaverShareExtension/NotebookSaverShareExtension.entitlements; + }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -552,6 +643,14 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 7FNEW100000000000000020 /* Build configuration list for PBXNativeTarget "NotebookSaverShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7FNEW100000000000000021 /* Debug */, + 7FNEW100000000000000022 /* Release */, + ); + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 7F3764A22DC45A9C0038F019 /* Project object */; diff --git a/NotebookSaver/AppDefaults.swift b/NotebookSaver/AppDefaults.swift index 621c243..f79d4e4 100644 --- a/NotebookSaver/AppDefaults.swift +++ b/NotebookSaver/AppDefaults.swift @@ -3,6 +3,6 @@ import Foundation /// Centralized app defaults to ensure consistency across the app enum AppDefaults { /// Default text extractor service - set to Vision (Local) since it works without setup - /// The onboarding flow will change this to Gemini when user provides API key + /// The onboarding flow will change this to Cloud when user provides API key static let textExtractorService = TextExtractorType.vision.rawValue } \ No newline at end of file diff --git a/NotebookSaver/CameraView.swift b/NotebookSaver/CameraView.swift index fc5e986..76088e1 100644 --- a/NotebookSaver/CameraView.swift +++ b/NotebookSaver/CameraView.swift @@ -302,7 +302,7 @@ extension CameraView { } catch let error as CameraManager.CameraError { await handleError(error.localizedDescription) } catch let error as APIError { - await handleError("Gemini Error: \(error.localizedDescription)") + await handleError("Cloud Error: \(error.localizedDescription)") } catch let error as VisionError { await handleError("Vision Error: \(error.localizedDescription)") } catch let error as DraftsError { @@ -326,14 +326,14 @@ extension CameraView { private func extractTextFromProcessedImage(_ processedImage: UIImage) async throws -> String { let defaults = UserDefaults.standard - let selectedServiceRaw = defaults.string(forKey: "textExtractorService") ?? TextExtractorType.gemini.rawValue - var selectedService = TextExtractorType(rawValue: selectedServiceRaw) ?? .gemini + let selectedServiceRaw = defaults.string(forKey: "textExtractorService") ?? TextExtractorType.cloud.rawValue + var selectedService = TextExtractorType(rawValue: selectedServiceRaw) ?? .cloud - // Check if Gemini is properly configured, fallback to Vision if not - if selectedService == .gemini { + // Check if Cloud is properly configured, fallback to Vision if not + if selectedService == .cloud { let apiKey = KeychainService.loadAPIKey() if apiKey?.isEmpty ?? true { - print("Gemini selected but API key is missing, falling back to Vision") + print("Cloud selected but API key is missing, falling back to Vision") selectedService = .vision } } @@ -342,9 +342,9 @@ extension CameraView { let textExtractor: ImageTextExtractor switch selectedService { - case .gemini: + case .cloud: textExtractor = GeminiService() - print("Using Gemini Service with processed image") + print("Using Cloud Service with processed image") case .vision: textExtractor = VisionService() print("Using Vision Service with processed image") diff --git a/NotebookSaver/GeminiService.swift b/NotebookSaver/GeminiService.swift index 5745850..4000fc2 100644 --- a/NotebookSaver/GeminiService.swift +++ b/NotebookSaver/GeminiService.swift @@ -5,13 +5,13 @@ import CoreImage // Import Core Image // MARK: - Model Management Service -class GeminiModelService: ObservableObject { - static let shared = GeminiModelService() +class CloudModelService: ObservableObject { + static let shared = CloudModelService() private init() {} // Storage keys private enum StorageKeys { - static let cachedModels = "cachedGeminiModels" + static let cachedModels = "cachedCloudModels" static let hasInitiallyFetchedModels = "hasInitiallyFetchedModels" } @@ -189,7 +189,7 @@ class GeminiService: ImageTextExtractor /*: APIServiceProtocol*/ { // Helper to get settings from UserDefaults static func getSettings() -> (apiKey: String?, apiEndpointUrl: URL?, modelToUse: String?, prompt: String?, draftsTag: String, thinkingEnabled: Bool) { - let defaults = UserDefaults.standard + let defaults = SharedDefaults.suite let apiKey = KeychainService.loadAPIKey() diff --git a/NotebookSaver/ImageTextExtractor.swift b/NotebookSaver/ImageTextExtractor.swift index b57bf1d..1d4a4d1 100644 --- a/NotebookSaver/ImageTextExtractor.swift +++ b/NotebookSaver/ImageTextExtractor.swift @@ -10,7 +10,7 @@ protocol ImageTextExtractor { // Enum to identify the different service types enum TextExtractorType: String, CaseIterable, Identifiable { - case gemini = "Cloud" + case cloud = "Cloud" case vision = "Local" var id: String { self.rawValue } diff --git a/NotebookSaver/Info.plist b/NotebookSaver/Info.plist index 0ef2b5b..9b29c34 100644 --- a/NotebookSaver/Info.plist +++ b/NotebookSaver/Info.plist @@ -23,5 +23,9 @@ drafts + IntentsSupportedIntents + + ProcessPhotoIntent + diff --git a/NotebookSaver/KeychainService.swift b/NotebookSaver/KeychainService.swift index 3d2f0bc..adf78eb 100644 --- a/NotebookSaver/KeychainService.swift +++ b/NotebookSaver/KeychainService.swift @@ -11,7 +11,7 @@ class KeychainService { // Define service and account keys used to identify the keychain item. // Using Bundle Identifier guarantees uniqueness. private static let service = Bundle.main.bundleIdentifier ?? "com.example.notebooksaver.apikey" - private static let account = "geminiAPIKey" + private static let account = "cloudAPIKey" // MARK: - Save API Key @@ -25,7 +25,7 @@ class KeychainService { _ = deleteAPIKey() // Attributes dictionary for the new keychain item. - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, @@ -34,6 +34,9 @@ class KeychainService { // This is a common security level for API keys. kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly ] + #if !targetEnvironment(macCatalyst) + query[kSecAttrAccessGroup as String] = "$(AppIdentifierPrefix)com.daviddegner.NotebookSaver" + #endif // Add the item to the keychain. let status = SecItemAdd(query as CFDictionary, nil) @@ -51,13 +54,16 @@ class KeychainService { static func loadAPIKey() -> String? { // Query to find the keychain item. - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: kCFBooleanTrue!, kSecMatchLimit as String: kSecMatchLimitOne // We only expect one item. ] + #if !targetEnvironment(macCatalyst) + query[kSecAttrAccessGroup as String] = "$(AppIdentifierPrefix)com.daviddegner.NotebookSaver" + #endif var dataTypeRef: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) @@ -82,22 +88,24 @@ class KeychainService { // MARK: - Delete API Key (Helper) static func deleteAPIKey() -> Bool { - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] + #if !targetEnvironment(macCatalyst) + query[kSecAttrAccessGroup as String] = "$(AppIdentifierPrefix)com.daviddegner.NotebookSaver" + #endif let status = SecItemDelete(query as CFDictionary) if status == errSecSuccess || status == errSecItemNotFound { - // Consider deletion successful if it succeeded or if the item wasn't found. if status == errSecSuccess { - print("Keychain: Existing Gemini API Key deleted.") // Clarified which key + print("Keychain: Existing Cloud API Key deleted.") } return true } else { - print("Keychain Error: Failed to delete Gemini API Key. Status code: \(status) - \(keychainErrorString(status))") + print("Keychain Error: Failed to delete Cloud API Key. Status code: \(status) - \(keychainErrorString(status))") return false } } diff --git a/NotebookSaver/NotebookSaver.entitlements b/NotebookSaver/NotebookSaver.entitlements new file mode 100644 index 0000000..e2c5c88 --- /dev/null +++ b/NotebookSaver/NotebookSaver.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.com.daviddegner.NotebookSaver + + keychain-access-groups + + $(AppIdentifierPrefix)com.daviddegner.NotebookSaver + + + \ No newline at end of file diff --git a/NotebookSaver/NotebookSaverApp.swift b/NotebookSaver/NotebookSaverApp.swift index 0acaecc..4394355 100644 --- a/NotebookSaver/NotebookSaverApp.swift +++ b/NotebookSaver/NotebookSaverApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppIntents @main struct NotebookSaverApp: App { @@ -19,8 +20,8 @@ struct NotebookSaverApp: App { appState.presentOnboarding() } - // Defer Gemini warmup to avoid blocking UI presentation - if hasCompletedOnboarding && textExtractorService == TextExtractorType.gemini.rawValue { + // Defer Cloud warmup to avoid blocking UI presentation + if hasCompletedOnboarding && textExtractorService == TextExtractorType.cloud.rawValue { Task.detached(priority: .background) { try? await Task.sleep(nanoseconds: 1_000_000_000) // 1s await GeminiService.warmUpConnection() @@ -30,6 +31,10 @@ struct NotebookSaverApp: App { // Initialize models on first launch initializeModelsIfNeeded() } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { _ in } + .appShortcuts { + NotebookSaverShortcuts() + } .sheet(isPresented: $appState.showOnboarding) { OnboardingView(isOnboarding: $appState.showOnboarding) .environmentObject(appState) @@ -40,7 +45,7 @@ struct NotebookSaverApp: App { // Initialize models on first launch only private func initializeModelsIfNeeded() { - let modelService = GeminiModelService.shared + let modelService = CloudModelService.shared // Only fetch on first launch and if we have an API key if modelService.shouldFetchModels, let _ = KeychainService.loadAPIKey() { diff --git a/NotebookSaver/NotebookSaverAppIntents.swift b/NotebookSaver/NotebookSaverAppIntents.swift new file mode 100644 index 0000000..bae4c44 --- /dev/null +++ b/NotebookSaver/NotebookSaverAppIntents.swift @@ -0,0 +1,12 @@ +import AppIntents + +struct NotebookSaverShortcuts: AppShortcutsProvider { + static var shortcutTileColor: ShortcutTileColor = .orange + + static var appShortcuts: [AppShortcut] { + [ + AppShortcut(intent: ProcessPhotoIntent(), + phrases: ["Process photo in \(.applicationName)", "Extract text with \(.applicationName)"]), + ] + } +} \ No newline at end of file diff --git a/NotebookSaver/NotebookSaverPipeline.swift b/NotebookSaver/NotebookSaverPipeline.swift new file mode 100644 index 0000000..761a0f4 --- /dev/null +++ b/NotebookSaver/NotebookSaverPipeline.swift @@ -0,0 +1,27 @@ +import Foundation +import UIKit + +struct NotebookSaverPipeline { + static func processImage(_ image: UIImage) async throws -> String { + let defaults = SharedDefaults.suite + let selectedServiceRaw = defaults.string(forKey: "textExtractorService") ?? TextExtractorType.cloud.rawValue + var selectedService = TextExtractorType(rawValue: selectedServiceRaw) ?? .cloud + + if selectedService == .cloud { + let apiKey = KeychainService.loadAPIKey() + if apiKey?.isEmpty ?? true { + selectedService = .vision + } + } + + let textExtractor: ImageTextExtractor + switch selectedService { + case .cloud: + textExtractor = GeminiService() + case .vision: + textExtractor = VisionService() + } + + return try await textExtractor.extractText(from: image) + } +} \ No newline at end of file diff --git a/NotebookSaver/OnboardingView.swift b/NotebookSaver/OnboardingView.swift index 0d481f8..670f55d 100644 --- a/NotebookSaver/OnboardingView.swift +++ b/NotebookSaver/OnboardingView.swift @@ -65,7 +65,7 @@ struct OnboardingView: View { Image(systemName: "key.fill") .foregroundColor(.orange) .font(.title2) - Text("Get Gemini API Key") + Text("Get Cloud API Key") .font(.title2) .fontWeight(.bold) .foregroundColor(.primary) @@ -261,8 +261,8 @@ struct OnboardingView: View { // Show confirmation showSaveConfirmation = true - // Set service to Gemini since we have an API key - textExtractorService = TextExtractorType.gemini.rawValue + // Set service to Cloud since we have an API key + textExtractorService = TextExtractorType.cloud.rawValue // Mark onboarding as completed hasCompletedOnboarding = true diff --git a/NotebookSaver/ProcessPhotoIntent.swift b/NotebookSaver/ProcessPhotoIntent.swift new file mode 100644 index 0000000..b04586c --- /dev/null +++ b/NotebookSaver/ProcessPhotoIntent.swift @@ -0,0 +1,23 @@ +import AppIntents +import UIKit + +struct ProcessPhotoIntent: AppIntent { + static var title: LocalizedStringResource = "Process Photo" + static var description = IntentDescription("Process a photo through NotebookSaver using current settings.") + + @Parameter(title: "Photo") + var photo: IntentFile + + static var parameterSummary: some ParameterSummary { + Summary("Process \(.photo)") + } + + func perform() async throws -> some IntentResult { + guard let data = try? Data(contentsOf: photo.fileURL), + let image = UIImage(data: data) else { + throw NSError(domain: "ProcessPhotoIntent", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid image data"]) + } + let text = try await NotebookSaverPipeline.processImage(image) + return .result(value: text) + } +} \ No newline at end of file diff --git a/NotebookSaver/README-EXTENSIONS.md b/NotebookSaver/README-EXTENSIONS.md new file mode 100644 index 0000000..fcd32dc --- /dev/null +++ b/NotebookSaver/README-EXTENSIONS.md @@ -0,0 +1,13 @@ +# Extensions + +- Share Extension: Share a single photo from Photos to NotebookSaver to extract text with current settings. +- App Intent: "Process Photo" accepts an image file and returns extracted text. Appears in Shortcuts and Spotlight. + +Setup Notes: +- Ensure App Group `group.com.daviddegner.NotebookSaver` is enabled for app and extension. +- The app must be launched once to save API key and settings before extensions can use them. +- The API key is stored in Keychain with access group `$(AppIdentifierPrefix)com.daviddegner.NotebookSaver`. + +Usage: +- From Photos: Share > NotebookSaver — waits, then shows extracted text with Copy/Share. +- From Shortcuts: Use the "Process Photo" intent with an image input; result is the extracted text string. \ No newline at end of file diff --git a/NotebookSaver/SettingsView.swift b/NotebookSaver/SettingsView.swift index 0ae6a99..599ca60 100644 --- a/NotebookSaver/SettingsView.swift +++ b/NotebookSaver/SettingsView.swift @@ -110,7 +110,7 @@ struct SettingsView: View { @State private var availableModels: [String] = [] @State private var isRefreshingModels = false @State private var modelsRefreshError: String? - @ObservedObject private var modelService = GeminiModelService.shared + @ObservedObject private var modelService = CloudModelService.shared // Focus state for text fields to enable tap-to-dismiss @FocusState private var isPromptFocused: Bool @@ -119,8 +119,8 @@ struct SettingsView: View { @FocusState private var isPhotoFolderFocused: Bool @FocusState private var isApiEndpointFocused: Bool - // Check if Gemini service is properly configured - private var isGeminiConfigured: Bool { + // Check if Cloud service is properly configured + private var isCloudConfigured: Bool { return !apiKey.isEmpty && connectionStatus != .failure } @@ -447,8 +447,8 @@ struct SettingsView: View { .animation(.easeInOut(duration: 0.2), value: textExtractorService) // Show different content based on selected service - if textExtractorService == TextExtractorType.gemini.rawValue { - // Cloud (Gemini) settings + if textExtractorService == TextExtractorType.cloud.rawValue { + // Cloud settings // AI Instruction Prompt VStack(alignment: .leading, spacing: 12) { diff --git a/NotebookSaver/SharedDefaults.swift b/NotebookSaver/SharedDefaults.swift new file mode 100644 index 0000000..a666170 --- /dev/null +++ b/NotebookSaver/SharedDefaults.swift @@ -0,0 +1,14 @@ +import Foundation + +// Shared App Group UserDefaults for app and extensions +// Update the App Group identifier if you change your Bundle ID +public enum SharedDefaults { + public static let appGroupId = "group.com.daviddegner.NotebookSaver" + public static let suite: UserDefaults = { + guard let defaults = UserDefaults(suiteName: appGroupId) else { + // Fallback to standard to avoid crashes if misconfigured + return .standard + } + return defaults + }() +} \ No newline at end of file diff --git a/NotebookSaver/VisionService.swift b/NotebookSaver/VisionService.swift index f873a4b..d662522 100644 --- a/NotebookSaver/VisionService.swift +++ b/NotebookSaver/VisionService.swift @@ -35,7 +35,7 @@ class VisionService: ImageTextExtractor { static let visionUsesLanguageCorrection = "visionUsesLanguageCorrection" } - // Reuse CIContext for efficiency, similar to GeminiService - Removed, context is in ImagePreprocessor + // Reuse CIContext for efficiency, similar to Cloud service - Removed, context is in ImagePreprocessor // private let ciContext = CIContext() func extractText(from imageData: Data) async throws -> String { diff --git a/NotebookSaverShareExtension/Info.plist b/NotebookSaverShareExtension/Info.plist new file mode 100644 index 0000000..b754903 --- /dev/null +++ b/NotebookSaverShareExtension/Info.plist @@ -0,0 +1,20 @@ + + + + + NSExtension + + NSExtensionAttributes + + PHSupportedMediaTypes + + Image + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + \ No newline at end of file diff --git a/NotebookSaverShareExtension/NotebookSaverShareExtension.entitlements b/NotebookSaverShareExtension/NotebookSaverShareExtension.entitlements new file mode 100644 index 0000000..e2c5c88 --- /dev/null +++ b/NotebookSaverShareExtension/NotebookSaverShareExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.com.daviddegner.NotebookSaver + + keychain-access-groups + + $(AppIdentifierPrefix)com.daviddegner.NotebookSaver + + + \ No newline at end of file diff --git a/NotebookSaverShareExtension/ShareView.swift b/NotebookSaverShareExtension/ShareView.swift new file mode 100644 index 0000000..73bde98 --- /dev/null +++ b/NotebookSaverShareExtension/ShareView.swift @@ -0,0 +1,51 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct ShareView: View { + let imageData: Data + @State private var extractedText: String = "" + @State private var isProcessing = true + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if isProcessing { + ProgressView("Processing…") + } else if let errorMessage = errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } else { + ScrollView { + Text(extractedText).textSelection(.enabled) + } + HStack { + Button("Copy") { + UIPasteboard.general.string = extractedText + } + Spacer() + ShareLink(item: extractedText) { + Text("Share") + } + } + } + } + .padding() + .task { + await process() + } + } + + private func process() async { + defer { isProcessing = false } + guard let image = UIImage(data: imageData) else { + errorMessage = "Invalid image data" + return + } + do { + let text = try await NotebookSaverPipeline.processImage(image) + extractedText = text + } catch { + errorMessage = error.localizedDescription + } + } +} \ No newline at end of file diff --git a/NotebookSaverShareExtension/ShareViewController.swift b/NotebookSaverShareExtension/ShareViewController.swift new file mode 100644 index 0000000..bddc8a1 --- /dev/null +++ b/NotebookSaverShareExtension/ShareViewController.swift @@ -0,0 +1,50 @@ +import UIKit +import Social +import SwiftUI +import UniformTypeIdentifiers + +class ShareViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + + loadFirstImageData { data in + let root = UIHostingController(rootView: ShareView(imageData: data)) + self.addChild(root) + root.view.frame = self.view.bounds + self.view.addSubview(root.view) + root.didMove(toParent: self) + } + } + + private func loadFirstImageData(completion: @escaping (Data) -> Void) { + guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem, + let providers = extensionItem.attachments else { + completion(Data()) + return + } + + let imageTypes: [UTType] = [.image, .png, .jpeg, .heic] + + for provider in providers { + for type in imageTypes { + if provider.hasItemConformingToTypeIdentifier(type.identifier) { + provider.loadItem(forTypeIdentifier: type.identifier, options: nil) { item, _ in + if let url = item as? URL, let data = try? Data(contentsOf: url) { + DispatchQueue.main.async { completion(data) } + } else if let image = item as? UIImage, let data = image.jpegData(compressionQuality: 0.95) { + DispatchQueue.main.async { completion(data) } + } + } + return + } + } + } + + completion(Data()) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + self.extensionContext?.completeRequest(returningItems: nil) + } +} \ No newline at end of file diff --git a/README.md b/README.md index 5930d31..2d5371d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NotebookSaver -A powerful iOS app that transforms handwritten notes and documents into digital text using AI-powered optical character recognition (OCR). Capture images with your camera and instantly extract text using Google's Gemini AI models. +A powerful iOS app that transforms handwritten notes and documents into digital text using AI-powered optical character recognition (OCR). Capture images with your camera and instantly extract text using Cloud AI models (e.g., Gemini, OpenAI-compatible). ## Features @@ -10,9 +10,9 @@ A powerful iOS app that transforms handwritten notes and documents into digital - High-quality HEIC image encoding for efficient processing ### 🤖 AI-Powered Text Extraction -- Integration with Google Gemini AI models (2.5 Flash, 2.0 Flash, 1.5 Pro, and more) +- Integration with Cloud AI models (e.g., Gemini family) - Customizable prompts to guide AI text extraction -- Support for multiple Gemini model variants +- Support for multiple Cloud model variants - Automatic model discovery and caching - "Thinking mode" for enhanced AI reasoning @@ -39,12 +39,12 @@ A powerful iOS app that transforms handwritten notes and documents into digital - iOS 18.0 or later - iPhone with camera -- Google Gemini API key +- Cloud AI API key - Drafts app (optional, for text export) ## Setup -### 1. Get a Gemini API Key +### 1. Get a Cloud API Key 1. Visit the [Google AI Studio](https://aistudio.google.com/) 2. Create a new project or select an existing one 3. Generate an API key for the Gemini API @@ -54,7 +54,7 @@ A powerful iOS app that transforms handwritten notes and documents into digital 1. Launch NotebookSaver 2. Complete the onboarding process 3. Go to Settings (swipe up from camera view) -4. Enter your Gemini API key +4. Enter your Cloud API key 5. Select your preferred AI model 6. Customize the text extraction prompt 7. Configure Drafts integration (optional) @@ -126,7 +126,7 @@ The app follows a clean architecture pattern with: ## Privacy NotebookSaver respects your privacy: -- Images are processed by Google's Gemini API according to their privacy policy +- Images are processed by your selected Cloud AI provider according to their privacy policy - No images or text are stored locally on your device - API keys are stored securely in the iOS Keychain - No analytics or tracking @@ -136,7 +136,7 @@ NotebookSaver respects your privacy: ### Common Issues **"API Key is missing"** -- Ensure you've entered a valid Gemini API key in Settings +- Ensure you've entered a valid Cloud AI API key in Settings **"Model not found"** - Try refreshing the models list in Settings @@ -152,7 +152,7 @@ NotebookSaver respects your privacy: - Ensure Drafts integration is enabled in Settings ### Performance Tips -- Use Gemini 2.5 Flash for the best speed/accuracy balance +- Use a fast Cloud model (e.g., Gemini 2.5 Flash) for the best speed/accuracy balance - Ensure good lighting when capturing images - Keep text images clear and well-focused - Use the highest quality camera settings @@ -163,8 +163,8 @@ This is a personal project, but feedback and suggestions are welcome. Please ens ## License -This project is for personal use. Please respect Google's Gemini API terms of service and usage limits. +This project is for personal use. Please respect your Cloud AI provider's terms of service and usage limits. --- -**Note**: This app requires a Google Gemini API key and active internet connection to function. API usage may incur costs based on Google's pricing structure. \ No newline at end of file +**Note**: This app requires a Cloud AI API key and active internet connection to function. API usage may incur costs based on your provider's pricing. \ No newline at end of file From 72dd5a80ddde6be324db09bb505a3a19916481d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 9 Aug 2025 14:12:37 +0000 Subject: [PATCH 2/3] Use CloudModelService to dynamically set default Gemini model Co-authored-by: dingoes-51.slots --- NotebookSaver/GeminiService.swift | 4 ++-- NotebookSaver/SettingsView.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/NotebookSaver/GeminiService.swift b/NotebookSaver/GeminiService.swift index 4000fc2..acc0332 100644 --- a/NotebookSaver/GeminiService.swift +++ b/NotebookSaver/GeminiService.swift @@ -174,7 +174,7 @@ class GeminiService: ImageTextExtractor /*: APIServiceProtocol*/ { private static var connectionVerified = false // Defaults - private let defaultModelId = "gemini-2.5-flash" // Default model if nothing is set + private let defaultModelId = CloudModelService.shared.loadCachedModelIds().first ?? "gemini-2.5-flash" // Default model if nothing is set private static let defaultApiEndpoint = "https://generativelanguage.googleapis.com/v1beta/models/" private let defaultDraftsTag = "notebook" @@ -196,7 +196,7 @@ class GeminiService: ImageTextExtractor /*: APIServiceProtocol*/ { let endpointString = defaults.string(forKey: "apiEndpointUrlString") ?? GeminiService.defaultApiEndpoint let apiEndpointUrl = URL(string: endpointString) - let selectedId = defaults.string(forKey: "selectedModelId") ?? "gemini-2.5-flash" + let selectedId = defaults.string(forKey: "selectedModelId") ?? (CloudModelService.shared.loadCachedModelIds().first ?? "gemini-2.5-flash") var modelToUse: String? if selectedId == "Custom" { modelToUse = defaults.string(forKey: "customModelName")?.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/NotebookSaver/SettingsView.swift b/NotebookSaver/SettingsView.swift index 599ca60..57fa0b2 100644 --- a/NotebookSaver/SettingsView.swift +++ b/NotebookSaver/SettingsView.swift @@ -74,7 +74,7 @@ struct SettingsView: View { } // === Persisted Settings === - @AppStorage(StorageKeys.selectedModelId) private var selectedModelId: String = "gemini-2.5-flash" + @AppStorage(StorageKeys.selectedModelId) private var selectedModelId: String = CloudModelService.shared.loadCachedModelIds().first ?? "gemini-2.5-flash" @AppStorage(StorageKeys.userPrompt) private var userPrompt: String = """ Output the text from the image as text. Start immediately with the first word. Format for clarity, format blocks of text into paragraphs, and use markdown sparingly where useful. Usually sentences and paragraphs will make sense. Do not include an intro like: "Here is the text extracted from the image:" """ @@ -1019,7 +1019,7 @@ struct SettingsView: View { // Reset settings to defaults private func resetToDefaults() { - selectedModelId = "gemini-2.5-flash" + selectedModelId = CloudModelService.shared.loadCachedModelIds().first ?? "gemini-2.5-flash" apiEndpointUrlString = "https://generativelanguage.googleapis.com/v1beta/models/" draftsTag = "notebook" photoFolderName = "notebook" // Updated from savePhotosToAlbum From 215727c0286f12502c5ade827dcacd674f4e7168 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 9 Aug 2025 14:27:35 +0000 Subject: [PATCH 3/3] Refactor DraftsHelper and VisionService, add ImageTextExtractor extension Co-authored-by: dingoes-51.slots --- NotebookSaver/DraftsHelper.swift | 110 ++++--------------------- NotebookSaver/ImageTextExtractor.swift | 9 ++ NotebookSaver/VisionService.swift | 58 ++----------- 3 files changed, 32 insertions(+), 145 deletions(-) diff --git a/NotebookSaver/DraftsHelper.swift b/NotebookSaver/DraftsHelper.swift index dc05815..c77d262 100644 --- a/NotebookSaver/DraftsHelper.swift +++ b/NotebookSaver/DraftsHelper.swift @@ -33,57 +33,28 @@ class DraftsHelper { // MARK: - Public Methods - /// Creates a new draft in the Drafts app - /// - Parameters: - /// - text: The text content to create in Drafts - /// - tag: Optional tag(s) to apply to the draft (comma-separated for multiple tags) - /// - completion: Optional completion handler with success status - /// - Throws: DraftsError if Drafts isn't installed or URL creation fails @MainActor static func createDraft(with text: String, tag: String? = nil, completion: ((Bool) -> Void)? = nil) throws { - // Check if Drafts is installed try checkDraftsInstalled() - - // Build and open URL let url = try buildDraftsURL(text: text, tag: tag) print("Opening URL: \(url.absoluteString)") - UIApplication.shared.open(url) { success in - if success { - print("Successfully opened Drafts URL.") - } else { - print("Warning: There may have been an issue opening Drafts.") - } + if success { print("Successfully opened Drafts URL.") } + else { print("Warning: There may have been an issue opening Drafts.") } completion?(success) } } - /// Async version of createDraft that returns success status - /// - Parameters: - /// - text: The text content to create in Drafts - /// - tag: Optional tag(s) to apply to the draft - /// - Returns: Boolean indicating success - /// - Throws: DraftsError if Drafts isn't installed or URL creation fails static func createDraftAsync(with text: String, tag: String? = nil) async throws -> Bool { - // Check if Drafts is installed try await checkDraftsInstalledAsync() - - // Check if we're in background - if so, store for later - let appState = await MainActor.run { - UIApplication.shared.applicationState - } - + let appState = await MainActor.run { UIApplication.shared.applicationState } print("DraftsHelper: Current app state: \(appState.rawValue) (0=active, 1=inactive, 2=background)") - if appState != .active { print("DraftsHelper: App is not active (state: \(appState)), storing draft for later creation") storePendingDraft(text: text, tag: tag) - return true // Return true since we've stored it successfully + return true } - - // Build and open URL let url = try await buildDraftsURLAsync(text: text, tag: tag) - return await withCheckedContinuation { continuation in Task { @MainActor in UIApplication.shared.open(url) { success in @@ -94,9 +65,6 @@ class DraftsHelper { } // MARK: - Private Helper Methods - - /// Checks if the Drafts app is installed - /// - Throws: DraftsError.notInstalled if Drafts is not installed @MainActor private static func checkDraftsInstalled() throws { guard let checkURL = URL(string: "\(scheme)://"), @@ -106,7 +74,6 @@ class DraftsHelper { } } - /// Async version of checkDraftsInstalled that can be called from background tasks private static func checkDraftsInstalledAsync() async throws { try await MainActor.run { guard let checkURL = URL(string: "\(scheme)://"), @@ -117,87 +84,56 @@ class DraftsHelper { } } - /// Builds a URL to create a draft with the specified parameters - /// - Parameters: - /// - text: The text content to create in Drafts - /// - tag: Optional tag(s) to apply to the draft - /// - Returns: URL to open Drafts with the specified content - /// - Throws: DraftsError.invalidURL if URL construction fails @MainActor private static func buildDraftsURL(text: String, tag: String?) throws -> URL { var components = URLComponents() components.scheme = scheme components.host = createAction - - // Add required and optional parameters var queryItems = [URLQueryItem(name: "text", value: text)] - - if let tag = tag, !tag.isEmpty { - queryItems.append(URLQueryItem(name: "tag", value: tag)) - print("Adding tag(s) to URL query: \(tag)") - } - + if let tag = tag, !tag.isEmpty { queryItems.append(URLQueryItem(name: "tag", value: tag)) } components.queryItems = queryItems - guard let url = components.url else { print("Error: Failed to create URL using URLComponents.") throw DraftsError.invalidURL } - return url } - /// Async version of buildDraftsURL that can be called from background tasks private static func buildDraftsURLAsync(text: String, tag: String?) async throws -> URL { return try await MainActor.run { var components = URLComponents() components.scheme = scheme components.host = createAction - - // Add required and optional parameters var queryItems = [URLQueryItem(name: "text", value: text)] - - if let tag = tag, !tag.isEmpty { - queryItems.append(URLQueryItem(name: "tag", value: tag)) - print("Adding tag(s) to URL query: \(tag)") - } - + if let tag = tag, !tag.isEmpty { queryItems.append(URLQueryItem(name: "tag", value: tag)) } components.queryItems = queryItems - guard let url = components.url else { print("Error: Failed to create URL using URLComponents.") throw DraftsError.invalidURL } - return url } } // MARK: - Pending Draft Management - - /// Store a draft to be created when the app returns to foreground private static func storePendingDraft(text: String, tag: String?) { let pendingDraft = PendingDraft(text: text, tag: tag, timestamp: Date()) - var pendingDrafts = loadPendingDrafts() pendingDrafts.append(pendingDraft) - do { let data = try JSONEncoder().encode(pendingDrafts) - UserDefaults.standard.set(data, forKey: pendingDraftsKey) - UserDefaults.standard.synchronize() // Force immediate save + SharedDefaults.suite.set(data, forKey: pendingDraftsKey) + SharedDefaults.suite.synchronize() print("DraftsHelper: Stored pending draft with \(text.count) characters, total pending: \(pendingDrafts.count)") } catch { print("DraftsHelper: Failed to store pending draft: \(error)") } } - /// Load all pending drafts from storage private static func loadPendingDrafts() -> [PendingDraft] { - guard let data = UserDefaults.standard.data(forKey: pendingDraftsKey) else { + guard let data = SharedDefaults.suite.data(forKey: pendingDraftsKey) else { return [] } - do { return try JSONDecoder().decode([PendingDraft].self, from: data) } catch { @@ -206,51 +142,33 @@ class DraftsHelper { } } - /// Create all pending drafts (call when app returns to foreground) @MainActor static func createPendingDrafts() async { let pendingDrafts = loadPendingDrafts() - - guard !pendingDrafts.isEmpty else { - return - } - - // Check if Drafts is still installed before trying to create drafts - do { - try checkDraftsInstalled() - } catch { + guard !pendingDrafts.isEmpty else { return } + do { try checkDraftsInstalled() } catch { print("DraftsHelper: Drafts app not available, keeping \(pendingDrafts.count) pending drafts") return } - print("DraftsHelper: Creating \(pendingDrafts.count) pending drafts") - for draft in pendingDrafts { do { - try await Task.sleep(nanoseconds: 500_000_000) // 0.5 second delay between drafts + try await Task.sleep(nanoseconds: 500_000_000) try createDraft(with: draft.text, tag: draft.tag) print("DraftsHelper: Successfully created pending draft from \(draft.timestamp)") } catch { print("DraftsHelper: Failed to create pending draft: \(error)") - // If Drafts is not installed, we should stop trying and keep the pending drafts - if error is DraftsError { - print("DraftsHelper: Drafts app issue detected, keeping remaining pending drafts") - return - } + if error is DraftsError { return } } } - - // Clear pending drafts after creation - UserDefaults.standard.removeObject(forKey: pendingDraftsKey) + SharedDefaults.suite.removeObject(forKey: pendingDraftsKey) print("DraftsHelper: Cleared all pending drafts") } - /// Get count of pending drafts static func pendingDraftCount() -> Int { return loadPendingDrafts().count } - /// Debug method to manually add a test pending draft static func addTestPendingDraft() { storePendingDraft(text: "Test draft created at \(Date())", tag: "test") } diff --git a/NotebookSaver/ImageTextExtractor.swift b/NotebookSaver/ImageTextExtractor.swift index 1d4a4d1..896b893 100644 --- a/NotebookSaver/ImageTextExtractor.swift +++ b/NotebookSaver/ImageTextExtractor.swift @@ -8,6 +8,15 @@ protocol ImageTextExtractor { func extractText(from processedImage: UIImage) async throws -> String } +extension ImageTextExtractor { + func extractText(from imageData: Data) async throws -> String { + guard let image = UIImage(data: imageData) else { + throw PreprocessingError.invalidImageData + } + return try await extractText(from: image) + } +} + // Enum to identify the different service types enum TextExtractorType: String, CaseIterable, Identifiable { case cloud = "Cloud" diff --git a/NotebookSaver/VisionService.swift b/NotebookSaver/VisionService.swift index d662522..c6bbe9c 100644 --- a/NotebookSaver/VisionService.swift +++ b/NotebookSaver/VisionService.swift @@ -2,13 +2,12 @@ import Foundation import Vision import UIKit // For UIImage import CoreImage // Needed for preprocessing + enum VisionError: LocalizedError { - // case imageConversionFailed // Replaced by PreprocessingError case requestHandlerFailed(Error) case noTextFound case visionRequestFailed(Error) - // case preprocessingFailed(String) // Replaced by PreprocessingError - case preprocessingError(PreprocessingError) // Wrap PreprocessingError + case preprocessingError(PreprocessingError) var errorDescription: String? { switch self { @@ -25,88 +24,49 @@ enum VisionError: LocalizedError { } class VisionService: ImageTextExtractor { - - // Removed reference to ImagePreprocessor as we'll handle UIImage/CGImage directly - // private let imagePreprocessor = ImageProcessor() - - // Define keys for UserDefaults access (matching SettingsView) private enum StorageKeys { static let visionRecognitionLevel = "visionRecognitionLevel" static let visionUsesLanguageCorrection = "visionUsesLanguageCorrection" } - // Reuse CIContext for efficiency, similar to Cloud service - Removed, context is in ImagePreprocessor - // private let ciContext = CIContext() - - func extractText(from imageData: Data) async throws -> String { - // Create UIImage from data and delegate to optimized method - guard let uiImage = UIImage(data: imageData) else { - throw PreprocessingError.invalidImageData - } - return try await extractText(from: uiImage) - } - // MARK: - Optimized method for pre-processed images func extractText(from processedImage: UIImage) async throws -> String { - // 1. Get CGImage directly from processed UIImage guard let cgImage = processedImage.cgImage else { throw VisionError.preprocessingError(PreprocessingError.invalidImageData) } print("VisionService: Using pre-processed UIImage directly.") - // 2. Create a Vision Request (VNRecognizeTextRequest) let textRecognitionRequest = VNRecognizeTextRequest { (_, error) in - // This completion handler will be called on a background thread - if let error = error { - // This error is handled in the continuation below - print("Vision internal request error: \(error)") - } + if let error = error { print("Vision internal request error: \(error)") } } - // -- Apply settings from UserDefaults -- - let defaults = UserDefaults.standard + let defaults = SharedDefaults.suite + let levelString = defaults.string(forKey: StorageKeys.visionRecognitionLevel) ?? "accurate" + textRecognitionRequest.recognitionLevel = (levelString == "fast") ? .fast : .accurate + print("VisionService: Using recognition level: \(textRecognitionRequest.recognitionLevel == .fast ? "fast" : "accurate")") - // Recognition Level - let levelString = defaults.string(forKey: StorageKeys.visionRecognitionLevel) ?? "accurate" // Default to accurate - if levelString == "fast" { - textRecognitionRequest.recognitionLevel = .fast - print("VisionService: Using recognition level: fast") - } else { - textRecognitionRequest.recognitionLevel = .accurate - print("VisionService: Using recognition level: accurate") - } - - // Language Correction - let useCorrection = defaults.bool(forKey: StorageKeys.visionUsesLanguageCorrection) // Defaults to false if key doesn't exist, but Settings sets a default + let useCorrection = defaults.bool(forKey: StorageKeys.visionUsesLanguageCorrection) textRecognitionRequest.usesLanguageCorrection = useCorrection print("VisionService: Using language correction: \(useCorrection)") - // -- End Apply Settings -- - // 3. Create a Request Handler with the CGImage let requestHandler = VNImageRequestHandler(cgImage: cgImage, options: [:]) - // 4. Perform the Request Asynchronously return try await withCheckedThrowingContinuation { continuation in do { print("Performing Vision text recognition...") try requestHandler.perform([textRecognitionRequest]) print("Vision request performed.") - // Process results - guard let results = textRecognitionRequest.results, - !results.isEmpty else { + guard let results = textRecognitionRequest.results, !results.isEmpty else { print("Vision found no text observations.") continuation.resume(throwing: VisionError.noTextFound) return } - print("Found \(results.count) text observations.") - // Extract text let recognizedStrings = results.compactMap { $0.topCandidates(1).first?.string } let joinedText = recognizedStrings.joined(separator: "\n") print("Extracted text successfully.") continuation.resume(returning: joinedText) - } catch let handlerError { print("Vision request handler failed: \(handlerError)") continuation.resume(throwing: VisionError.requestHandlerFailed(handlerError))