diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 83d1440..0a9afcd 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -131,6 +131,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { // 2. Normal dictation processing & paste let processed = TextProcessor.shared.process(text: trimmed) if !processed.isEmpty { + VoiceCommandLogger.shared.logDictation(rawSpeech: trimmed, processed: processed) HistoryManager.shared.addEntry(processed) self.pasteTextToFrontmostApp(text: processed) } diff --git a/src/StatusBarController.swift b/src/StatusBarController.swift index 7e51410..8e364c6 100644 --- a/src/StatusBarController.swift +++ b/src/StatusBarController.swift @@ -208,6 +208,20 @@ public final class StatusBarController: NSObject, NSMenuDelegate { cheatSheetItem.target = self advancedMenu.addItem(cheatSheetItem) + advancedMenu.addItem(NSMenuItem.separator()) + + let viewLogItem = NSMenuItem(title: "📜 View Voice Commands Log...", action: #selector(viewVoiceLogAction), keyEquivalent: "") + viewLogItem.target = self + advancedMenu.addItem(viewLogItem) + + let openFolderItem = NSMenuItem(title: "📁 Open Logs Folder...", action: #selector(openLogFolderAction), keyEquivalent: "") + openFolderItem.target = self + advancedMenu.addItem(openFolderItem) + + let clearLogItem = NSMenuItem(title: "🗑️ Clear Voice Commands Log", action: #selector(clearVoiceLogAction), keyEquivalent: "") + clearLogItem.target = self + advancedMenu.addItem(clearLogItem) + let advancedMenuItem = NSMenuItem(title: "🛠️ Advanced Options", action: nil, keyEquivalent: "") advancedMenuItem.submenu = advancedMenu menu.addItem(advancedMenuItem) @@ -474,6 +488,24 @@ public final class StatusBarController: NSObject, NSMenuDelegate { alert.addButton(withTitle: "Got it!") alert.runModal() } + + @objc private func viewVoiceLogAction() { + VoiceCommandLogger.shared.openLogInViewer() + } + + @objc private func openLogFolderAction() { + VoiceCommandLogger.shared.openLogFolder() + } + + @objc private func clearVoiceLogAction() { + VoiceCommandLogger.shared.clearLog() + let alert = NSAlert() + alert.messageText = "Voice Log Cleared" + alert.informativeText = "The voice commands debug log has been cleared." + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } @objc private func toggleLaunchAtLoginAction(_ sender: NSMenuItem) { SonioxSettings.shared.launchAtLogin.toggle() diff --git a/src/VoiceCommandExecutor.swift b/src/VoiceCommandExecutor.swift index d5d648a..1986d54 100644 --- a/src/VoiceCommandExecutor.swift +++ b/src/VoiceCommandExecutor.swift @@ -23,318 +23,523 @@ public final class VoiceCommandExecutor { private var cachedInstalledApps: [InstalledAppInfo] = [] private var lastAppsScanTime: TimeInterval = 0 - // Comprehensive Persian to Canonical English Application Names + // Canonical Persian to English application names private let persianAppDictionary: [String: String] = [ - // System & Apple Core Apps + // Antigravity Suite (All phonetic variations with and without alef) + "انتی گراویتی ای دی ای": "Antigravity IDE", + "انتی گراویتی ایدی ای": "Antigravity IDE", + "انتی گراویتی ایدیه": "Antigravity IDE", + "انتی گراویتی ادیتور": "Antigravity IDE", + "انتی گراویتی کد": "Antigravity IDE", + "انتی گراویتی ide": "Antigravity IDE", + "انتی گرویتی ای دی ای": "Antigravity IDE", + "انتی گرویتی ایدی ای": "Antigravity IDE", + "انتی گرویتی ادیتور": "Antigravity IDE", + "انتیگراویتی ایدی ای": "Antigravity IDE", + "انتیگراویتی ای دی ای": "Antigravity IDE", + "انتیگرویتی ایدی ای": "Antigravity IDE", + "antigravity ide": "Antigravity IDE", + "anti gravity ide": "Antigravity IDE", + + "انتی گراویتی": "Antigravity", + "انتی گرویتی": "Antigravity", + "انتیگراویتی": "Antigravity", + "انتیگرویتی": "Antigravity", + "antigravity": "Antigravity", + "anti gravity": "Antigravity", + + // System Core Apps "ستینگ": "System Settings", "ستینگز": "System Settings", "تنظیمات": "System Settings", "تنظیمات سیستم": "System Settings", "سیستم ستینگ": "System Settings", + "settings": "System Settings", + "system settings": "System Settings", + "فایندر": "Finder", + "finder": "Finder", "ترمینال": "Terminal", + "terminal": "Terminal", "سافاری": "Safari", + "safari": "Safari", "ماشین حساب": "Calculator", "کلکولیتور": "Calculator", + "calculator": "Calculator", "تقویم": "Calendar", "کلندر": "Calendar", + "calendar": "Calendar", "یادداشت": "Notes", - "یادداشت‌ها": "Notes", + "یادداشت ها": "Notes", "نوت": "Notes", "نوتس": "Notes", + "notes": "Notes", "اپ استور": "App Store", + "app store": "App Store", "فوتوز": "Photos", "عکس": "Photos", + "photos": "Photos", "موزیک": "Music", - "آهنگ": "Music", + "اهنگ": "Music", + "music": "Music", "پادکست": "Podcasts", + "podcasts": "Podcasts", "مخاطبین": "Contacts", - "پیام‌ها": "Messages", + "contacts": "Contacts", + "پیام ها": "Messages", "مسج": "Messages", + "messages": "Messages", "ایمیل": "Mail", "میل": "Mail", + "mail": "Mail", "شورتکات": "Shortcuts", "میانبرها": "Shortcuts", + "shortcuts": "Shortcuts", "فری فرم": "Freeform", + "freeform": "Freeform", "پریویو": "Preview", + "preview": "Preview", "اکتیویتی مانیتور": "Activity Monitor", + "activity monitor": "Activity Monitor", "دیسک یوتیلیتی": "Disk Utility", + "disk utility": "Disk Utility", "پسورد": "Passwords", + "passwords": "Passwords", - // Developer & Custom Apps on this Mac - "آنتی گرویتی آیدی ای": "Antigravity IDE", - "آنتی گرویتی آی دی ای": "Antigravity IDE", - "آنتی گرویتی ادیتور": "Antigravity IDE", - "آنتی گرویتی کد": "Antigravity IDE", - "آنتیگرویتی آیدی ای": "Antigravity IDE", - "انتی گرویتی ایدی ای": "Antigravity IDE", - - "آنتی گرویتی": "Antigravity", - "انتی گرویتی": "Antigravity", - "آنتیگرویتی": "Antigravity", - + // Third-party & Productivity "فایرفاکس": "Firefox", + "فایر فاکس": "Firefox", + "فایرفکس": "Firefox", + "firefox": "Firefox", + "کروم": "Google Chrome", "گوگل کروم": "Google Chrome", + "chrome": "Google Chrome", + "google chrome": "Google Chrome", + "تلگرام": "Telegram", + "تلگرم": "Telegram", + "telegram": "Telegram", + "تیم بی": "TeamBy", "تیم بای": "TeamBy", - "تیم‌بای": "TeamBy", "تیمبی": "TeamBy", + "تیمبای": "TeamBy", + "teamby": "TeamBy", + "فیگما": "Figma", + "figma": "Figma", + "وی تو باکس": "V2Box", "وی۲باکس": "V2Box", + "ویتوباکس": "V2Box", + "v2box": "V2Box", + "وی اس کد": "Visual Studio Code", - "کد": "Visual Studio Code", + "ویژوال استودیو کد": "Visual Studio Code", "ویژوال استودیو": "Visual Studio Code", + "کد": "Visual Studio Code", + "vscode": "Visual Studio Code", + "vs code": "Visual Studio Code", + "code": "Visual Studio Code", + "سانی اوکس": "Soniox Voice", + "soniox": "Soniox Voice", - // Utilities installed on this Mac "روزگار": "Roozegar", - "رزگار": "Roozegar", + "roozegar": "Roozegar", "شمسی کال": "ShamsiCal", + "shamsical": "ShamsiCal", "چند": "Chand", - "میدل کلیک": "MiddleClick" + "chand": "Chand", + "میدل کلیک": "MiddleClick", + "middleclick": "MiddleClick" ] private init() { refreshInstalledApps() } + // MARK: - Normalization Helpers + public static func normalizePersianText(_ text: String) -> String { + var s = text.lowercased() + + // 1. ZWNJ, ZWJ, and non-standard spaces + s = s.replacingOccurrences(of: "\u{200C}", with: " ") + s = s.replacingOccurrences(of: "\u{200D}", with: " ") + s = s.replacingOccurrences(of: "\u{FEFF}", with: "") + s = s.replacingOccurrences(of: "\u{00A0}", with: " ") + + // 2. Arabic to Persian letter unification + s = s.replacingOccurrences(of: "ي", with: "ی") + s = s.replacingOccurrences(of: "ى", with: "ی") + s = s.replacingOccurrences(of: "ك", with: "ک") + s = s.replacingOccurrences(of: "آ", with: "ا") + s = s.replacingOccurrences(of: "أ", with: "ا") + s = s.replacingOccurrences(of: "إ", with: "ا") + s = s.replacingOccurrences(of: "ؤ", with: "و") + s = s.replacingOccurrences(of: "ة", with: "ه") + + // 3. Remove vowels and diacritics + let diacritics = ["\u{064B}", "\u{064C}", "\u{064D}", "\u{064E}", "\u{064F}", "\u{0650}", "\u{0651}", "\u{0652}"] + for d in diacritics { + s = s.replacingOccurrences(of: d, with: "") + } + + // 4. Punctuation to space + let punct = CharacterSet(charactersIn: ".,،؛:!؟?\"'«»()[]{}—-_/\\") + s = s.components(separatedBy: punct).joined(separator: " ") + + // 5. Clean whitespace + let parts = s.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } + return parts.joined(separator: " ") + } + public func isSlashCommand(text: String) -> Bool { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if trimmed.hasPrefix("اسلش") || - trimmed.hasPrefix("اصلش") || - trimmed.hasPrefix("slash") || - trimmed.hasPrefix("slsh") || - trimmed.hasPrefix("/") || - trimmed.hasPrefix("\\") { + let lower = text.lowercased() + + // Check for starting prefix + for prefix in ["اسلش", "اصلش", "slash", "slsh", "/", "\\"] { + if lower.hasPrefix(prefix) { return true } + } + + // Check for slash trigger inside speech (e.g. after filler words like "امم، اسلش ...") + if lower.contains("اسلش") || lower.contains("اصلش") || lower.contains("slash") || lower.contains("slsh") { return true } - let firstWord = trimmed.components(separatedBy: .whitespacesAndNewlines).first ?? "" - let cleanFirst = firstWord.trimmingCharacters(in: CharacterSet(charactersIn: ":,،.؛-_/\\")) - return cleanFirst == "اسلش" || cleanFirst == "اصلش" || cleanFirst == "slash" || cleanFirst == "slsh" + return false } public func extractCommandBody(rawText: String) -> String { - var body = rawText.trimmingCharacters(in: .whitespacesAndNewlines) - let lower = body.lowercased() - - if lower.hasPrefix("اسلش") || lower.hasPrefix("اصلش") { - body = String(body.dropFirst(4)).trimmingCharacters(in: .whitespacesAndNewlines) - } else if lower.hasPrefix("slash") { - body = String(body.dropFirst(5)).trimmingCharacters(in: .whitespacesAndNewlines) - } else if lower.hasPrefix("slsh") { - body = String(body.dropFirst(4)).trimmingCharacters(in: .whitespacesAndNewlines) - } else if body.hasPrefix("/") || body.hasPrefix("\\") { - body = String(body.dropFirst(1)).trimmingCharacters(in: .whitespacesAndNewlines) - if body.hasPrefix("/") { - body = String(body.dropFirst(1)).trimmingCharacters(in: .whitespacesAndNewlines) + var text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = text.lowercased() + + let triggers = ["اسلش", "اصلش", "slash", "slsh", "//", "/", "\\"] + var foundRange: Range? = nil + + for trig in triggers { + if let r = lower.range(of: trig) { + if foundRange == nil || r.lowerBound < foundRange!.lowerBound { + foundRange = r + } } } - return body.trimmingCharacters(in: CharacterSet(charactersIn: ":,،.؛ -_")) + if let r = foundRange { + text = String(text[r.upperBound...]) + } + + let leadingPunct = CharacterSet(charactersIn: " :.,،؛!؟?-_—\"'«»()[]{}") + text = text.trimmingCharacters(in: leadingPunct) + return text } + // MARK: - Main Execution Dispatcher @discardableResult public func execute(rawText: String) -> Bool { let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) guard isSlashCommand(text: trimmed) else { return false } let body = extractCommandBody(rawText: trimmed) - let lower = body.lowercased() + let normalized = Self.normalizePersianText(body) - print("VoiceCommandExecutor: executing command for body: '\(body)'") + print("VoiceCommandExecutor: Executing command body: '\(body)' | Normalized: '\(normalized)'") // 1. User-Customized Commands (from custom_commands.json) - if let customCmd = CustomCommandManager.shared.findMatchingCommand(query: body) { + if let customCmd = CustomCommandManager.shared.findMatchingCommand(query: body) ?? + CustomCommandManager.shared.findMatchingCommand(query: normalized) { if CustomCommandManager.shared.executeCommand(customCmd) { playConfirmationSound() + VoiceCommandLogger.shared.logCommand( + rawSpeech: rawText, + body: body, + normalized: normalized, + actionCategory: "Custom User Command", + actionDetail: customCmd.title ?? "\(customCmd.action): \(customCmd.value)", + status: "SUCCESS" + ) return true } } - // 2. Web Search & Direct Navigation ("سرچ X", "search X", "یوتیوب X", "youtube X") - if handleWebSearchAndNavigation(lower: lower, original: body) { + // 2. Specialized Antigravity Matcher (Instant detection of any variation) + if let antiMatch = matchAntigravity(normalized: normalized) { + launchOrActivateApp(named: antiMatch) + playConfirmationSound() + HistoryManager.shared.addEntry("⚡️ [App] Open \(antiMatch)") + VoiceCommandLogger.shared.logCommand( + rawSpeech: rawText, + body: body, + normalized: normalized, + actionCategory: "Application Launch", + actionDetail: "Launched / Focused: \(antiMatch)", + status: "SUCCESS" + ) + return true + } + + // 3. Web Search & Direct Navigation ("سرچ X", "search X", "یوتیوب X", "youtube X", "برو به X") + if let searchDetail = handleWebSearchAndNavigation(normalized: normalized, original: body) { playConfirmationSound() + VoiceCommandLogger.shared.logCommand( + rawSpeech: rawText, + body: body, + normalized: normalized, + actionCategory: "Web Search / Navigation", + actionDetail: searchDetail, + status: "SUCCESS" + ) return true } - // 3. Window Tiling & Management (چپ, راست, بالا, پایین, تمام صفحه, وسط, مینیمایز, هاید) - if handleWindowTiling(lower: lower) { + // 4. Window Tiling & Management (چپ, راست, بالا, پایین, تمام صفحه, وسط, مینیمایز, هاید, بستن) + if let tileDetail = handleWindowTiling(normalized: normalized) { playConfirmationSound() + VoiceCommandLogger.shared.logCommand( + rawSpeech: rawText, + body: body, + normalized: normalized, + actionCategory: "Window Management", + actionDetail: tileDetail, + status: "SUCCESS" + ) return true } - // 4. App + Tab combinations (e.g. "فایرفاکس تب ۱", "کروم تب ۲", "firefox tab 1", "chrome tab 3") - if handleAppWithTab(lower: lower, original: body) { + // 5. App + Tab combinations (e.g. "فایرفاکس تب ۱", "کروم تب ۲", "firefox tab 1") + if let appTabDetail = handleAppWithTab(normalized: normalized) { playConfirmationSound() + VoiceCommandLogger.shared.logCommand( + rawSpeech: rawText, + body: body, + normalized: normalized, + actionCategory: "Browser App Tab", + actionDetail: appTabDetail, + status: "SUCCESS" + ) return true } - // 5. Standalone Tab & Navigation commands (e.g. "تب ۱", "تب جدید", "بستن تب", "tab 1", "new tab") - if handleStandaloneTab(lower: lower) { + // 6. Standalone Tab & Navigation commands (e.g. "تب ۱", "تب جدید", "بستن تب", "tab 1", "new tab") + if let tabDetail = handleStandaloneTab(normalized: normalized) { playConfirmationSound() + VoiceCommandLogger.shared.logCommand( + rawSpeech: rawText, + body: body, + normalized: normalized, + actionCategory: "Tab Navigation", + actionDetail: tabDetail, + status: "SUCCESS" + ) return true } - // 6. System controls (volume, mute, lock, screenshot, dark mode, clipboard) - if handleSystemControls(lower: lower) { + // 7. System controls (volume, mute, lock, screenshot, dark mode, editing hotkeys) + if let sysDetail = handleSystemControls(normalized: normalized) { playConfirmationSound() + VoiceCommandLogger.shared.logCommand( + rawSpeech: rawText, + body: body, + normalized: normalized, + actionCategory: "System Control", + actionDetail: sysDetail, + status: "SUCCESS" + ) return true } - // 7. Match App from Persian dictionary or exact names - if handleKnownApp(lower: lower, original: body) { + // 8. Match App from Persian dictionary or exact names + if let appName = matchKnownApp(normalized: normalized) { + launchOrActivateApp(named: appName) playConfirmationSound() + HistoryManager.shared.addEntry("⚡️ [App] Open \(appName)") + VoiceCommandLogger.shared.logCommand( + rawSpeech: rawText, + body: body, + normalized: normalized, + actionCategory: "Application Launch", + actionDetail: "Launched / Focused: \(appName)", + status: "SUCCESS" + ) return true } - // 8. Universal fuzzy resolver across all installed apps in /Applications and /System/Applications - if handleUniversalInstalledApp(query: body) { + // 9. Universal fuzzy resolver across all installed apps in /Applications and /System/Applications + if let universalMatch = handleUniversalInstalledApp(query: body, normalized: normalized) { playConfirmationSound() + VoiceCommandLogger.shared.logCommand( + rawSpeech: rawText, + body: body, + normalized: normalized, + actionCategory: "Universal App Match", + actionDetail: "Launched / Focused: \(universalMatch)", + status: "SUCCESS" + ) return true } - // Command was prefixed with slash but body was not recognized - print("VoiceCommandExecutor: slash command body unrecognized: '\(body)'") + // Unrecognized Slash Command + print("VoiceCommandExecutor: Unrecognized slash command body: '\(body)'") HistoryManager.shared.addEntry("⚡️ [Command] Unrecognized: \(body)") + VoiceCommandLogger.shared.logCommand( + rawSpeech: rawText, + body: body, + normalized: normalized, + actionCategory: "Unrecognized", + actionDetail: "No matching application, shortcut, or search trigger found", + status: "UNRECOGNIZED" + ) playWarningSound() return false } - // MARK: - 1. Web Search & Direct Navigation - private func handleWebSearchAndNavigation(lower: String, original: String) -> Bool { + // MARK: - Specialized Antigravity Matcher + private func matchAntigravity(normalized: String) -> String? { + let hasAnti = normalized.contains("انتی") || normalized.contains("anti") + let hasGravity = normalized.contains("گراویتی") || normalized.contains("گرویتی") || normalized.contains("gravity") + + if (hasAnti && hasGravity) || normalized.contains("antigravity") || normalized.contains("انتیگراویتی") || normalized.contains("انتیگرویتی") { + let isIDE = normalized.contains("ای دی ای") || + normalized.contains("ایدی ای") || + normalized.contains("ایدیای") || + normalized.contains("ide") || + normalized.contains("ادیتور") || + normalized.contains("کد") + return isIDE ? "Antigravity IDE" : "Antigravity" + } + return nil + } + + // MARK: - Web Search & Direct Navigation + private func handleWebSearchAndNavigation(normalized: String, original: String) -> String? { // Google Search - if lower.hasPrefix("سرچ ") || lower.hasPrefix("جستجو ") || lower.hasPrefix("search ") || lower.hasPrefix("google ") { - var q = original - for p in ["سرچ ", "جستجو ", "search ", "google "] { - if q.lowercased().hasPrefix(p) { - q = String(q.dropFirst(p.count)).trimmingCharacters(in: .whitespacesAndNewlines) - break + for p in ["سرچ", "جستجو", "search", "google"] { + if normalized.hasPrefix(p) { + var q = original + // Clean original string from search keyword and punctuations + let lowerOrig = q.lowercased() + if let r = lowerOrig.range(of: p) { + q = String(q[r.upperBound...]) + } + let trimSet = CharacterSet(charactersIn: " :.,،؛!؟?-_—\"'«»()[]{}") + q = q.trimmingCharacters(in: trimSet) + + if !q.isEmpty, let enc = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://www.google.com/search?q=\(enc)") { + NSWorkspace.shared.open(url) + HistoryManager.shared.addEntry("⚡️ [Search] Google: \(q)") + return "Google Search: \(q)" } - } - if !q.isEmpty, let enc = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - let url = URL(string: "https://www.google.com/search?q=\(enc)") { - NSWorkspace.shared.open(url) - HistoryManager.shared.addEntry("⚡️ [Search] Google: \(q)") - return true } } // YouTube Search - if lower.hasPrefix("یوتیوب ") || lower.hasPrefix("youtube ") { - var q = original - for p in ["یوتیوب ", "youtube "] { - if q.lowercased().hasPrefix(p) { - q = String(q.dropFirst(p.count)).trimmingCharacters(in: .whitespacesAndNewlines) - break + for p in ["یوتیوب", "youtube"] { + if normalized.hasPrefix(p) { + var q = original + let lowerOrig = q.lowercased() + if let r = lowerOrig.range(of: p) { + q = String(q[r.upperBound...]) + } + let trimSet = CharacterSet(charactersIn: " :.,،؛!؟?-_—\"'«»()[]{}") + q = q.trimmingCharacters(in: trimSet) + + if !q.isEmpty, let enc = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://www.youtube.com/results?search_query=\(enc)") { + NSWorkspace.shared.open(url) + HistoryManager.shared.addEntry("⚡️ [Search] YouTube: \(q)") + return "YouTube Search: \(q)" } - } - if !q.isEmpty, let enc = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - let url = URL(string: "https://www.youtube.com/results?search_query=\(enc)") { - NSWorkspace.shared.open(url) - HistoryManager.shared.addEntry("⚡️ [Search] YouTube: \(q)") - return true } } // Direct URL navigation ("برو به X" / "go to X") - if lower.hasPrefix("برو به ") || lower.hasPrefix("goto ") || lower.hasPrefix("go to ") { - var target = original - for p in ["برو به ", "goto ", "go to "] { - if target.lowercased().hasPrefix(p) { - target = String(target.dropFirst(p.count)).trimmingCharacters(in: .whitespacesAndNewlines) - break + for p in ["برو به", "برو تو", "برو", "goto", "go to"] { + if normalized.hasPrefix(p) { + var target = original + let lowerOrig = target.lowercased() + if let r = lowerOrig.range(of: p) { + target = String(target[r.upperBound...]) } - } - if !target.isEmpty { - var urlStr = target - if !urlStr.contains("://") { - urlStr = "https://" + urlStr - } - if let url = URL(string: urlStr) { - NSWorkspace.shared.open(url) - HistoryManager.shared.addEntry("⚡️ [Nav] Go to: \(target)") - return true + let trimSet = CharacterSet(charactersIn: " :.,،؛!؟?-_—\"'«»()[]{}") + target = target.trimmingCharacters(in: trimSet) + + if !target.isEmpty { + var urlStr = target + if !urlStr.contains("://") { + urlStr = "https://" + urlStr + } + if let url = URL(string: urlStr) { + NSWorkspace.shared.open(url) + HistoryManager.shared.addEntry("⚡️ [Nav] Go to: \(target)") + return "Direct Navigation: \(urlStr)" + } } } } - return false + return nil } - // MARK: - 2. Window Tiling & Management - private func handleWindowTiling(lower: String) -> Bool { - // Snap Left - if lower == "چپ" || lower.contains("پنجره چپ") || lower == "left" || lower == "window left" { + // MARK: - Window Tiling & Management + private func handleWindowTiling(normalized: String) -> String? { + if normalized == "چپ" || normalized.contains("پنجره چپ") || normalized == "left" || normalized == "window left" { tileFrontmostWindow(action: .left) HistoryManager.shared.addEntry("⚡️ [Window] Tile Left Half") - return true + return "Tile Left Half" } - // Snap Right - if lower == "راست" || lower.contains("پنجره راست") || lower == "right" || lower == "window right" { + if normalized == "راست" || normalized.contains("پنجره راست") || normalized == "right" || normalized == "window right" { tileFrontmostWindow(action: .right) HistoryManager.shared.addEntry("⚡️ [Window] Tile Right Half") - return true + return "Tile Right Half" } - // Snap Top - if lower == "بالا" || lower.contains("پنجره بالا") || lower == "top" || lower == "window top" { + if normalized == "بالا" || normalized.contains("پنجره بالا") || normalized == "top" || normalized == "window top" { tileFrontmostWindow(action: .top) HistoryManager.shared.addEntry("⚡️ [Window] Tile Top Half") - return true + return "Tile Top Half" } - // Snap Bottom - if lower == "پایین" || lower.contains("پنجره پایین") || lower == "bottom" || lower == "window bottom" { + if normalized == "پایین" || normalized.contains("پنجره پایین") || normalized == "bottom" || normalized == "window bottom" { tileFrontmostWindow(action: .bottom) HistoryManager.shared.addEntry("⚡️ [Window] Tile Bottom Half") - return true + return "Tile Bottom Half" } - // Maximize / Full Screen - if lower.contains("تمام صفحه") || lower.contains("فول اسکرین") || lower.contains("ماکزیمایز") || lower == "maximize" || lower == "full screen" { + if normalized.contains("تمام صفحه") || normalized.contains("فول اسکرین") || normalized.contains("ماکزیمایز") || normalized == "maximize" || normalized == "full screen" { tileFrontmostWindow(action: .maximize) HistoryManager.shared.addEntry("⚡️ [Window] Maximize Window") - return true + return "Maximize Window" } - // Center Window - if lower == "وسط" || lower == "مرکز" || lower.contains("پنجره وسط") || lower == "center" || lower == "window center" { + if normalized == "وسط" || normalized == "مرکز" || normalized.contains("پنجره وسط") || normalized == "center" || normalized == "window center" { tileFrontmostWindow(action: .center) HistoryManager.shared.addEntry("⚡️ [Window] Center Window") - return true + return "Center Window" } - // Minimize - if lower == "مینیمایز" || lower.contains("کوچک کردن") || lower == "minimize" { + if normalized == "مینیمایز" || normalized.contains("کوچک کردن") || normalized == "minimize" { pressHotkey(keyCode: 46, modifiers: .maskCommand) // Cmd+M HistoryManager.shared.addEntry("⚡️ [Window] Minimize") - return true + return "Minimize Window (Cmd+M)" } - // Hide Frontmost App - if lower == "هاید" || lower == "مخفی" || lower.contains("مخفی کن") || lower == "hide" { + if normalized == "هاید" || normalized == "مخفی" || normalized.contains("مخفی کن") || normalized == "hide" { pressHotkey(keyCode: 4, modifiers: .maskCommand) // Cmd+H HistoryManager.shared.addEntry("⚡️ [Window] Hide App") - return true + return "Hide Frontmost App (Cmd+H)" } - // Hide Others - if lower.contains("هاید بقیه") || lower.contains("مخفی کردن بقیه") || lower == "hide others" { + if normalized.contains("هاید بقیه") || normalized.contains("مخفی کردن بقیه") || normalized == "hide others" { pressHotkey(keyCode: 4, modifiers: [.maskCommand, .maskAlternate]) // Cmd+Option+H HistoryManager.shared.addEntry("⚡️ [Window] Hide Other Apps") - return true + return "Hide Other Apps (Cmd+Option+H)" } - // Close Window - if lower == "بستن پنجره" || lower == "کلوز پنجره" || lower == "close window" { + if normalized == "بستن پنجره" || normalized == "کلوز پنجره" || normalized == "close window" { pressHotkey(keyCode: 13, modifiers: .maskCommand) // Cmd+W HistoryManager.shared.addEntry("⚡️ [Window] Close Window") - return true + return "Close Window (Cmd+W)" } - // Quit App - if lower == "ببند" || lower.contains("خروج از برنامه") || lower.contains("کویت") || lower == "quit" || lower == "quit app" { + if normalized == "ببند" || normalized.contains("خروج از برنامه") || normalized.contains("کویت") || normalized == "quit" || normalized == "quit app" { pressHotkey(keyCode: 12, modifiers: .maskCommand) // Cmd+Q HistoryManager.shared.addEntry("⚡️ [Window] Quit App") - return true + return "Quit Application (Cmd+Q)" } - - return false + return nil } private func tileFrontmostWindow(action: WindowTileAction) { @@ -387,52 +592,50 @@ public final class VoiceCommandExecutor { } } - // MARK: - 3. App + Tab Combinations - private func handleAppWithTab(lower: String, original: String) -> Bool { - guard lower.contains("تب") || lower.contains("tab") else { return false } + // MARK: - App + Tab Combinations + private func handleAppWithTab(normalized: String) -> String? { + guard normalized.contains("تب") || normalized.contains("tab") else { return nil } var targetApp: String? = nil - if lower.contains("فایرفاکس") || lower.contains("firefox") { + if normalized.contains("فایرفاکس") || normalized.contains("firefox") { targetApp = "Firefox" - } else if lower.contains("کروم") || lower.contains("chrome") { + } else if normalized.contains("کروم") || normalized.contains("chrome") { targetApp = "Google Chrome" - } else if lower.contains("سافاری") || lower.contains("safari") { + } else if normalized.contains("سافاری") || normalized.contains("safari") { targetApp = "Safari" - } else if lower.contains("آنتی گرویتی آیدی ای") || lower.contains("antigravity ide") { - targetApp = "Antigravity IDE" - } else if lower.contains("آنتی گرویتی") || lower.contains("antigravity") { - targetApp = "Antigravity" + } else if let anti = matchAntigravity(normalized: normalized) { + targetApp = anti } - guard let appName = targetApp else { return false } + guard let appName = targetApp else { return nil } - if let tabNum = extractTabNumber(from: lower) { + if let tabNum = extractTabNumber(from: normalized) { activateApp(named: appName) { self.switchToTab(number: tabNum) } HistoryManager.shared.addEntry("⚡️ [Command] \(appName): Tab \(tabNum)") - return true - } else if lower.contains("تب جدید") || lower.contains("new tab") { + return "\(appName) -> Tab \(tabNum)" + } else if normalized.contains("تب جدید") || normalized.contains("new tab") { activateApp(named: appName) { self.pressHotkey(keyCode: 17, modifiers: .maskCommand) // Cmd+T } HistoryManager.shared.addEntry("⚡️ [Command] \(appName): New Tab") - return true - } else if lower.contains("بستن تب") || lower.contains("close tab") { + return "\(appName) -> New Tab (Cmd+T)" + } else if normalized.contains("بستن تب") || normalized.contains("close tab") { activateApp(named: appName) { self.pressHotkey(keyCode: 13, modifiers: .maskCommand) // Cmd+W } HistoryManager.shared.addEntry("⚡️ [Command] \(appName): Close Tab") - return true - } else if lower.contains("ری اپن") || lower.contains("reopen tab") || lower.contains("باز کردن مجدد تب") { + return "\(appName) -> Close Tab (Cmd+W)" + } else if normalized.contains("ری اپن") || normalized.contains("reopen tab") || normalized.contains("باز کردن مجدد تب") { activateApp(named: appName) { self.pressHotkey(keyCode: 17, modifiers: [.maskCommand, .maskShift]) // Cmd+Shift+T } HistoryManager.shared.addEntry("⚡️ [Command] \(appName): Reopen Tab") - return true + return "\(appName) -> Reopen Tab (Cmd+Shift+T)" } - return false + return nil } private func extractTabNumber(from text: String) -> Int? { @@ -469,220 +672,210 @@ public final class VoiceCommandExecutor { t.contains("tab 8") || t.contains("tab eight") { return 8 } - if t.contains("تب ۹") || t.contains("تب 9") || t.contains("تب نه") || t.contains("تب نهم") || t.contains("تب آخر") || + if t.contains("تب ۹") || t.contains("تب 9") || t.contains("تب نه") || t.contains("تب نهم") || t.contains("تب اخر") || t.contains("tab 9") || t.contains("tab nine") || t.contains("tab last") { return 9 } return nil } - // MARK: - 4. Standalone Tab Commands - private func handleStandaloneTab(lower: String) -> Bool { - if let num = extractTabNumber(from: lower) { + // MARK: - Standalone Tab Commands + private func handleStandaloneTab(normalized: String) -> String? { + if let num = extractTabNumber(from: normalized) { switchToTab(number: num) HistoryManager.shared.addEntry("⚡️ [Command] Switch to Tab \(num)") - return true + return "Switch to Tab \(num) (Cmd+\(num))" } - if lower.contains("تب جدید") || lower.contains("نیو تب") || lower.contains("new tab") { + if normalized.contains("تب جدید") || normalized.contains("نیو تب") || normalized.contains("new tab") { pressHotkey(keyCode: 17, modifiers: .maskCommand) // Cmd+T HistoryManager.shared.addEntry("⚡️ [Command] New Tab") - return true + return "New Tab (Cmd+T)" } - if lower.contains("بستن تب") || lower.contains("کلوز تب") || lower.contains("close tab") { + if normalized.contains("بستن تب") || normalized.contains("کلوز تب") || normalized.contains("close tab") { pressHotkey(keyCode: 13, modifiers: .maskCommand) // Cmd+W HistoryManager.shared.addEntry("⚡️ [Command] Close Tab") - return true + return "Close Tab (Cmd+W)" } - if lower.contains("ری اپن") || lower.contains("reopen tab") || lower.contains("باز کردن مجدد تب") { + if normalized.contains("ری اپن") || normalized.contains("reopen tab") || normalized.contains("باز کردن مجدد تب") { pressHotkey(keyCode: 17, modifiers: [.maskCommand, .maskShift]) // Cmd+Shift+T HistoryManager.shared.addEntry("⚡️ [Command] Reopen Closed Tab") - return true + return "Reopen Closed Tab (Cmd+Shift+T)" } - if lower.contains("تب بعد") || lower.contains("تب بعدی") || lower.contains("next tab") { + if normalized.contains("تب بعد") || normalized.contains("تب بعدی") || normalized.contains("next tab") { pressHotkey(keyCode: 48, modifiers: .maskControl) // Ctrl+Tab HistoryManager.shared.addEntry("⚡️ [Command] Next Tab") - return true + return "Next Tab (Ctrl+Tab)" } - if lower.contains("تب قبل") || lower.contains("تب قبلی") || lower.contains("prev tab") || lower.contains("previous tab") { + if normalized.contains("تب قبل") || normalized.contains("تب قبلی") || normalized.contains("prev tab") || normalized.contains("previous tab") { pressHotkey(keyCode: 48, modifiers: [.maskControl, .maskShift]) // Ctrl+Shift+Tab HistoryManager.shared.addEntry("⚡️ [Command] Previous Tab") - return true + return "Previous Tab (Ctrl+Shift+Tab)" } - if lower.contains("رفرش") || lower.contains("ریلود") || lower.contains("reload") || lower.contains("refresh") { + if normalized.contains("رفرش") || normalized.contains("ریلود") || normalized.contains("reload") || normalized.contains("refresh") { pressHotkey(keyCode: 15, modifiers: .maskCommand) // Cmd+R HistoryManager.shared.addEntry("⚡️ [Command] Refresh Page") - return true + return "Reload Page (Cmd+R)" } - if lower.contains("هارد رفرش") || lower.contains("hard refresh") { + if normalized.contains("هارد رفرش") || normalized.contains("hard refresh") { pressHotkey(keyCode: 15, modifiers: [.maskCommand, .maskShift]) // Cmd+Shift+R HistoryManager.shared.addEntry("⚡️ [Command] Hard Refresh Page") - return true + return "Hard Refresh Page (Cmd+Shift+R)" } - if lower.contains("آدرس بار") || lower.contains("ادرس بار") || lower.contains("address bar") { + if normalized.contains("ادرس بار") || normalized.contains("address bar") { pressHotkey(keyCode: 37, modifiers: .maskCommand) // Cmd+L HistoryManager.shared.addEntry("⚡️ [Command] Focus Address Bar") - return true + return "Focus Address Bar (Cmd+L)" } - return false + return nil } - // MARK: - 5. System Controls - private func handleSystemControls(lower: String) -> Bool { + // MARK: - System Controls + private func handleSystemControls(normalized: String) -> String? { // Volume exact levels - if lower.contains("صدا ۱۰۰") || lower.contains("صدا 100") || lower == "volume 100" || lower == "max volume" { + if normalized.contains("صدا ۱۰۰") || normalized.contains("صدا 100") || normalized == "volume 100" || normalized == "max volume" { runAppleScript("set volume output volume 100") HistoryManager.shared.addEntry("⚡️ [Audio] Volume 100%") - return true + return "Set Output Volume to 100%" } - if lower.contains("صدا ۵۰") || lower.contains("صدا 50") || lower == "volume 50" { + if normalized.contains("صدا ۵۰") || normalized.contains("صدا 50") || normalized == "volume 50" { runAppleScript("set volume output volume 50") HistoryManager.shared.addEntry("⚡️ [Audio] Volume 50%") - return true + return "Set Output Volume to 50%" } - if lower.contains("صدا صفر") || lower.contains("صدا 0") || lower == "volume 0" { + if normalized.contains("صدا صفر") || normalized.contains("صدا 0") || normalized == "volume 0" { runAppleScript("set volume output volume 0") HistoryManager.shared.addEntry("⚡️ [Audio] Volume 0%") - return true + return "Set Output Volume to 0%" } // Volume up / down - if lower.contains("صدا زیاد") || lower.contains("افزایش صدا") || lower.contains("volume up") { + if normalized.contains("صدا زیاد") || normalized.contains("افزایش صدا") || normalized.contains("volume up") { runAppleScript("set volume output volume ((output volume of (get volume settings)) + 15)") HistoryManager.shared.addEntry("⚡️ [Audio] Volume Up") - return true + return "Volume Up (+15%)" } - if lower.contains("صدا کم") || lower.contains("کاهش صدا") || lower.contains("volume down") { + if normalized.contains("صدا کم") || normalized.contains("کاهش صدا") || normalized.contains("volume down") { runAppleScript("set volume output volume ((output volume of (get volume settings)) - 15)") HistoryManager.shared.addEntry("⚡️ [Audio] Volume Down") - return true + return "Volume Down (-15%)" } - if lower.contains("میوت") || lower.contains("بی صدا") || lower.contains("بی‌صدا") || lower == "mute" { + if normalized.contains("میوت") || normalized.contains("بی صدا") || normalized == "mute" { runAppleScript("set volume output muted not (output muted of (get volume settings))") HistoryManager.shared.addEntry("⚡️ [Audio] Toggle Mute") - return true + return "Toggle Audio Mute" } - // Media Play / Pause / Next / Prev - if lower == "پلی" || lower == "پاز" || lower == "توقف" || lower == "play" || lower == "pause" || lower == "play pause" { + // Media Play / Pause + if normalized == "پلی" || normalized == "پاز" || normalized == "توقف" || normalized == "play" || normalized == "pause" { runAppleScript("tell application \"System Events\" to key code 16 using {}") HistoryManager.shared.addEntry("⚡️ [Media] Play / Pause") - return true + return "Media Play / Pause" } - if lower.contains("آهنگ بعد") || lower.contains("اهنگ بعد") || lower == "next track" { + if normalized.contains("اهنگ بعد") || normalized == "next track" { runAppleScript("tell application \"System Events\" to key code 19 using {}") HistoryManager.shared.addEntry("⚡️ [Media] Next Track") - return true + return "Media Next Track" } - if lower.contains("آهنگ قبل") || lower.contains("اهنگ قبل") || lower == "prev track" { + if normalized.contains("اهنگ قبل") || normalized == "prev track" { runAppleScript("tell application \"System Events\" to key code 20 using {}") HistoryManager.shared.addEntry("⚡️ [Media] Previous Track") - return true + return "Media Previous Track" } // Lock Screen - if lower.contains("قفل") || lower.contains("لاک") || lower.contains("lock screen") || lower == "lock" { + if normalized.contains("قفل") || normalized.contains("لاک") || normalized.contains("lock screen") || normalized == "lock" { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/pmset") task.arguments = ["displaysleepnow"] try? task.run() HistoryManager.shared.addEntry("⚡️ [System] Lock Screen") - return true + return "Display Sleep / Lock Screen" } // Sleep - if lower == "اسلیپ" || lower == "sleep" { + if normalized == "اسلیپ" || normalized == "sleep" { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/pmset") task.arguments = ["sleepnow"] try? task.run() HistoryManager.shared.addEntry("⚡️ [System] Sleep Mac") - return true + return "System Sleep (pmset sleepnow)" } // Screenshot (Interactive) - if lower.contains("اسکرین شات") || lower.contains("عکس صفحه") || lower == "screenshot" { + if normalized.contains("اسکرین شات") || normalized.contains("عکس صفحه") || normalized == "screenshot" { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") task.arguments = ["-i", "-c"] try? task.run() HistoryManager.shared.addEntry("⚡️ [System] Interactive Screenshot") - return true - } - - // Full Screenshot - if lower.contains("اسکرین شات فول") || lower.contains("اسکرین شات کامل") || lower == "full screenshot" { - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") - task.arguments = ["-c"] - try? task.run() - HistoryManager.shared.addEntry("⚡️ [System] Full Screenshot to Clipboard") - return true + return "Interactive Screenshot to Clipboard" } // Dark Mode toggle - if lower.contains("دارک مود") || lower.contains("لایت مود") || lower.contains("حالت شب") || lower.contains("حالت روز") || lower.contains("dark mode") { + if normalized.contains("دارک مود") || normalized.contains("لایت مود") || normalized.contains("حالت شب") || normalized.contains("حالت روز") || normalized.contains("dark mode") { runAppleScript("tell application \"System Events\" to tell appearance preferences to set dark mode to not dark mode") HistoryManager.shared.addEntry("⚡️ [System] Toggle Dark Mode") - return true + return "Toggle Dark Mode Appearance" } - // Clipboard & Editing hotkeys - if lower == "کپی" || lower == "copy" { + // Editing hotkeys + if normalized == "کپی" || normalized == "copy" { pressHotkey(keyCode: 8, modifiers: .maskCommand) // Cmd+C HistoryManager.shared.addEntry("⚡️ [Edit] Copy") - return true + return "Copy (Cmd+C)" } - if lower == "پیست" || lower == "paste" { + if normalized == "پیست" || normalized == "paste" { pressHotkey(keyCode: 9, modifiers: .maskCommand) // Cmd+V HistoryManager.shared.addEntry("⚡️ [Edit] Paste") - return true + return "Paste (Cmd+V)" } - if lower == "کات" || lower == "cut" { + if normalized == "کات" || normalized == "cut" { pressHotkey(keyCode: 7, modifiers: .maskCommand) // Cmd+X HistoryManager.shared.addEntry("⚡️ [Edit] Cut") - return true + return "Cut (Cmd+X)" } - if lower.contains("انتخاب همه") || lower.contains("سلکت آل") || lower == "select all" { + if normalized.contains("انتخاب همه") || normalized.contains("سلکت ال") || normalized == "select all" { pressHotkey(keyCode: 0, modifiers: .maskCommand) // Cmd+A HistoryManager.shared.addEntry("⚡️ [Edit] Select All") - return true + return "Select All (Cmd+A)" } - if lower == "آندو" || lower == "برگرد" || lower == "undo" { + if normalized == "اندو" || normalized == "برگرد" || normalized == "undo" { pressHotkey(keyCode: 6, modifiers: .maskCommand) // Cmd+Z HistoryManager.shared.addEntry("⚡️ [Edit] Undo") - return true + return "Undo (Cmd+Z)" } - if lower == "ریدو" || lower == "redo" { + if normalized == "ریدو" || normalized == "redo" { pressHotkey(keyCode: 6, modifiers: [.maskCommand, .maskShift]) // Cmd+Shift+Z HistoryManager.shared.addEntry("⚡️ [Edit] Redo") - return true + return "Redo (Cmd+Shift+Z)" } - if lower == "سیو" || lower == "ذخیره" || lower == "save" { + if normalized == "سیو" || normalized == "ذخیره" || normalized == "save" { pressHotkey(keyCode: 1, modifiers: .maskCommand) // Cmd+S HistoryManager.shared.addEntry("⚡️ [Edit] Save") - return true + return "Save (Cmd+S)" } - if lower == "فایند" || lower == "پیدا کن" || lower == "find" { + if normalized == "فایند" || normalized == "پیدا کن" || normalized == "find" { pressHotkey(keyCode: 3, modifiers: .maskCommand) // Cmd+F HistoryManager.shared.addEntry("⚡️ [Edit] Find") - return true + return "Find (Cmd+F)" } - if lower == "اینتر" || lower == "enter" { + if normalized == "اینتر" || normalized == "enter" { pressHotkey(keyCode: 36, modifiers: []) // Return HistoryManager.shared.addEntry("⚡️ [Edit] Enter") - return true + return "Press Enter Key" } - if lower.contains("پاک کردن خط") || lower == "delete line" { + if normalized.contains("پاک کردن خط") || normalized == "delete line" { pressHotkey(keyCode: 51, modifiers: .maskCommand) // Cmd+Backspace HistoryManager.shared.addEntry("⚡️ [Edit] Delete Line") - return true + return "Delete Line (Cmd+Backspace)" } - return false + return nil } - // MARK: - 6. Persian Dictionary Apps - private func handleKnownApp(lower: String, original: String) -> Bool { - var query = lower + // MARK: - Known Apps Matching + private func matchKnownApp(normalized: String) -> String? { + var query = normalized for prefix in ["باز کن ", "بازکن ", "برو به ", "برو تو ", "اجرا کن ", "open ", "launch ", "goto "] { if query.hasPrefix(prefix) { query = String(query.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) @@ -692,17 +885,15 @@ public final class VoiceCommandExecutor { for (persianKey, englishAppName) in persianAppDictionary { if query == persianKey || query.contains(persianKey) { - launchOrActivateApp(named: englishAppName) - HistoryManager.shared.addEntry("⚡️ [App] Open \(englishAppName)") - return true + return englishAppName } } - return false + return nil } - // MARK: - 7. Universal Installed App Scanner - private func handleUniversalInstalledApp(query: String) -> Bool { + // MARK: - Universal Installed App Scanner + private func handleUniversalInstalledApp(query: String, normalized: String) -> String? { var cleanQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) for prefix in ["باز کن ", "بازکن ", "open ", "launch "] { if cleanQuery.lowercased().hasPrefix(prefix) { @@ -710,28 +901,25 @@ public final class VoiceCommandExecutor { break } } - guard !cleanQuery.isEmpty else { return false } + guard !cleanQuery.isEmpty else { return nil } let normalizedQuery = normalizeForMatching(cleanQuery) refreshInstalledApps() - // Exact match if let match = cachedInstalledApps.first(where: { $0.normalizedName == normalizedQuery }) { launchApp(at: match.url, name: match.name) - return true + return match.name } - // Prefix match if let match = cachedInstalledApps.first(where: { $0.normalizedName.hasPrefix(normalizedQuery) }) { launchApp(at: match.url, name: match.name) - return true + return match.name } - // Substring match if let match = cachedInstalledApps.first(where: { $0.normalizedName.contains(normalizedQuery) || normalizedQuery.contains($0.normalizedName) }) { launchApp(at: match.url, name: match.name) - return true + return match.name } - return false + return nil } private func launchApp(at url: URL, name: String) { diff --git a/src/VoiceCommandLogger.swift b/src/VoiceCommandLogger.swift new file mode 100644 index 0000000..8b20193 --- /dev/null +++ b/src/VoiceCommandLogger.swift @@ -0,0 +1,119 @@ +import Foundation +import Cocoa + +public final class VoiceCommandLogger { + public static let shared = VoiceCommandLogger() + + public let logFileURL: URL + private let queue = DispatchQueue(label: "com.soniox.voicecommandlogger", qos: .utility) + private let dateFormatter: DateFormatter + + private init() { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let dir = appSupport.appendingPathComponent("SonioxVoice") + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + self.logFileURL = dir.appendingPathComponent("voice_commands.log") + + self.dateFormatter = DateFormatter() + self.dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + ensureLogHeader() + } + + private func ensureLogHeader() { + if !FileManager.default.fileExists(atPath: logFileURL.path) { + let header = """ + ================================================================================ + Soniox Voice Commands Execution & Debug Log + Log Location: \(logFileURL.path) + Started: \(dateFormatter.string(from: Date())) + ================================================================================ + + + """ + try? header.write(to: logFileURL, atomically: true, encoding: .utf8) + } + } + + public func logCommand( + rawSpeech: String, + body: String, + normalized: String, + actionCategory: String, + actionDetail: String, + status: String + ) { + queue.async { + let timestamp = self.dateFormatter.string(from: Date()) + let statusIcon = status.contains("SUCCESS") ? "✅" : "❌" + let entry = """ + -------------------------------------------------------------------------------- + [\(timestamp)] ⚡️ VOICE SLASH COMMAND + 🎤 Raw Soniox Speech: "\(rawSpeech)" + ✂️ Extracted Body: "\(body)" + 🔍 Normalized: "\(normalized)" + 🏷️ Category: \(actionCategory) + 🎯 Executed Action: \(actionDetail) + \(statusIcon) Status: \(status) + -------------------------------------------------------------------------------- + + """ + self.appendToFile(text: entry) + } + } + + public func logDictation( + rawSpeech: String, + processed: String + ) { + queue.async { + let timestamp = self.dateFormatter.string(from: Date()) + let entry = """ + -------------------------------------------------------------------------------- + [\(timestamp)] ✍️ VOICE DICTATION (Normal Speech) + 🎤 Raw Soniox Speech: "\(rawSpeech)" + 📝 Processed Text: "\(processed)" + 📋 Destination: Pasted to Frontmost Application + ✅ Status: SUCCESS + -------------------------------------------------------------------------------- + + """ + self.appendToFile(text: entry) + } + } + + private func appendToFile(text: String) { + guard let data = text.data(using: .utf8) else { return } + if let fileHandle = try? FileHandle(forWritingTo: logFileURL) { + fileHandle.seekToEndOfFile() + fileHandle.write(data) + try? fileHandle.close() + } else { + try? data.write(to: logFileURL, options: .atomic) + } + } + + public func clearLog() { + queue.async { + let timestamp = self.dateFormatter.string(from: Date()) + let header = """ + ================================================================================ + Soniox Voice Commands Execution & Debug Log (Cleared) + Reset At: \(timestamp) + ================================================================================ + + + """ + try? header.write(to: self.logFileURL, atomically: true, encoding: .utf8) + } + } + + public func openLogInViewer() { + // Open log in default text editor or Console + NSWorkspace.shared.open(logFileURL) + } + + public func openLogFolder() { + NSWorkspace.shared.activateFileViewerSelecting([logFileURL]) + } +}