diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 3c98aacd..b62098b3 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 7C3697892ED70F9C005874CE /* DynamicNotchKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7C3697882ED70F9C005874CE /* DynamicNotchKit */; }; 7C5AF14B2F15041600DE21B0 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = 7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */; }; 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */; }; + A1B2C3D42F70000100ABCDEF /* SmartNotesStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D52F70000100ABCDEF /* SmartNotesStoreTests.swift */; }; 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; @@ -35,6 +36,7 @@ 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; + A1B2C3D52F70000100ABCDEF /* SmartNotesStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartNotesStoreTests.swift; sourceTree = ""; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = ""; }; @@ -108,6 +110,7 @@ 7CDB0A272F3C4D5600FB7CAD /* Resources */, 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, + A1B2C3D52F70000100ABCDEF /* SmartNotesStoreTests.swift */, 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */, ); @@ -264,6 +267,7 @@ 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */, 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, + A1B2C3D42F70000100ABCDEF /* SmartNotesStoreTests.swift in Sources */, 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */, ); diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 26e617e6..5101c31e 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -56,6 +56,7 @@ enum SidebarItem: Hashable { case customDictionary case stats case history + case smartNotes case changelog case feedback case commandMode @@ -79,6 +80,7 @@ enum ShortcutRecordingTarget: Hashable { case edit case cancel case pasteLast + case smartNotes case dictationPrompt(String) case newPrompt @@ -96,6 +98,8 @@ enum ShortcutRecordingTarget: Hashable { return "Cancel Recording" case .pasteLast: return "Paste Last Transcription" + case .smartNotes: + return "Smart Notes" case .dictationPrompt: return "Prompt Shortcut" case .newPrompt: @@ -105,7 +109,7 @@ enum ShortcutRecordingTarget: Hashable { var enablesFeatureOnAssignment: Bool { switch self { - case .secondaryDictation, .command, .edit, .pasteLast: + case .secondaryDictation, .command, .edit, .pasteLast, .smartNotes: return true case .primaryDictation, .cancel, .dictationPrompt, .newPrompt: return false @@ -121,7 +125,7 @@ enum ShortcutRecordingTarget: Hashable { switch self { case .primaryDictation, .pasteLast: return true - case .secondaryDictation, .command, .edit, .cancel, .dictationPrompt, .newPrompt: + case .secondaryDictation, .command, .edit, .cancel, .smartNotes, .dictationPrompt, .newPrompt: return false } } @@ -155,6 +159,7 @@ struct ContentView: View { case promptMode case edit case command + case smartNotes } private enum DictationOutputRoute: String { @@ -192,7 +197,9 @@ struct ContentView: View { @State private var rewriteModeHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.rewriteModeHotkeyShortcut @State private var cancelRecordingHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.cancelRecordingHotkeyShortcut @State private var pasteLastTranscriptionHotkeyShortcut: HotkeyShortcut? = SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut + @State private var smartNotesHotkeyShortcut: HotkeyShortcut? = SettingsStore.shared.smartNotesHotkeyShortcut @State private var isPasteLastTranscriptionShortcutEnabled: Bool = SettingsStore.shared.pasteLastTranscriptionShortcutEnabled + @State private var isSmartNotesShortcutEnabled: Bool = SettingsStore.shared.smartNotesShortcutEnabled @State private var isPromptModeShortcutEnabled: Bool = SettingsStore.shared.promptModeShortcutEnabled @State private var isCommandModeShortcutEnabled: Bool = SettingsStore.shared.commandModeShortcutEnabled @State private var isRewriteModeShortcutEnabled: Bool = SettingsStore.shared.rewriteModeShortcutEnabled @@ -475,6 +482,13 @@ struct ContentView: View { .onChange(of: self.isPasteLastTranscriptionShortcutEnabled) { newValue in self.handlePasteLastTranscriptionShortcutEnabledChange(newValue) } + .onChange(of: self.smartNotesHotkeyShortcut) { _, newValue in + SettingsStore.shared.smartNotesHotkeyShortcut = newValue + self.hotkeyManager?.updateSmartNotesShortcut(newValue) + } + .onChange(of: self.isSmartNotesShortcutEnabled) { newValue in + self.handleSmartNotesShortcutEnabledChange(newValue) + } } private func handlePasteLastTranscriptionShortcutEnabledChange(_ isEnabled: Bool) { @@ -484,6 +498,21 @@ struct ContentView: View { } } + private func handleSmartNotesShortcutEnabledChange(_ isEnabled: Bool) { + SettingsStore.shared.smartNotesShortcutEnabled = isEnabled + self.hotkeyManager?.updateSmartNotesShortcutEnabled(isEnabled) + if !isEnabled, self.activeShortcutRecordingTarget == .smartNotes { + self.clearShortcutRecordingMode() + } + if !isEnabled, self.activeRecordingMode == .smartNotes { + if self.asr.isRunning { + Task { await self.asr.stopWithoutTranscription() } + } + self.clearActiveRecordingMode() + self.menuBarManager.setOverlayMode(.dictation) + } + } + private func handlePromptShortcutEnabledChange(_ isEnabled: Bool) { SettingsStore.shared.promptModeShortcutEnabled = isEnabled self.hotkeyManager?.updatePromptModeShortcutEnabled(isEnabled) @@ -1028,6 +1057,7 @@ struct ContentView: View { let optionalConfiguredShortcuts: [(ShortcutRecordingTarget, HotkeyShortcut?)] = [ (.command, self.commandModeHotkeyShortcut), (.pasteLast, self.pasteLastTranscriptionHotkeyShortcut), + (.smartNotes, self.smartNotesHotkeyShortcut), ] for (otherTarget, configuredShortcut) in configuredShortcuts where otherTarget != target { @@ -1097,6 +1127,10 @@ struct ContentView: View { // The hotkey manager reads this shortcut directly from SettingsStore, so no manager update is needed. self.pasteLastTranscriptionHotkeyShortcut = shortcut SettingsStore.shared.pasteLastTranscriptionHotkeyShortcut = shortcut + case .smartNotes: + self.smartNotesHotkeyShortcut = shortcut + SettingsStore.shared.smartNotesHotkeyShortcut = shortcut + self.hotkeyManager?.updateSmartNotesShortcut(shortcut) case let .dictationPrompt(key): guard let selection = SettingsStore.shared.dictationPromptSelection(forConfigurationKey: key) else { return } var configuration = SettingsStore.shared.dictationPromptConfiguration(for: selection) @@ -1144,6 +1178,10 @@ struct ContentView: View { case .pasteLast: self.isPasteLastTranscriptionShortcutEnabled = enabled SettingsStore.shared.pasteLastTranscriptionShortcutEnabled = enabled + case .smartNotes: + self.isSmartNotesShortcutEnabled = enabled + SettingsStore.shared.smartNotesShortcutEnabled = enabled + self.hotkeyManager?.updateSmartNotesShortcutEnabled(enabled) case .primaryDictation, .cancel, .dictationPrompt, .newPrompt: break } @@ -1181,6 +1219,7 @@ struct ContentView: View { Section { self.sidebarNavigationLink(.commandMode, title: "Command Mode", systemImage: "terminal.fill") + self.sidebarNavigationLink(.smartNotes, title: "Smart Notes", systemImage: "note.text") self.sidebarNavigationLink(.meetingTools, title: "File Transcription", systemImage: "doc.text.fill") } header: { self.sidebarSectionHeader("Use") @@ -1295,6 +1334,8 @@ struct ContentView: View { return AnyView(self.rewriteModeView) case .history: return AnyView(TranscriptionHistoryView()) + case .smartNotes: + return AnyView(SmartNotesView()) } } @@ -1475,9 +1516,11 @@ struct ContentView: View { rewriteShortcut: self.$rewriteModeHotkeyShortcut, cancelRecordingShortcut: self.$cancelRecordingHotkeyShortcut, pasteLastTranscriptionShortcut: self.$pasteLastTranscriptionHotkeyShortcut, + smartNotesShortcut: self.$smartNotesHotkeyShortcut, commandModeShortcutEnabled: self.$isCommandModeShortcutEnabled, rewriteShortcutEnabled: self.$isRewriteModeShortcutEnabled, pasteLastTranscriptionShortcutEnabled: self.$isPasteLastTranscriptionShortcutEnabled, + smartNotesShortcutEnabled: self.$isSmartNotesShortcutEnabled, hotkeyManagerInitialized: self.$hotkeyManagerInitialized, hotkeyMode: self.$hotkeyMode, enableStreamingPreview: self.$enableStreamingPreview, @@ -2064,10 +2107,11 @@ struct ContentView: View { DebugLogger.shared.info("Output route selected: \(route.rawValue)", source: "ContentView") self.appBench("stop_path_enter route=\(route.rawValue)") - // Check if we're in rewrite or command mode + // Check if we're in a routed recording mode. let modeAtStop = self.activeRecordingMode let wasRewriteMode = modeAtStop == .edit || self.isRecordingForRewrite let wasCommandMode = modeAtStop == .command || self.isRecordingForCommand + let wasSmartNotesMode = modeAtStop == .smartNotes let activeDictationSlot = self.currentDictationShortcutSlot(for: modeAtStop) let promptOverride = self.promptModeOverrideText let promptTest = DictationPromptTestCoordinator.shared @@ -2077,6 +2121,7 @@ struct ContentView: View { let shouldHideOverlayOnStop = route == .normal && !wasRewriteMode && !wasCommandMode && + !wasSmartNotesMode && !promptTest.isActive && !shouldUseAIOnStop var didRequestOverlayHideOnStop = false @@ -2133,6 +2178,13 @@ struct ContentView: View { return } + // Smart Notes is a dedicated output route and must never be redirected into + // prompt testing, typing, clipboard, or transcription history. + if wasSmartNotesMode { + await self.captureSmartNote(from: transcribedText) + return + } + // Prompt Test Mode: reroute dictation hotkey output into the prompt editor (no typing/clipboard/history). if promptTest.isActive { promptTest.lastTranscriptionText = transcribedText @@ -2955,13 +3007,62 @@ struct ContentView: View { } } + private func captureSmartNote(from transcript: String) async { + self.menuBarManager.setProcessing(true) + NotchOverlayManager.shared.updateTranscriptionText("Saving note") + + do { + let note = try SmartNotesStore.shared.capture(rawText: transcript) + + if SettingsStore.shared.smartNotesAIEnhancementEnabled { + NotchOverlayManager.shared.updateTranscriptionText("Organizing note") + do { + let response = try await self.processTextWithAI( + transcript, + overrideSystemPrompt: Self.smartNotesEnhancementPrompt + ) + let enhancement = try SmartNoteEnhancement.parseAIResponse(response) + try SmartNotesStore.shared.apply(enhancement, to: note.id) + } catch { + DebugLogger.shared.error( + "Smart Notes AI enrichment failed; raw note preserved: \(error.localizedDescription)", + source: "ContentView" + ) + NotificationService.showSmartNotesFallback(error: error.localizedDescription) + } + } + + NotchOverlayManager.shared.updateTranscriptionText("Note saved") + try? await Task.sleep(nanoseconds: 450_000_000) + } catch { + DebugLogger.shared.error( + "Smart Note save failed: \(error.localizedDescription)", + source: "ContentView" + ) + NotchOverlayManager.shared.updateTranscriptionText("Note could not be saved") + try? await Task.sleep(nanoseconds: 900_000_000) + } + + await self.menuBarManager.finishProcessingAndHideOverlay() + } + + private static let smartNotesEnhancementPrompt = """ + Turn the voice transcript into a useful structured note without inventing facts or adding advice. + Remove fillers and false starts, preserve the speaker's meaning, and use concise Markdown in the body. + Choose one short category and up to eight lowercase tags. + + Return only valid JSON with exactly this shape: + {"title":"Short descriptive title","category":"Category","tags":["tag"],"body":"Markdown note body"} + Do not wrap the JSON in a code fence. + """ + private func setActiveRecordingMode(_ mode: ActiveRecordingMode) { if mode != .dictate, mode != .promptMode { self.clearActiveDictationShortcutState() } self.activeRecordingMode = mode switch mode { - case .none, .dictate, .promptMode: + case .none, .dictate, .promptMode, .smartNotes: self.isRecordingForCommand = false self.isRecordingForRewrite = false case .edit: @@ -3282,10 +3383,12 @@ struct ContentView: View { promptModeShortcut: self.promptModeHotkeyShortcut, commandModeShortcut: self.commandModeHotkeyShortcut, rewriteModeShortcut: self.rewriteModeHotkeyShortcut, + smartNotesShortcut: self.smartNotesHotkeyShortcut, promptShortcutAssignments: SettingsStore.shared.dictationPromptShortcutAssignments(), promptModeShortcutEnabled: self.isPromptModeShortcutEnabled, commandModeShortcutEnabled: self.isCommandModeShortcutEnabled, rewriteModeShortcutEnabled: self.isRewriteModeShortcutEnabled, + smartNotesShortcutEnabled: self.isSmartNotesShortcutEnabled, startRecordingCallback: { DebugLogger.shared.debug("ContentView: startRecordingCallback invoked by hotkey", source: "ContentView") self.startRecording() @@ -3372,6 +3475,23 @@ struct ContentView: View { await self.asr.start() } }, + smartNotesCallback: { + self.captureRecordingContext() + self.setActiveRecordingMode(.smartNotes) + self.menuBarManager.setOverlayMode(.dictation) + + guard !self.asr.isRunning else { return } + self.advanceOverlayLifecycle() + TranscriptionSoundPlayer.shared.playStartSound() + Task { + await self.asr.start(onCaptureStarted: { + self.menuBarManager.showRecordingOverlayImmediately() + }) + if !self.asr.isRunning { + self.menuBarManager.hideRecordingOverlayImmediately(reason: "smart_notes_start_failed") + } + } + }, isDictateRecordingProvider: { self.activeRecordingMode == .dictate }, @@ -3384,6 +3504,9 @@ struct ContentView: View { isRewriteRecordingProvider: { self.activeRecordingMode == .edit }, + isSmartNotesRecordingProvider: { + self.activeRecordingMode == .smartNotes + }, isShortcutCaptureActiveProvider: { self.isRecordingAnyShortcutCapture } @@ -3616,7 +3739,7 @@ extension ContentView { return self.activeDictationShortcutSlot ?? .primary case .promptMode: return self.activeDictationShortcutSlot ?? .secondary - case .none, .edit, .command: + case .none, .edit, .command, .smartNotes: return nil } } diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 715b2525..58db0838 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -2499,6 +2499,49 @@ final class SettingsStore: ObservableObject { } } + // MARK: - Smart Notes Settings + + /// Smart Notes is opt-in so existing shortcut configurations cannot gain a collision on upgrade. + var smartNotesShortcutEnabled: Bool { + get { + let value = self.defaults.object(forKey: Keys.smartNotesShortcutEnabled) + return value as? Bool ?? false + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.smartNotesShortcutEnabled) + } + } + + var smartNotesHotkeyShortcut: HotkeyShortcut? { + get { + guard let data = defaults.data(forKey: Keys.smartNotesHotkeyShortcut) else { return nil } + return try? JSONDecoder().decode(HotkeyShortcut.self, from: data) + } + set { + objectWillChange.send() + guard let newValue else { + self.defaults.removeObject(forKey: Keys.smartNotesHotkeyShortcut) + return + } + if let data = try? JSONEncoder().encode(newValue) { + self.defaults.set(data, forKey: Keys.smartNotesHotkeyShortcut) + } + } + } + + /// AI enrichment is deliberately independent from capture and off by default. + var smartNotesAIEnhancementEnabled: Bool { + get { + let value = self.defaults.object(forKey: Keys.smartNotesAIEnhancementEnabled) + return value as? Bool ?? false + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.smartNotesAIEnhancementEnabled) + } + } + var commandModeConfirmBeforeExecute: Bool { get { // Default to true (safer - ask before running commands) @@ -4500,6 +4543,9 @@ private extension SettingsStore { static let cancelRecordingHotkeyShortcut = "CancelRecordingHotkeyShortcut" static let pasteLastTranscriptionHotkeyShortcut = "PasteLastTranscriptionHotkeyShortcut" static let pasteLastTranscriptionShortcutEnabled = "PasteLastTranscriptionShortcutEnabled" + static let smartNotesHotkeyShortcut = "SmartNotesHotkeyShortcut" + static let smartNotesShortcutEnabled = "SmartNotesShortcutEnabled" + static let smartNotesAIEnhancementEnabled = "SmartNotesAIEnhancementEnabled" static let commandModeLinkedToGlobal = "CommandModeLinkedToGlobal" static let commandModeShortcutEnabled = "CommandModeShortcutEnabled" diff --git a/Sources/Fluid/Persistence/SmartNotesStore.swift b/Sources/Fluid/Persistence/SmartNotesStore.swift new file mode 100644 index 00000000..5d70c27d --- /dev/null +++ b/Sources/Fluid/Persistence/SmartNotesStore.swift @@ -0,0 +1,288 @@ +import Combine +import Foundation + +struct SmartNote: Identifiable, Equatable { + let id: UUID + let createdAt: Date + var title: String + var category: String? + var tags: [String] + var body: String + var isAIEnhanced: Bool + let fileURL: URL +} + +struct SmartNoteEnhancement: Decodable, Equatable { + let title: String + let category: String + let tags: [String] + let body: String + + static func parseAIResponse(_ response: String) throws -> SmartNoteEnhancement { + guard let openingBrace = response.firstIndex(of: "{"), + let closingBrace = response.lastIndex(of: "}"), + openingBrace <= closingBrace + else { + throw SmartNotesError.invalidAIResponse + } + + let json = String(response[openingBrace...closingBrace]) + guard let data = json.data(using: .utf8) else { + throw SmartNotesError.invalidAIResponse + } + + do { + let decoded = try JSONDecoder().decode(Self.self, from: data) + let title = decoded.title.trimmingCharacters(in: .whitespacesAndNewlines) + let body = decoded.body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty, !body.isEmpty else { + throw SmartNotesError.invalidAIResponse + } + return Self( + title: String(title.prefix(120)), + category: String(decoded.category.trimmingCharacters(in: .whitespacesAndNewlines).prefix(80)), + tags: Array(decoded.tags + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + .uniqued() + .prefix(8)), + body: body + ) + } catch let error as SmartNotesError { + throw error + } catch { + throw SmartNotesError.invalidAIResponse + } + } +} + +enum SmartNotesError: LocalizedError { + case emptyTranscript + case noteNotFound + case invalidAIResponse + + var errorDescription: String? { + switch self { + case .emptyTranscript: + return "The note transcript was empty." + case .noteNotFound: + return "The note could not be found." + case .invalidAIResponse: + return "AI returned an invalid note structure." + } + } +} + +@MainActor +final class SmartNotesStore: ObservableObject { + static let shared = SmartNotesStore() + + @Published private(set) var notes: [SmartNote] = [] + + let notesDirectoryURL: URL + private let fileManager: FileManager + + init(directoryURL: URL? = nil, fileManager: FileManager = .default) { + self.fileManager = fileManager + self.notesDirectoryURL = directoryURL ?? Self.defaultNotesDirectory(fileManager: fileManager) + self.reload() + } + + @discardableResult + func capture(rawText: String, at date: Date = Date(), id: UUID = UUID()) throws -> SmartNote { + let body = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !body.isEmpty else { throw SmartNotesError.emptyTranscript } + + try self.ensureDirectoryExists() + let note = SmartNote( + id: id, + createdAt: date, + title: Self.defaultTitle(for: body), + category: nil, + tags: [], + body: body, + isAIEnhanced: false, + fileURL: self.notesDirectoryURL.appendingPathComponent(Self.fileName(for: date, id: id)) + ) + try self.write(note) + self.reload() + return note + } + + @discardableResult + func apply(_ enhancement: SmartNoteEnhancement, to noteID: UUID) throws -> SmartNote { + guard var note = self.notes.first(where: { $0.id == noteID }) else { + throw SmartNotesError.noteNotFound + } + + note.title = enhancement.title + note.category = enhancement.category.isEmpty ? nil : enhancement.category + note.tags = enhancement.tags + note.body = enhancement.body + note.isAIEnhanced = true + try self.write(note) + self.reload() + return note + } + + func delete(_ note: SmartNote) throws { + try self.fileManager.removeItem(at: note.fileURL) + self.reload() + } + + func reload() { + do { + try self.ensureDirectoryExists() + let urls = try self.fileManager.contentsOfDirectory( + at: self.notesDirectoryURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + self.notes = urls + .filter { $0.pathExtension.lowercased() == "md" } + .compactMap { try? self.read(from: $0) } + .sorted { $0.createdAt > $1.createdAt } + } catch { + DebugLogger.shared.error( + "Unable to load Smart Notes: \(error.localizedDescription)", + source: "SmartNotesStore" + ) + self.notes = [] + } + } + + private static func defaultNotesDirectory(fileManager: FileManager) -> URL { + let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first + ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Documents", isDirectory: true) + return documents.appendingPathComponent("FluidVoice Notes", isDirectory: true) + } + + private func ensureDirectoryExists() throws { + try self.fileManager.createDirectory( + at: self.notesDirectoryURL, + withIntermediateDirectories: true + ) + } + + private func write(_ note: SmartNote) throws { + let contents = try Self.markdown(for: note) + try contents.write(to: note.fileURL, atomically: true, encoding: .utf8) + } + + private func read(from url: URL) throws -> SmartNote { + let contents = try String(contentsOf: url, encoding: .utf8) + guard contents.hasPrefix("---\n"), + let closingRange = contents.range(of: "\n---\n", range: contents.index(contents.startIndex, offsetBy: 4).. String { + let id = try self.json(note.id.uuidString) + let createdAt = try self.json(self.iso8601.string(from: note.createdAt)) + let title = try self.json(note.title) + let category = try self.json(note.category ?? "") + let tags = try self.json(note.tags) + return """ + --- + id: \(id) + createdAt: \(createdAt) + title: \(title) + category: \(category) + tags: \(tags) + enhanced: \(note.isAIEnhanced) + --- + + # \(note.title) + + \(note.body) + """ + } + + private static func parseMetadata(_ text: String) -> [String: String] { + text.split(separator: "\n").reduce(into: [:]) { result, line in + guard let separator = line.firstIndex(of: ":") else { return } + let key = line[.. [String] { + guard let value, let data = value.data(using: .utf8) else { return [] } + return (try? JSONDecoder().decode([String].self, from: data)) ?? [] + } + + private static func json(_ value: T) throws -> String { + let data = try JSONEncoder().encode(value) + return String(decoding: data, as: UTF8.self) + } + + private static func defaultTitle(for text: String) -> String { + let words = text + .replacingOccurrences(of: "\n", with: " ") + .split(whereSeparator: { $0.isWhitespace }) + let title = words.prefix(9).joined(separator: " ") + if title.count <= 72 { return title } + return String(title.prefix(69)) + "..." + } + + private static func fileName(for date: Date, id: UUID) -> String { + "\(self.fileNameDate.string(from: date))-\(id.uuidString.prefix(8).lowercased()).md" + } + + private static let iso8601: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let fileNameDate: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd-HHmmss" + return formatter + }() +} + +private extension Sequence where Element: Hashable { + func uniqued() -> [Element] { + var seen: Set = [] + return self.filter { seen.insert($0).inserted } + } +} diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index e974e41f..5224dbab 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -6,6 +6,7 @@ private nonisolated enum HotkeyHoldModeType: Hashable { case promptMode case commandMode case rewriteMode + case smartNotes case promptAssignment } @@ -20,6 +21,7 @@ private final nonisolated class HotkeyState: @unchecked Sendable { var isPromptModeKeyPressed = false var isCommandModeKeyPressed = false var isRewriteKeyPressed = false + var isSmartNotesKeyPressed = false var isPromptAssignmentKeyPressed = false var pressedModifierKeyCodes: Set = [] var modifierOnlyKeyDown = false @@ -51,10 +53,12 @@ final class GlobalHotkeyManager: NSObject { private var promptModeShortcut: HotkeyShortcut private var commandModeShortcut: HotkeyShortcut? private var rewriteModeShortcut: HotkeyShortcut + private var smartNotesShortcut: HotkeyShortcut? private var promptShortcutAssignments: [(selection: SettingsStore.DictationPromptSelection, shortcut: HotkeyShortcut)] private var promptModeShortcutEnabled: Bool private var commandModeShortcutEnabled: Bool private var rewriteModeShortcutEnabled: Bool + private var smartNotesShortcutEnabled: Bool private var startRecordingCallback: (() async -> Void)? private var dictationModeCallback: (() async -> Void)? private var stopAndProcessCallback: (() async -> Void)? @@ -62,10 +66,12 @@ final class GlobalHotkeyManager: NSObject { private var promptSelectionCallback: ((SettingsStore.DictationPromptSelection) async -> Void)? private var commandModeCallback: (() async -> Void)? private var rewriteModeCallback: (() async -> Void)? + private var smartNotesCallback: (() async -> Void)? private var isDictateRecordingProvider: (() -> Bool)? private var isPromptModeRecordingProvider: (() -> Bool)? private var isCommandRecordingProvider: (() -> Bool)? private var isRewriteRecordingProvider: (() -> Bool)? + private var isSmartNotesRecordingProvider: (() -> Bool)? private var isShortcutCaptureActiveProvider: (() -> Bool)? private var cancelCallback: (() -> Bool)? // Returns true if handled private var pasteLastTranscriptionCallback: (() -> Void)? @@ -112,6 +118,11 @@ final class GlobalHotkeyManager: NSObject { set { self.state.withLock { self.state.isRewriteKeyPressed = newValue } } } + private nonisolated var isSmartNotesKeyPressed: Bool { + get { self.state.withLock { self.state.isSmartNotesKeyPressed } } + set { self.state.withLock { self.state.isSmartNotesKeyPressed = newValue } } + } + private nonisolated var isPromptAssignmentKeyPressed: Bool { get { self.state.withLock { self.state.isPromptAssignmentKeyPressed } } set { self.state.withLock { self.state.isPromptAssignmentKeyPressed = newValue } } @@ -274,10 +285,12 @@ final class GlobalHotkeyManager: NSObject { promptModeShortcut: HotkeyShortcut, commandModeShortcut: HotkeyShortcut?, rewriteModeShortcut: HotkeyShortcut, + smartNotesShortcut: HotkeyShortcut?, promptShortcutAssignments: [(selection: SettingsStore.DictationPromptSelection, shortcut: HotkeyShortcut)] = [], promptModeShortcutEnabled: Bool, commandModeShortcutEnabled: Bool, rewriteModeShortcutEnabled: Bool, + smartNotesShortcutEnabled: Bool, startRecordingCallback: (() async -> Void)? = nil, dictationModeCallback: (() async -> Void)? = nil, stopAndProcessCallback: (() async -> Void)? = nil, @@ -285,10 +298,12 @@ final class GlobalHotkeyManager: NSObject { promptSelectionCallback: ((SettingsStore.DictationPromptSelection) async -> Void)? = nil, commandModeCallback: (() async -> Void)? = nil, rewriteModeCallback: (() async -> Void)? = nil, + smartNotesCallback: (() async -> Void)? = nil, isDictateRecordingProvider: (() -> Bool)? = nil, isPromptModeRecordingProvider: (() -> Bool)? = nil, isCommandRecordingProvider: (() -> Bool)? = nil, isRewriteRecordingProvider: (() -> Bool)? = nil, + isSmartNotesRecordingProvider: (() -> Bool)? = nil, isShortcutCaptureActiveProvider: (() -> Bool)? = nil ) { self.asrService = asrService @@ -296,10 +311,12 @@ final class GlobalHotkeyManager: NSObject { self.promptModeShortcut = promptModeShortcut self.commandModeShortcut = commandModeShortcut self.rewriteModeShortcut = rewriteModeShortcut + self.smartNotesShortcut = smartNotesShortcut self.promptShortcutAssignments = promptShortcutAssignments self.promptModeShortcutEnabled = promptModeShortcutEnabled self.commandModeShortcutEnabled = commandModeShortcutEnabled self.rewriteModeShortcutEnabled = rewriteModeShortcutEnabled + self.smartNotesShortcutEnabled = smartNotesShortcutEnabled self.startRecordingCallback = startRecordingCallback self.dictationModeCallback = dictationModeCallback self.stopAndProcessCallback = stopAndProcessCallback @@ -307,10 +324,12 @@ final class GlobalHotkeyManager: NSObject { self.promptSelectionCallback = promptSelectionCallback self.commandModeCallback = commandModeCallback self.rewriteModeCallback = rewriteModeCallback + self.smartNotesCallback = smartNotesCallback self.isDictateRecordingProvider = isDictateRecordingProvider self.isPromptModeRecordingProvider = isPromptModeRecordingProvider self.isCommandRecordingProvider = isCommandRecordingProvider self.isRewriteRecordingProvider = isRewriteRecordingProvider + self.isSmartNotesRecordingProvider = isSmartNotesRecordingProvider self.isShortcutCaptureActiveProvider = isShortcutCaptureActiveProvider super.init() @@ -356,6 +375,11 @@ final class GlobalHotkeyManager: NSObject { DebugLogger.shared.info("Updated rewrite mode hotkey", source: "GlobalHotkeyManager") } + func updateSmartNotesShortcut(_ newShortcut: HotkeyShortcut?) { + self.smartNotesShortcut = newShortcut + DebugLogger.shared.info("Updated Smart Notes hotkey", source: "GlobalHotkeyManager") + } + func updateCommandModeShortcutEnabled(_ enabled: Bool) { self.commandModeShortcutEnabled = enabled if !enabled { @@ -378,6 +402,17 @@ final class GlobalHotkeyManager: NSObject { ) } + func updateSmartNotesShortcutEnabled(_ enabled: Bool) { + self.smartNotesShortcutEnabled = enabled + if !enabled { + self.isSmartNotesKeyPressed = false + } + DebugLogger.shared.info( + "Smart Notes shortcut \(enabled ? "enabled" : "disabled")", + source: "GlobalHotkeyManager" + ) + } + func setPromptModeCallback(_ callback: @escaping () async -> Void) { self.promptModeCallback = callback } @@ -754,6 +789,52 @@ final class GlobalHotkeyManager: NSObject { return nil } + // Check Smart Notes hotkey + if self.smartNotesShortcutEnabled, + let smartNotesShortcut = self.smartNotesShortcut, + smartNotesShortcut.matches(keyCode: keyCode, modifiers: eventModifiers) + { + switch self.hotkeyMode { + case .hold: + if !self.isSmartNotesKeyPressed { + self.cancelPendingReleaseStop(for: .smartNotes) + self.clearHoldModeStartTriggered(for: .smartNotes) + self.isSmartNotesKeyPressed = true + DebugLogger.shared.info("Smart Notes shortcut pressed (hold mode) - starting", source: "GlobalHotkeyManager") + self.triggerSmartNotes() + self.markHoldModeStartTriggered(for: .smartNotes) + } + case .automatic: + if !self.isSmartNotesKeyPressed { + self.isSmartNotesKeyPressed = true + let isSameMode = self.asrService.isRunning && (self.isSmartNotesRecordingProvider?() ?? false) + self.beginAutomaticPress(for: .smartNotes, wasTargetActive: isSameMode) + if self.asrService.isRunning { + if isSameMode { + DebugLogger.shared.info("Smart Notes shortcut pressed (automatic, same mode) - waiting for release", source: "GlobalHotkeyManager") + } else { + self.triggerSmartNotes() + self.markAutomaticPressStarted(for: .smartNotes) + } + } else { + self.triggerSmartNotes() + self.markAutomaticPressStarted(for: .smartNotes) + } + } + case .toggle: + if self.asrService.isRunning { + if self.isSmartNotesRecordingProvider?() ?? false { + self.stopRecordingIfNeeded() + } else { + self.triggerSmartNotes() + } + } else { + self.triggerSmartNotes() + } + } + return nil + } + // Check dedicated rewrite mode hotkey if self.rewriteModeShortcutEnabled { if self.rewriteModeShortcut.matches(keyCode: keyCode, modifiers: eventModifiers) { @@ -839,6 +920,26 @@ final class GlobalHotkeyManager: NSObject { return nil } + // Smart Notes key up + if self.smartNotesShortcutEnabled, + self.isSmartNotesKeyPressed, + let smartNotesShortcut = self.smartNotesShortcut, + keyCode == smartNotesShortcut.keyCode + { + switch self.hotkeyMode { + case .hold: + self.isSmartNotesKeyPressed = false + _ = self.finishHoldModeStartTriggered(for: .smartNotes) + self.stopRecordingAfterRelease(for: .smartNotes, label: "Smart Notes") + case .automatic: + self.isSmartNotesKeyPressed = false + self.handleAutomaticKeyRelease(for: .smartNotes, label: "Smart Notes") + case .toggle: + break + } + return nil + } + // Rewrite mode key up // Note: Only check keyCode, not modifiers - user may release modifier before/with main key if self.rewriteModeShortcutEnabled, self.isRewriteKeyPressed, keyCode == self.rewriteModeShortcut.keyCode { @@ -949,6 +1050,36 @@ final class GlobalHotkeyManager: NSObject { ) { return nil } + if let smartNotesShortcut = self.smartNotesShortcut, + self.handleModifierOnlyShortcutFlagsChanged( + behavior: .init( + shortcut: smartNotesShortcut, + isEnabled: self.smartNotesShortcutEnabled, + holdModeType: .smartNotes, + holdStartMessage: "Smart Notes modifier held (hold mode) - starting", + holdReleaseMessage: "Smart Notes modifier released (hold mode) - stopping", + toggleIgnoredMessage: "Smart Notes modifier released but another key was pressed - ignoring", + isModeKeyPressed: { self.isSmartNotesKeyPressed }, + setModeKeyPressed: { self.isSmartNotesKeyPressed = $0 }, + onHoldStart: { self.triggerSmartNotes() }, + onToggleRelease: { + if self.asrService.isRunning { + if self.isSmartNotesRecordingProvider?() ?? false { + self.stopRecordingIfNeeded() + } else { + self.triggerSmartNotes() + } + } else { + self.triggerSmartNotes() + } + }, + isTargetModeActive: { self.isSmartNotesRecordingProvider?() ?? false } + ), + keyCode: keyCode, + modifiers: eventModifiers + ) + { return nil } + if self.handleModifierOnlyShortcutFlagsChanged( behavior: .init( shortcut: self.rewriteModeShortcut, @@ -1198,6 +1329,9 @@ final class GlobalHotkeyManager: NSObject { case .rewriteMode: guard let provider = self.isRewriteRecordingProvider else { return true } return provider() + case .smartNotes: + guard let provider = self.isSmartNotesRecordingProvider else { return true } + return provider() case .promptAssignment: guard let provider = self.isPromptModeRecordingProvider else { return true } return provider() @@ -1259,6 +1393,8 @@ final class GlobalHotkeyManager: NSObject { return "Command mode" case .rewriteMode: return "Rewrite mode" + case .smartNotes: + return "Smart Notes" case .promptAssignment: return "Prompt shortcut" } @@ -1332,7 +1468,7 @@ final class GlobalHotkeyManager: NSObject { func resetModifierOnlyShortcutTracking(reason: ModifierTrackingResetReason = .shortcutCapture) { let shouldStopActiveHold = self.hotkeyMode != .toggle && self.asrService.isRunning - && (self.isKeyPressed || self.isPromptModeKeyPressed || self.isCommandModeKeyPressed || self.isRewriteKeyPressed || self.isPromptAssignmentKeyPressed) + && (self.isKeyPressed || self.isPromptModeKeyPressed || self.isCommandModeKeyPressed || self.isRewriteKeyPressed || self.isSmartNotesKeyPressed || self.isPromptAssignmentKeyPressed) self.pressedModifierKeyCodes = [] self.modifierOnlyKeyDown = false @@ -1344,6 +1480,7 @@ final class GlobalHotkeyManager: NSObject { self.isPromptModeKeyPressed = false self.isCommandModeKeyPressed = false self.isRewriteKeyPressed = false + self.isSmartNotesKeyPressed = false self.isPromptAssignmentKeyPressed = false self.activePrimaryShortcutPress = nil @@ -1635,6 +1772,15 @@ final class GlobalHotkeyManager: NSObject { } } + private func triggerSmartNotes() { + Task { @MainActor [weak self] in + guard let self else { return } + guard self.canTriggerRecordingAction("Smart Notes hotkey") else { return } + DebugLogger.shared.info("Smart Notes hotkey triggered", source: "GlobalHotkeyManager") + await self.smartNotesCallback?() + } + } + /// Handles a mouse-button down event against the configured mouse shortcuts. Returns true when /// the event was consumed. "Paste Last Transcription" is a one-shot trigger (mirrors the keyboard /// path); primary dictation begins a press here and ends it on mouse-up. @@ -1727,7 +1873,7 @@ final class GlobalHotkeyManager: NSObject { func setHotkeyMode(_ mode: HotkeyActivationMode) { let shouldStopActivePress = self.hotkeyMode != .toggle && self.asrService.isRunning - && (self.isKeyPressed || self.isPromptModeKeyPressed || self.isCommandModeKeyPressed || self.isRewriteKeyPressed || self.isPromptAssignmentKeyPressed) + && (self.isKeyPressed || self.isPromptModeKeyPressed || self.isCommandModeKeyPressed || self.isRewriteKeyPressed || self.isSmartNotesKeyPressed || self.isPromptAssignmentKeyPressed) self.hotkeyMode = mode self.clearAutomaticPressTracking() @@ -1735,6 +1881,7 @@ final class GlobalHotkeyManager: NSObject { self.isPromptModeKeyPressed = false self.isCommandModeKeyPressed = false self.isRewriteKeyPressed = false + self.isSmartNotesKeyPressed = false self.isPromptAssignmentKeyPressed = false self.activePrimaryShortcutPress = nil diff --git a/Sources/Fluid/Services/NotificationService.swift b/Sources/Fluid/Services/NotificationService.swift index a5bbdbb5..f1e3cf1c 100644 --- a/Sources/Fluid/Services/NotificationService.swift +++ b/Sources/Fluid/Services/NotificationService.swift @@ -9,6 +9,7 @@ enum NotificationService { enum Kind { static let aiProcessingFallback = "aiProcessingFallback" static let commandModeFailure = "commandModeFailure" + static let smartNotesFallback = "smartNotesFallback" } static func showAIProcessingFallback(error: String) { @@ -71,6 +72,33 @@ enum NotificationService { } } + static func showSmartNotesFallback(error: String) { + guard SettingsStore.shared.notifyAIProcessingFailures else { return } + + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + self.deliverSmartNotesFallback(error: error, using: center) + case .notDetermined: + center.requestAuthorization(options: [.alert]) { granted, requestError in + if let requestError { + DebugLogger.shared.warning( + "Notification permission request failed: \(requestError.localizedDescription)", + source: "NotificationService" + ) + } + guard granted else { return } + self.deliverSmartNotesFallback(error: error, using: center) + } + case .denied: + break + @unknown default: + break + } + } + } + private static func deliverAIProcessingFallback(error: String, using center: UNUserNotificationCenter) { let content = UNMutableNotificationContent() content.title = "AI Enhancement failed" @@ -117,4 +145,27 @@ enum NotificationService { } } } + + private static func deliverSmartNotesFallback(error: String, using center: UNUserNotificationCenter) { + let content = UNMutableNotificationContent() + content.title = "Smart Note saved without AI" + content.body = "The raw transcript is safe in your notes folder." + content.subtitle = error + content.sound = nil + content.userInfo = [UserInfoKey.kind: Kind.smartNotesFallback] + + let request = UNNotificationRequest( + identifier: "smart-notes-fallback-\(UUID().uuidString)", + content: content, + trigger: nil + ) + center.add(request) { addError in + if let addError { + DebugLogger.shared.warning( + "Failed to show Smart Notes fallback notification: \(addError.localizedDescription)", + source: "NotificationService" + ) + } + } + } } diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 5b551d40..4c70f2ba 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -41,9 +41,11 @@ struct SettingsView: View { @Binding var rewriteShortcut: HotkeyShortcut @Binding var cancelRecordingShortcut: HotkeyShortcut @Binding var pasteLastTranscriptionShortcut: HotkeyShortcut? + @Binding var smartNotesShortcut: HotkeyShortcut? @Binding var commandModeShortcutEnabled: Bool @Binding var rewriteShortcutEnabled: Bool @Binding var pasteLastTranscriptionShortcutEnabled: Bool + @Binding var smartNotesShortcutEnabled: Bool @Binding var hotkeyManagerInitialized: Bool @Binding var hotkeyMode: HotkeyActivationMode @Binding var enableStreamingPreview: Bool @@ -742,6 +744,34 @@ struct SettingsView: View { ) Divider().opacity(0.2).padding(.vertical, 4) + self.shortcutRow( + content: .init( + icon: "note.text", + iconColor: .secondary, + title: "Smart Notes", + description: "Dictate a Markdown note without typing into the active app" + ), + shortcut: self.smartNotesShortcut, + isRecording: self.isRecording(.smartNotes), + isAnyRecordingActive: self.isRecordingAnyShortcut, + recordingMessage: self.isRecording(.smartNotes) ? self.shortcutRecordingMessage : nil, + isEnabled: self.$smartNotesShortcutEnabled, + requiresShortcutToEnable: true, + onChangePressed: { + self.shortcutRecordingMessage = nil + self.activeShortcutRecordingTarget = .smartNotes + }, + onRemovePressed: { + if self.activeShortcutRecordingTarget == .smartNotes { + self.shortcutRecordingMessage = nil + self.activeShortcutRecordingTarget = nil + } + self.smartNotesShortcut = nil + self.smartNotesShortcutEnabled = false + } + ) + Divider().opacity(0.2).padding(.vertical, 4) + self.shortcutRow( content: .init( icon: "xmark.circle.fill", diff --git a/Sources/Fluid/UI/SmartNotesView.swift b/Sources/Fluid/UI/SmartNotesView.swift new file mode 100644 index 00000000..1d2ed77a --- /dev/null +++ b/Sources/Fluid/UI/SmartNotesView.swift @@ -0,0 +1,199 @@ +import AppKit +import SwiftUI + +struct SmartNotesView: View { + @ObservedObject private var store = SmartNotesStore.shared + @ObservedObject private var settings = SettingsStore.shared + @State private var selectedNoteID: UUID? + @State private var notePendingDeletion: SmartNote? + + private var selectedNote: SmartNote? { + guard let selectedNoteID else { return self.store.notes.first } + return self.store.notes.first(where: { $0.id == selectedNoteID }) ?? self.store.notes.first + } + + var body: some View { + VStack(spacing: 0) { + self.header + Divider() + + if self.store.notes.isEmpty { + self.emptyState + } else { + HSplitView { + self.noteList + .frame(minWidth: 230, idealWidth: 280, maxWidth: 340) + self.noteDetail + .frame(minWidth: 420, maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .navigationTitle("Smart Notes") + .onAppear { + self.store.reload() + if self.selectedNoteID == nil { + self.selectedNoteID = self.store.notes.first?.id + } + } + .confirmationDialog( + "Delete this note?", + isPresented: Binding( + get: { self.notePendingDeletion != nil }, + set: { if !$0 { self.notePendingDeletion = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete Note", role: .destructive) { + guard let note = self.notePendingDeletion else { return } + try? self.store.delete(note) + self.notePendingDeletion = nil + self.selectedNoteID = self.store.notes.first?.id + } + Button("Cancel", role: .cancel) { + self.notePendingDeletion = nil + } + } + } + + private var header: some View { + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 3) { + Text("Smart Notes") + .font(.title2.weight(.semibold)) + Text("Dictate from anywhere. Notes are saved as Markdown in Documents/FluidVoice Notes.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Toggle("Enhance with AI", isOn: Binding( + get: { self.settings.smartNotesAIEnhancementEnabled }, + set: { self.settings.smartNotesAIEnhancementEnabled = $0 } + )) + .toggleStyle(.switch) + .help("Organize new notes, add a title, category, and tags using the configured AI provider.") + + Button("Open Folder") { + try? FileManager.default.createDirectory( + at: self.store.notesDirectoryURL, + withIntermediateDirectories: true + ) + NSWorkspace.shared.open(self.store.notesDirectoryURL) + } + + Button { + self.store.reload() + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Refresh notes") + } + .padding(18) + } + + private var noteList: some View { + List(selection: self.$selectedNoteID) { + ForEach(self.store.notes) { note in + VStack(alignment: .leading, spacing: 5) { + Text(note.title) + .font(.body.weight(.medium)) + .lineLimit(2) + + HStack(spacing: 6) { + Text(note.createdAt, style: .date) + Text(note.createdAt, style: .time) + if note.isAIEnhanced { + Label("AI", systemImage: "sparkles") + .foregroundStyle(.purple) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .tag(note.id) + .contextMenu { + Button("Reveal in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([note.fileURL]) + } + Button("Delete", role: .destructive) { + self.notePendingDeletion = note + } + } + } + } + .listStyle(.sidebar) + } + + @ViewBuilder + private var noteDetail: some View { + if let note = self.selectedNote { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 8) { + Text(note.title) + .font(.largeTitle.weight(.bold)) + .textSelection(.enabled) + + HStack(spacing: 8) { + if let category = note.category { + Label(category, systemImage: "folder") + } + ForEach(note.tags, id: \.self) { tag in + Text("#\(tag)") + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Menu { + Button("Reveal in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([note.fileURL]) + } + Button("Delete", role: .destructive) { + self.notePendingDeletion = note + } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + } + + Divider() + + Text(note.body) + .font(.body) + .lineSpacing(5) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(28) + } + } + } + + private var emptyState: some View { + VStack(spacing: 14) { + Spacer() + Image(systemName: "note.text.badge.plus") + .font(.system(size: 46, weight: .light)) + .foregroundStyle(.secondary) + Text("No Smart Notes Yet") + .font(.title3.weight(.semibold)) + Text("Set a Smart Notes shortcut in Settings, then use it to dictate your first note.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Button("Open Notes Folder") { + NSWorkspace.shared.open(self.store.notesDirectoryURL) + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} diff --git a/Tests/FluidDictationIntegrationTests/SmartNotesStoreTests.swift b/Tests/FluidDictationIntegrationTests/SmartNotesStoreTests.swift new file mode 100644 index 00000000..db9a4840 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/SmartNotesStoreTests.swift @@ -0,0 +1,82 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest + +@MainActor +final class SmartNotesStoreTests: XCTestCase { + private var temporaryDirectory: URL! + + override func setUpWithError() throws { + self.temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("FluidVoice-SmartNotesTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: self.temporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + if let temporaryDirectory { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + self.temporaryDirectory = nil + } + + func testRawCaptureWritesReadableMarkdownAndReloads() throws { + let store = SmartNotesStore(directoryURL: self.temporaryDirectory) + let id = UUID(uuidString: "6A1AF9AF-EE38-49AA-8E51-23E573A62002")! + let date = Date(timeIntervalSince1970: 1_725_000_000) + + let captured = try store.capture( + rawText: "Remember to send the quarterly planning notes to Maya tomorrow.", + at: date, + id: id + ) + + XCTAssertTrue(FileManager.default.fileExists(atPath: captured.fileURL.path)) + XCTAssertFalse(captured.isAIEnhanced) + XCTAssertEqual(captured.body, "Remember to send the quarterly planning notes to Maya tomorrow.") + + let markdown = try String(contentsOf: captured.fileURL, encoding: .utf8) + XCTAssertTrue(markdown.contains("enhanced: false")) + XCTAssertTrue(markdown.contains("")) + + let reloaded = SmartNotesStore(directoryURL: self.temporaryDirectory) + XCTAssertEqual(reloaded.notes.count, 1) + XCTAssertEqual(reloaded.notes[0].id, id) + XCTAssertEqual(reloaded.notes[0].createdAt, date) + XCTAssertEqual(reloaded.notes[0].body, captured.body) + } + + func testAIResponseParserExtractsJSONAndNormalizesTags() throws { + let response = """ + ```json + {"title":"Launch checklist","category":"Work","tags":[" Launch ","release","launch",""],"body":"- Confirm build\n- Notify support"} + ``` + """ + + let enhancement = try SmartNoteEnhancement.parseAIResponse(response) + + XCTAssertEqual(enhancement.title, "Launch checklist") + XCTAssertEqual(enhancement.category, "Work") + XCTAssertEqual(enhancement.tags, ["launch", "release"]) + XCTAssertEqual(enhancement.body, "- Confirm build\n- Notify support") + } + + func testEnhancementUpdatesTheExistingMarkdownFile() throws { + let store = SmartNotesStore(directoryURL: self.temporaryDirectory) + let note = try store.capture(rawText: "we should organize the launch checklist") + let originalURL = note.fileURL + let enhancement = SmartNoteEnhancement( + title: "Launch checklist", + category: "Work", + tags: ["launch", "release"], + body: "- Organize the launch checklist" + ) + + let updated = try store.apply(enhancement, to: note.id) + + XCTAssertEqual(updated.fileURL, originalURL) + XCTAssertTrue(updated.isAIEnhanced) + XCTAssertEqual(updated.title, "Launch checklist") + XCTAssertEqual(updated.tags, ["launch", "release"]) + XCTAssertEqual(store.notes.count, 1) + } +}