diff --git a/app.go b/app.go index b0fbb5e..4850f62 100644 --- a/app.go +++ b/app.go @@ -14,8 +14,10 @@ import ( "time" "yap/internal/audio" + "yap/internal/diagnostics" "yap/internal/hotkey" "yap/internal/models" + "yap/internal/obsidian" "yap/internal/overlay" "yap/internal/sounds" "yap/internal/system" @@ -27,11 +29,15 @@ import ( // RecordingState represents the current state of the app type RecordingState string +type RecordingMode string + const ( StateReady RecordingState = "ready" StateRecording RecordingState = "recording" StateTranscribing RecordingState = "transcribing" StateError RecordingState = "error" + + RecordingModeNormal RecordingMode = "normal" ) // AppState is sent to the frontend @@ -86,15 +92,17 @@ type App struct { statsManager *models.StatsManager hotkeyManager *hotkey.Manager overlay *overlay.Overlay + diagnostics *diagnostics.Logger mu sync.Mutex state RecordingState lastTranscript string lastError string recordStartTime time.Time + recordingMode RecordingMode hotkeyEnabled bool history []HistoryItem - + // Tray callback to update icon onTrayUpdate func(recording bool) } @@ -108,17 +116,17 @@ func NewApp() *App { state: StateReady, history: make([]HistoryItem, 0), } - + // Set up overlay stop callback app.overlay.SetStopCallback(func() { app.ToggleRecording() }) - + // Set up overlay cancel callback app.overlay.SetCancelCallback(func() { app.CancelRecording() }) - + return app } @@ -145,6 +153,8 @@ func (a *App) startup(ctx context.Context) { return } a.configManager = configManager + a.diagnostics = diagnostics.New(configManager.GetConfigDir()) + a.logInfo("startup", map[string]any{"platform": goruntime.GOOS}) // Initialize stats manager statsManager, err := models.NewStatsManager(configManager.GetConfigDir()) @@ -189,22 +199,24 @@ func (a *App) startup(ctx context.Context) { hotkeyType = models.DefaultRecordingHotkey() } a.hotkeyManager.SetHotkeyType(hotkeyType) - + // Apply configured cancel key cancelKey := configManager.Get().CancelHotkey if cancelKey == "" { cancelKey = "escape" } a.hotkeyManager.SetCancelKey(cancelKey) - + // Register global hotkey if err := a.hotkeyManager.Register(func() { a.ToggleRecording() - }); err != nil { + }, nil); err != nil { fmt.Printf("Warning: Failed to register hotkey: %v\n", err) + a.logWarn("hotkey_register_failed", map[string]any{"error": err.Error()}) } else { a.hotkeyEnabled = true fmt.Printf("Global hotkey registered: %s\n", hotkey.GetHotkeyDisplayName(hotkeyType)) + a.logInfo("hotkey_registered", map[string]any{"recordingHotkey": hotkeyType}) } // Load history from disk @@ -236,7 +248,7 @@ func (a *App) GetState() AppState { config := a.configManager.Get() modelReady := false if config.Provider == "local" { - modelReady = a.modelManager.IsModelDownloaded(config.Model) + modelReady = a.modelManager.IsModelDownloaded(config.Model) && a.localEngine != nil && a.localEngine.IsAvailable() } else { modelReady = a.openaiEngine.IsAvailable() } @@ -260,6 +272,24 @@ func (a *App) GetHistory() []HistoryItem { return a.history } +func (a *App) logInfo(event string, fields map[string]any) { + if a.diagnostics != nil { + a.diagnostics.Info(event, fields) + } +} + +func (a *App) logWarn(event string, fields map[string]any) { + if a.diagnostics != nil { + a.diagnostics.Warn(event, fields) + } +} + +func (a *App) logError(event string, fields map[string]any) { + if a.diagnostics != nil { + a.diagnostics.Error(event, fields) + } +} + // ClearHistory clears all history func (a *App) ClearHistory() { a.mu.Lock() @@ -328,7 +358,7 @@ func (a *App) saveHistory() { func (a *App) CopyHistoryItem(id string) error { a.mu.Lock() defer a.mu.Unlock() - + for _, item := range a.history { if item.ID == id { return system.CopyToClipboard(item.Text) @@ -340,7 +370,7 @@ func (a *App) CopyHistoryItem(id string) error { // DeleteHistoryItem deletes a history item by ID func (a *App) DeleteHistoryItem(id string) error { a.mu.Lock() - + // Find and remove the item var audioPath string found := false @@ -353,18 +383,18 @@ func (a *App) DeleteHistoryItem(id string) error { } } a.mu.Unlock() - + if !found { return fmt.Errorf("history item not found") } - + // Delete the audio file if it exists if audioPath != "" { if err := os.Remove(audioPath); err != nil && !os.IsNotExist(err) { fmt.Printf("Warning: Failed to delete audio file: %v\n", err) } } - + a.saveHistory() runtime.EventsEmit(a.ctx, "historyChanged", a.history) return nil @@ -400,18 +430,18 @@ func (a *App) ShowInFolder(id string) error { func (a *App) GetAudioData(id string) (string, error) { a.mu.Lock() defer a.mu.Unlock() - + for _, item := range a.history { if item.ID == id { if !item.HasAudio || item.AudioPath == "" { return "", fmt.Errorf("no audio available for this item") } - + data, err := audio.LoadWAV(item.AudioPath) if err != nil { return "", fmt.Errorf("failed to load audio: %v", err) } - + // Return as base64 return base64.StdEncoding.EncodeToString(data), nil } @@ -421,6 +451,10 @@ func (a *App) GetAudioData(id string) (string, error) { // ToggleRecording starts or stops recording func (a *App) ToggleRecording() error { + return a.toggleRecordingMode(RecordingModeNormal) +} + +func (a *App) toggleRecordingMode(mode RecordingMode) error { a.mu.Lock() currentState := a.state a.mu.Unlock() @@ -428,35 +462,46 @@ func (a *App) ToggleRecording() error { if currentState == StateRecording { return a.StopRecording() } - return a.StartRecording() + return a.startRecording(mode) } // StartRecording begins audio capture func (a *App) StartRecording() error { + return a.startRecording(RecordingModeNormal) +} + +func (a *App) startRecording(mode RecordingMode) error { runtime.LogInfo(a.ctx, "StartRecording called") fmt.Println("StartRecording: entering function") - - // Save the current frontmost app before we do anything (for auto-paste later) - system.SaveFrontmostApp() - + + if mode == RecordingModeNormal { + // Save the current frontmost app before we do anything (for auto-paste later). + system.SaveFrontmostApp() + } + a.mu.Lock() + if a.state == StateError { + a.state = StateReady + a.lastError = "" + } if a.state != StateReady { a.mu.Unlock() runtime.LogWarning(a.ctx, fmt.Sprintf("Cannot start recording in state: %s", a.state)) return fmt.Errorf("cannot start recording in state: %s", a.state) } - + // Check if sound is enabled soundEnabled := a.configManager != nil && (a.configManager.Get().SoundEnabled == nil || *a.configManager.Get().SoundEnabled) fmt.Printf("StartRecording: soundEnabled=%v\n", soundEnabled) - + // Set state to recording first a.state = StateRecording + a.recordingMode = mode a.lastError = "" a.recordStartTime = time.Now() onTrayUpdate := a.onTrayUpdate a.mu.Unlock() - + // Play start sound (non-blocking) - afplay goes to speakers, not mic input if soundEnabled { fmt.Println("StartRecording: playing start sound") @@ -474,7 +519,7 @@ func (a *App) StartRecording() error { // Create fresh recorder for each recording session a.recorder = audio.NewRecorder() - + // Set audio device from config if available if a.configManager != nil { config := a.configManager.Get() @@ -516,6 +561,7 @@ func (a *App) StopRecording() error { return fmt.Errorf("not recording") } recordDuration := time.Since(a.recordStartTime).Seconds() + mode := a.recordingMode a.state = StateTranscribing a.mu.Unlock() @@ -539,7 +585,7 @@ func (a *App) StopRecording() error { return err } - go a.transcribe(samples, recordDuration) + go a.transcribe(samples, recordDuration, mode) return nil } @@ -552,6 +598,7 @@ func (a *App) CancelRecording() error { return fmt.Errorf("not recording") } a.state = StateReady + a.recordingMode = RecordingModeNormal onTrayUpdate := a.onTrayUpdate a.mu.Unlock() @@ -572,7 +619,6 @@ func (a *App) CancelRecording() error { a.emitState() - return nil } @@ -587,36 +633,62 @@ func (a *App) getAudioDir() string { } // transcribe processes the audio samples -func (a *App) transcribe(samples []float32, duration float64) { +func (a *App) transcribe(samples []float32, duration float64, mode RecordingMode) { config := a.configManager.Get() var text string var err error + capturedAt := time.Now() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() + a.logInfo("transcription_started", map[string]any{ + "mode": mode, + "provider": config.Provider, + "durationSec": fmt.Sprintf("%.2f", duration), + "samples": len(samples), + }) + if config.Provider == "openai" { text, err = a.openaiEngine.Transcribe(ctx, samples) } else { text, err = a.localEngine.Transcribe(ctx, samples) } + if err != nil { + a.logError("transcription_failed", map[string]any{"mode": mode, "provider": config.Provider, "error": err.Error()}) + } else { + a.logInfo("transcription_completed", map[string]any{"mode": mode, "provider": config.Provider, "textLength": len(text)}) + } + + if err == nil && a.configManager.IsObsidianExtensionInstalled() { + a.overlay.SetStatus("Saving to Obsidian...") + if notePath, writeErr := obsidian.AppendTranscription(config.ObsidianVaultPath, config.ObsidianNoteName, text, capturedAt); writeErr != nil { + a.logError("obsidian_write_failed", map[string]any{"error": writeErr.Error()}) + fmt.Printf("Warning: Failed to write Obsidian note: %v\n", writeErr) + } else { + fmt.Printf("Saved Obsidian note to: %s\n", notePath) + a.logInfo("obsidian_write_completed", map[string]any{"notePath": notePath}) + } + } - // Generate unique ID for this recording - recordingID := fmt.Sprintf("%d", time.Now().UnixNano()) + // Generate unique ID for normal recordings + recordingID := fmt.Sprintf("%d", capturedAt.UnixNano()) // Save audio to file var audioPath string var hasAudio bool - audioDir := a.getAudioDir() - if audioDir != "" { - audioPath = filepath.Join(audioDir, recordingID+".wav") - if saveErr := audio.SaveWAV(audioPath, samples); saveErr != nil { - fmt.Printf("Warning: Failed to save audio: %v\n", saveErr) - audioPath = "" - } else { - hasAudio = true - fmt.Printf("Saved audio to: %s\n", audioPath) + if err == nil { + audioDir := a.getAudioDir() + if audioDir != "" { + audioPath = filepath.Join(audioDir, recordingID+".wav") + if saveErr := audio.SaveWAV(audioPath, samples); saveErr != nil { + fmt.Printf("Warning: Failed to save audio: %v\n", saveErr) + audioPath = "" + } else { + hasAudio = true + fmt.Printf("Saved audio to: %s\n", audioPath) + } } } @@ -625,21 +697,27 @@ func (a *App) transcribe(samples []float32, duration float64) { if err != nil { a.state = StateError a.lastError = err.Error() + a.logError("recording_finished_with_error", map[string]any{"mode": mode, "error": err.Error()}) + if text != "" { + a.lastTranscript = text + } } else { a.state = StateReady a.lastTranscript = text + a.lastError = "" + a.logInfo("recording_finished", map[string]any{"mode": mode}) // Add to history historyItem := HistoryItem{ ID: recordingID, Text: text, - Timestamp: time.Now().Format("2 Jan 2006, 3:04 pm"), + Timestamp: capturedAt.Format("2 Jan 2006, 3:04 pm"), Duration: duration, AudioPath: audioPath, HasAudio: hasAudio, } a.history = append([]HistoryItem{historyItem}, a.history...) - + // Keep only last 50 items if len(a.history) > 50 { // Delete audio files for items being removed @@ -678,6 +756,7 @@ func (a *App) transcribe(samples []float32, duration float64) { go system.CopyToClipboard(text) } } + a.recordingMode = RecordingModeNormal a.mu.Unlock() // Update tray icon @@ -812,7 +891,7 @@ func (a *App) GetStats() UsageStats { if a.statsManager == nil { return UsageStats{} } - + stats := a.statsManager.Get() return UsageStats{ AverageWPM: a.statsManager.GetAverageWPM(), @@ -831,14 +910,17 @@ func (a *App) SetRecordingHotkey(keyName string) error { if keyName == "" { return fmt.Errorf("hotkey cannot be empty") } - - // Update the hotkey manager + + // Save first so the live hotkey state cannot drift if persistence fails. + if err := a.configManager.SetRecordingHotkey(keyName); err != nil { + return err + } + if a.hotkeyManager != nil { a.hotkeyManager.SetHotkeyType(keyName) } - - // Save to config - return a.configManager.SetRecordingHotkey(keyName) + + return nil } // GetRecordingHotkey returns the current recording hotkey string @@ -860,14 +942,17 @@ func (a *App) SetCancelHotkey(keyName string) error { if keyName == "" { return fmt.Errorf("cancel hotkey cannot be empty") } - - // Update the hotkey manager + + // Save first so the live hotkey state cannot drift if persistence fails. + if err := a.configManager.SetCancelHotkey(keyName); err != nil { + return err + } + if a.hotkeyManager != nil { a.hotkeyManager.SetCancelKey(keyName) } - - // Save to config - return a.configManager.SetCancelHotkey(keyName) + + return nil } // GetCancelHotkey returns the current cancel hotkey string @@ -882,6 +967,67 @@ func (a *App) GetCancelHotkey() string { return "escape" } +// SetObsidianVaultPath sets the Obsidian vault path for notes. +func (a *App) SetObsidianVaultPath(path string) error { + if !a.IsObsidianExtensionInstalled() { + return fmt.Errorf("install the Obsidian extension before setting a vault path") + } + return a.configManager.SetObsidianVaultPath(strings.TrimSpace(path)) +} + +// SetObsidianNoteName sets the Obsidian note filename template. +func (a *App) SetObsidianNoteName(name string) error { + if !a.IsObsidianExtensionInstalled() { + return fmt.Errorf("install the Obsidian extension before setting a note name") + } + return a.configManager.SetObsidianNoteName(strings.TrimSpace(name)) +} + +// GetObsidianNoteName returns the configured Obsidian note filename template. +func (a *App) GetObsidianNoteName() string { + if a.configManager != nil && strings.TrimSpace(a.configManager.Get().ObsidianNoteName) != "" { + return a.configManager.Get().ObsidianNoteName + } + return obsidian.DefaultNoteName +} + +// GetObsidianVaultPath returns the configured Obsidian vault path. +func (a *App) GetObsidianVaultPath() string { + if a.configManager != nil && a.configManager.IsObsidianExtensionInstalled() { + return a.configManager.Get().ObsidianVaultPath + } + return "" +} + +// IsObsidianExtensionInstalled returns whether the Obsidian integration is enabled. +func (a *App) IsObsidianExtensionInstalled() bool { + return a.configManager != nil && a.configManager.IsObsidianExtensionInstalled() +} + +// InstallObsidianExtension enables the Obsidian integration. +func (a *App) InstallObsidianExtension() error { + if a.configManager == nil { + return fmt.Errorf("config manager not initialized") + } + return a.configManager.InstallObsidianExtension() +} + +// UninstallObsidianExtension disables the Obsidian integration and clears its settings. +func (a *App) UninstallObsidianExtension() error { + if a.configManager == nil { + return fmt.Errorf("config manager not initialized") + } + if err := a.configManager.UninstallObsidianExtension(); err != nil { + return err + } + return nil +} + +// GetObsidianDestinationPreview returns the vault-relative daily Obsidian note path. +func (a *App) GetObsidianDestinationPreview() string { + return obsidian.TranscriptionsRelativePath(a.GetObsidianNoteName(), time.Now()) +} + // GetPlatform returns the current operating system func (a *App) GetPlatform() string { return goruntime.GOOS @@ -920,31 +1066,31 @@ func (a *App) ReregisterHotkey() error { if a.hotkeyManager == nil { return fmt.Errorf("hotkey manager not initialized") } - + // Unregister current hotkey a.hotkeyManager.Unregister() - + // Re-apply hotkey settings hotkeyType := a.configManager.Get().RecordingHotkey if hotkeyType == "" { hotkeyType = models.DefaultRecordingHotkey() } a.hotkeyManager.SetHotkeyType(hotkeyType) - + cancelKey := a.configManager.Get().CancelHotkey if cancelKey == "" { cancelKey = "escape" } a.hotkeyManager.SetCancelKey(cancelKey) - + // Register the hotkey if err := a.hotkeyManager.Register(func() { a.ToggleRecording() - }); err != nil { + }, nil); err != nil { a.hotkeyEnabled = false return fmt.Errorf("failed to register hotkey: %w", err) } - + a.hotkeyEnabled = true fmt.Printf("Hotkey re-registered: %s\n", hotkey.GetHotkeyDisplayName(hotkeyType)) return nil diff --git a/docs/obsidian-integration.md b/docs/obsidian-integration.md new file mode 100644 index 0000000..74f073b --- /dev/null +++ b/docs/obsidian-integration.md @@ -0,0 +1,69 @@ +# Obsidian Integration + +## Goal + +When the Obsidian extension is installed, Yap appends every successful transcription to an Obsidian daily note while preserving the normal Yap flow: history, audio archive, stats, clipboard, and auto-paste. + +## Integration Strategy + +Use direct Markdown file writes into the selected Obsidian vault. Obsidian vaults are local folders of Markdown files and Obsidian watches filesystem changes automatically. This avoids requiring Obsidian to be open and avoids mandatory community plugin dependencies. + +Alternatives considered: + +- Native `obsidian://` URIs can open vaults and notes, but native append support is limited. +- Advanced URI and Actions URI can append content, but require community plugins. +- Local REST API is powerful, but requires a community plugin, API key, local server, and certificate handling. + +## Note Layout + +Yap writes to a dedicated `Yap` folder in the configured vault. The note name is configurable, and defaults to a daily note: + +```text +/Yap/Transcriptions YYYY-MM-DD.md +``` + +The default note name template is `Transcriptions {{date}}`, where `{{date}}` expands to `YYYY-MM-DD`. A static name such as `Inbox` writes to `/Yap/Inbox.md`. + +Example: + +```text +~/Documents/Obsidian/MainVault/Yap/Transcriptions 2026-06-04.md +``` + +New daily note content: + +```md +--- +source: yap +type: transcriptions +date: 2026-06-04 +--- + +# Transcriptions 2026-06-04 + +## 14:32 + +Transcribed text goes here. +``` + +Subsequent captures append to the same daily note: + +```md +## 16:08 + +Another capture goes here. +``` + +## Implemented Behavior + +- The Obsidian extension is installed and uninstalled from the Extensions page. +- The extension stores an enabled flag, the selected Obsidian vault path, and the configured note name. +- The note name always resolves to a Markdown file inside the `Yap` folder. +- When installed, every successful transcription must be written to Obsidian. +- Normal Yap behavior remains active: history, audio saving, stats, clipboard, and auto-paste still run after a successful Obsidian write. +- If Obsidian writing fails after transcription, Yap surfaces the error and keeps the transcript in app state. +- Uninstalling the extension disables Obsidian writes and clears the vault path. + +## First Version Scope + +Ship plugin-free direct vault writing. REST API and URI-based integrations can be added later if users need remote vault operations, command execution, or richer Obsidian automation. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index efb3030..0e645d8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.2.0", + "version": "0.4.0", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index ebfbdac..5ddc60b 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -a93b9b9ee0731e091d302c100808c109 \ No newline at end of file +5ed087423043b58e5b2fc9dd9c3156b1 \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css index 254dc3e..0048ab1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -718,6 +718,136 @@ body { margin-bottom: 16px; } +/* Extensions Page */ +.extensions-page { + flex: 1; + padding: 32px; + overflow-y: auto; +} + +.extensions-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 16px; + margin-bottom: 32px; +} + +.extension-card { + display: grid; + grid-template-columns: 56px 1fr auto; + align-items: center; + gap: 16px; + padding: 18px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 16px; +} + +.extension-icon { + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 14px; + background: var(--bg-dark); +} + +.extension-icon img { + width: 42px; + height: 42px; +} + +.obsidian-icon { + background: radial-gradient(circle at 30% 20%, rgba(161, 139, 255, 0.25), rgba(79, 44, 170, 0.12)); +} + +.extension-content h2 { + font-size: 17px; + font-weight: 600; + color: var(--text-primary); +} + +.extension-content p { + margin-top: 6px; + max-width: 440px; + color: var(--text-muted); + font-size: 13px; + line-height: 1.45; +} + +.extension-title-row { + display: flex; + align-items: center; + gap: 10px; +} + +.extension-status { + padding: 3px 8px; + border-radius: 999px; + background: var(--bg-dark); + color: var(--text-muted); + font-size: 11px; + font-weight: 600; +} + +.extension-status.installed { + background: rgba(48, 209, 88, 0.16); + color: var(--success); +} + +.extension-actions { + display: flex; + gap: 8px; +} + +.primary-button, +.secondary-button, +.danger-button { + padding: 9px 14px; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} + +.primary-button { + background: var(--accent); + color: #000; +} + +.secondary-button { + background: var(--bg-dark); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.extension-actions .secondary-button { + border: 1px solid rgba(255, 255, 255, 0.28); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); +} + +.extension-actions .secondary-button:hover { + border-color: var(--accent); +} + +.danger-button { + background: rgba(255, 69, 58, 0.14); + color: var(--danger); +} + +.primary-button:hover, +.secondary-button:hover, +.danger-button:hover { + transform: translateY(-1px); +} + +.extension-settings { + max-width: 900px; +} + .setting-row { display: flex; justify-content: space-between; @@ -846,6 +976,39 @@ body { width: 200px; } +.api-key-input.vault-path-input input { + width: 360px; +} + +.hotkey-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.clear-hotkey-btn { + padding: 10px 14px; + background: var(--bg-dark); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-secondary); + font-size: 14px; + cursor: pointer; +} + +.clear-hotkey-btn:hover { + border-color: var(--text-secondary); + color: var(--text-primary); +} + +.destination-preview { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.braincache-hint { + margin-top: 8px; +} + .api-key-input input:focus { outline: none; border-color: var(--accent); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e36cd00..b406325 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import './App.css'; import { RecordingOverlay } from './RecordingOverlay'; import { Onboarding } from './Onboarding'; import packageJson from '../package.json'; +import obsidianLogo from './assets/obsidian-logo.svg'; // Sound playback is now handled natively in Go (internal/sounds) import { GetState, @@ -27,11 +28,18 @@ import { GetStats, GetRecordingHotkey, SetRecordingHotkey, + GetObsidianVaultPath, + SetObsidianVaultPath, + GetObsidianNoteName, + SetObsidianNoteName, + GetObsidianDestinationPreview, GetCancelHotkey, SetCancelHotkey, GetPlatform, Quit, IsOnboardingCompleted, + InstallObsidianExtension, + UninstallObsidianExtension, } from '../wailsjs/go/main/App'; import { EventsOn, LogInfo } from '../wailsjs/runtime/runtime'; @@ -61,6 +69,9 @@ interface Config { audioInputDevice?: string; autoPaste: boolean; soundEnabled?: boolean; + obsidianVaultPath?: string; + obsidianNoteName?: string; + obsidianExtensionInstalled: boolean; } interface HistoryItem { @@ -93,7 +104,11 @@ interface UsageStats { totalWords: number; } -type Page = 'home' | 'settings' | 'history'; +type Page = 'home' | 'extensions' | 'settings' | 'history'; + +const getErrorMessage = (error: unknown): string => { + return error instanceof Error ? error.message : String(error); +}; function App() { const [appState, setAppState] = useState({ @@ -127,6 +142,10 @@ function App() { totalWords: 0, }); const [currentHotkey, setCurrentHotkey] = useState('rightoption'); + const [obsidianVaultPath, setObsidianVaultPathState] = useState(''); + const [obsidianNoteName, setObsidianNoteNameState] = useState('Transcriptions {{date}}'); + const [obsidianDestination, setObsidianDestination] = useState('Yap/Transcriptions YYYY-MM-DD.md'); + const [managingObsidian, setManagingObsidian] = useState(false); const [cancelHotkey, setCancelHotkey] = useState('escape'); const [showOnboarding, setShowOnboarding] = useState(null); const [platform, setPlatform] = useState('darwin'); @@ -150,11 +169,16 @@ function App() { setConfig(cfg); setApiKey(cfg.openaiApiKey || ''); setSelectedAudioDevice(cfg.audioInputDevice || ''); + setObsidianVaultPathState(cfg.obsidianVaultPath || ''); + setObsidianNoteNameState(cfg.obsidianNoteName || 'Transcriptions {{date}}'); }); GetHistory().then((h: HistoryItem[]) => setHistory(h)); GetAudioInputDevices().then((devices: AudioInputDevice[]) => setAudioDevices(devices)); GetStats().then((s: UsageStats) => setStats(s)); GetRecordingHotkey().then((h: string) => setCurrentHotkey(h)); + GetObsidianVaultPath().then((path: string) => setObsidianVaultPathState(path)); + GetObsidianNoteName().then((name: string) => setObsidianNoteNameState(name)); + GetObsidianDestinationPreview().then((path: string) => setObsidianDestination(path)); GetCancelHotkey().then((h: string) => setCancelHotkey(h)); LogInfo('Setting up EventsOn for stateChanged'); @@ -351,20 +375,82 @@ function App() { }, []); const handleHotkeyChange = useCallback(async (keyName: string) => { - setCurrentHotkey(keyName); try { await SetRecordingHotkey(keyName); + setCurrentHotkey(keyName); } catch (error) { console.error('Failed to set hotkey:', error); + alert(getErrorMessage(error)); + GetRecordingHotkey().then((h: string) => setCurrentHotkey(h)); + } + }, []); + + const handleObsidianVaultPathChange = useCallback(async (path: string) => { + setObsidianVaultPathState(path); + try { + await SetObsidianVaultPath(path); + GetConfig().then((c: Config) => setConfig(c)); + } catch (error) { + console.error('Failed to set Obsidian vault path:', error); + } + }, []); + + const handleObsidianNoteNameChange = useCallback(async (name: string) => { + setObsidianNoteNameState(name); + try { + await SetObsidianNoteName(name); + const cfg = await GetConfig(); + setConfig(cfg); + const savedName = await GetObsidianNoteName(); + setObsidianNoteNameState(savedName); + const preview = await GetObsidianDestinationPreview(); + setObsidianDestination(preview); + } catch (error) { + console.error('Failed to set Obsidian note name:', error); + alert(getErrorMessage(error)); + GetObsidianNoteName().then((savedName: string) => setObsidianNoteNameState(savedName)); + GetObsidianDestinationPreview().then((path: string) => setObsidianDestination(path)); + } + }, []); + + const handleInstallObsidian = useCallback(async () => { + try { + await InstallObsidianExtension(); + const cfg = await GetConfig(); + setConfig(cfg); + setManagingObsidian(true); + } catch (error) { + console.error('Failed to install Obsidian extension:', error); + alert(getErrorMessage(error)); + } + }, []); + + const handleUninstallObsidian = useCallback(async () => { + if (!confirm('Uninstall the Obsidian extension? This clears its vault path.')) { + return; + } + try { + await UninstallObsidianExtension(); + const cfg = await GetConfig(); + setConfig(cfg); + setObsidianVaultPathState(''); + setObsidianNoteNameState('Transcriptions {{date}}'); + GetObsidianDestinationPreview().then((path: string) => setObsidianDestination(path)); + setManagingObsidian(false); + } catch (error) { + console.error('Failed to uninstall Obsidian extension:', error); + alert(getErrorMessage(error)); } }, []); const handleCancelKeyChange = useCallback(async (keyName: string) => { - setCancelHotkey(keyName); try { await SetCancelHotkey(keyName); + setCancelHotkey(keyName); } catch (error) { console.error('Failed to set cancel key:', error); + alert(getErrorMessage(error)); + GetCancelHotkey().then((h: string) => setCancelHotkey(h)); } }, []); @@ -528,6 +614,7 @@ function App() { const currentModel = models.find(m => m.name === appState.currentModel); const needsDownload = appState.currentProvider === 'local' && currentModel && !currentModel.downloaded; + const obsidianInstalled = config?.obsidianExtensionInstalled === true || Boolean(config?.obsidianVaultPath); const handleOnboardingComplete = useCallback(() => { setShowOnboarding(false); @@ -569,6 +656,23 @@ function App() { Home + + + + + ) : ( + + )} + + + + + {obsidianInstalled && managingObsidian && ( +
+

Obsidian Settings

+ +
+
+ +

Transcriptions are appended to daily notes in the Yap folder in this vault

+
+
+ setObsidianVaultPathState(e.target.value)} + placeholder="/Users/you/Documents/Obsidian/Vault" + /> + +
+
+ +
+
+ +

Stored inside the Yap folder. Use {'{{date}}'} for a daily note.

+
+
+ setObsidianNoteNameState(e.target.value)} + placeholder="Transcriptions {{date}}" + /> + +
+
+ +
+
+ +

{obsidianDestination}

+
+
+
+ )} + + )} + {currentPage === 'settings' && (
diff --git a/frontend/src/Onboarding.css b/frontend/src/Onboarding.css index ad7018f..c53eae2 100644 --- a/frontend/src/Onboarding.css +++ b/frontend/src/Onboarding.css @@ -871,6 +871,10 @@ background: rgba(48, 209, 88, 0.08); } +.hotkey-test-visual.error { + background: rgba(255, 159, 10, 0.08); +} + .hotkey-test-icon { width: 80px; height: 80px; @@ -893,6 +897,11 @@ color: var(--success); } +.hotkey-test-icon.error { + background: rgba(255, 159, 10, 0.15); + color: var(--warning); +} + .recording-pulse { position: absolute; inset: -4px; @@ -941,6 +950,22 @@ font-weight: 500; } +.hotkey-test-visual.error .hotkey-test-label { + color: var(--warning); + font-weight: 500; +} + +.hotkey-test-error { + max-width: 360px; + padding: 12px 14px; + border-radius: 8px; + background: rgba(255, 159, 10, 0.1); + color: var(--text-secondary); + font-size: 13px; + line-height: 1.45; + text-align: center; +} + .hotkey-test-hint { text-align: center; margin-bottom: 12px; diff --git a/frontend/src/Onboarding.tsx b/frontend/src/Onboarding.tsx index 91f2646..6e23ed5 100644 --- a/frontend/src/Onboarding.tsx +++ b/frontend/src/Onboarding.tsx @@ -46,7 +46,7 @@ interface OnboardingProps { type Step = 'welcome' | 'provider' | 'model' | 'download' | 'apikey' | 'micRequest' | 'micSuccess' | 'accessRequest' | 'accessSuccess' | 'hotkeyTest' | 'ready'; type Provider = 'local' | 'openai'; -type HotkeyTestState = 'waiting' | 'recording' | 'success'; +type HotkeyTestState = 'waiting' | 'recording' | 'success' | 'error'; export function Onboarding({ onComplete }: OnboardingProps) { const [step, setStep] = useState('welcome'); @@ -63,6 +63,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { const [platform, setPlatform] = useState('darwin'); const [hotkeyTestState, setHotkeyTestState] = useState('waiting'); const [testTranscript, setTestTranscript] = useState(''); + const [hotkeyTestError, setHotkeyTestError] = useState(''); const hotkeyTestStateRef = useRef('waiting'); useEffect(() => { @@ -218,6 +219,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { // Reset test state when entering this step setHotkeyTestState('waiting'); setTestTranscript(''); + setHotkeyTestError(''); hotkeyTestStateRef.current = 'waiting'; // Make sure hotkey is registered @@ -227,6 +229,10 @@ export function Onboarding({ onComplete }: OnboardingProps) { if (state.state === 'recording' && hotkeyTestStateRef.current === 'waiting') { setHotkeyTestState('recording'); hotkeyTestStateRef.current = 'recording'; + } else if (state.state === 'error' && hotkeyTestStateRef.current === 'recording') { + setHotkeyTestState('error'); + setHotkeyTestError(state.error || 'Recording worked, but transcription failed.'); + hotkeyTestStateRef.current = 'error'; } else if (state.state === 'ready' && hotkeyTestStateRef.current === 'recording') { setHotkeyTestState('success'); hotkeyTestStateRef.current = 'success'; @@ -762,6 +768,23 @@ export function Onboarding({ onComplete }: OnboardingProps) { )} )} + {hotkeyTestState === 'error' && ( + <> +
+ + + + + +
+
Hotkey worked, but transcription needs setup.
+ {hotkeyTestError && ( +
+ {hotkeyTestError} +
+ )} + + )}
@@ -778,9 +801,9 @@ export function Onboarding({ onComplete }: OnboardingProps) { diff --git a/frontend/src/assets/obsidian-logo.svg b/frontend/src/assets/obsidian-logo.svg new file mode 100644 index 0000000..bfebfbd --- /dev/null +++ b/frontend/src/assets/obsidian-logo.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 8b0b562..481b976 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -33,6 +33,12 @@ export function GetHistory():Promise>; export function GetModels():Promise>; +export function GetObsidianDestinationPreview():Promise; + +export function GetObsidianNoteName():Promise; + +export function GetObsidianVaultPath():Promise; + export function GetPlatform():Promise; export function GetRecordingHotkey():Promise; @@ -45,8 +51,12 @@ export function GetStats():Promise; export function Hide():Promise; +export function InstallObsidianExtension():Promise; + export function IsModelDownloaded(arg1:string):Promise; +export function IsObsidianExtensionInstalled():Promise; + export function IsOnboardingCompleted():Promise; export function Minimize():Promise; @@ -69,6 +79,10 @@ export function SetCancelHotkey(arg1:string):Promise; export function SetModel(arg1:string):Promise; +export function SetObsidianNoteName(arg1:string):Promise; + +export function SetObsidianVaultPath(arg1:string):Promise; + export function SetOnboardingCompleted(arg1:boolean):Promise; export function SetOpenAIKey(arg1:string):Promise; @@ -90,3 +104,5 @@ export function StartRecording():Promise; export function StopRecording():Promise; export function ToggleRecording():Promise; + +export function UninstallObsidianExtension():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index e4a5b9d..43656cc 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -62,6 +62,18 @@ export function GetModels() { return window['go']['main']['App']['GetModels'](); } +export function GetObsidianDestinationPreview() { + return window['go']['main']['App']['GetObsidianDestinationPreview'](); +} + +export function GetObsidianNoteName() { + return window['go']['main']['App']['GetObsidianNoteName'](); +} + +export function GetObsidianVaultPath() { + return window['go']['main']['App']['GetObsidianVaultPath'](); +} + export function GetPlatform() { return window['go']['main']['App']['GetPlatform'](); } @@ -86,10 +98,18 @@ export function Hide() { return window['go']['main']['App']['Hide'](); } +export function InstallObsidianExtension() { + return window['go']['main']['App']['InstallObsidianExtension'](); +} + export function IsModelDownloaded(arg1) { return window['go']['main']['App']['IsModelDownloaded'](arg1); } +export function IsObsidianExtensionInstalled() { + return window['go']['main']['App']['IsObsidianExtensionInstalled'](); +} + export function IsOnboardingCompleted() { return window['go']['main']['App']['IsOnboardingCompleted'](); } @@ -134,6 +154,14 @@ export function SetModel(arg1) { return window['go']['main']['App']['SetModel'](arg1); } +export function SetObsidianNoteName(arg1) { + return window['go']['main']['App']['SetObsidianNoteName'](arg1); +} + +export function SetObsidianVaultPath(arg1) { + return window['go']['main']['App']['SetObsidianVaultPath'](arg1); +} + export function SetOnboardingCompleted(arg1) { return window['go']['main']['App']['SetOnboardingCompleted'](arg1); } @@ -177,3 +205,7 @@ export function StopRecording() { export function ToggleRecording() { return window['go']['main']['App']['ToggleRecording'](); } + +export function UninstallObsidianExtension() { + return window['go']['main']['App']['UninstallObsidianExtension'](); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 66e2a0f..ac1127a 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -118,6 +118,9 @@ export namespace models { showNotification: boolean; recordingHotkey: string; cancelHotkey: string; + obsidianVaultPath?: string; + obsidianNoteName?: string; + obsidianExtensionInstalled: boolean; soundEnabled?: boolean; onboardingCompleted: boolean; @@ -135,6 +138,9 @@ export namespace models { this.showNotification = source["showNotification"]; this.recordingHotkey = source["recordingHotkey"]; this.cancelHotkey = source["cancelHotkey"]; + this.obsidianVaultPath = source["obsidianVaultPath"]; + this.obsidianNoteName = source["obsidianNoteName"]; + this.obsidianExtensionInstalled = source["obsidianExtensionInstalled"]; this.soundEnabled = source["soundEnabled"]; this.onboardingCompleted = source["onboardingCompleted"]; } diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts index 4445dac..3bbea84 100644 --- a/frontend/wailsjs/runtime/runtime.d.ts +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -246,4 +246,85 @@ export function OnFileDropOff() :void export function CanResolveFilePaths(): boolean; // Resolves file paths for an array of files -export function ResolveFilePaths(files: File[]): void \ No newline at end of file +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js index 623397b..556621e 100644 --- a/frontend/wailsjs/runtime/runtime.js +++ b/frontend/wailsjs/runtime/runtime.js @@ -48,6 +48,10 @@ export function EventsOff(eventName, ...additionalEventNames) { return window.runtime.EventsOff(eventName, ...additionalEventNames); } +export function EventsOffAll() { + return window.runtime.EventsOffAll(); +} + export function EventsOnce(eventName, callback) { return EventsOnMultiple(eventName, callback, 1); } @@ -235,4 +239,60 @@ export function CanResolveFilePaths() { export function ResolveFilePaths(files) { return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); } \ No newline at end of file diff --git a/go.mod b/go.mod index 196765c..2bc6adb 100644 --- a/go.mod +++ b/go.mod @@ -7,15 +7,17 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/gordonklaus/portaudio v0.0.0-20260203164431-765aa7dfa631 github.com/sashabaranov/go-openai v1.41.2 - github.com/wailsapp/wails/v2 v2.10.1 + github.com/wailsapp/wails/v2 v2.12.0 golang.org/x/sys v0.30.0 ) require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -32,7 +34,7 @@ require ( github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/wailsapp/go-webview2 v1.0.19 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect golang.org/x/crypto v0.33.0 // indirect golang.org/x/net v0.35.0 // indirect diff --git a/go.sum b/go.sum index 55098a8..9a52232 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ= fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= @@ -14,6 +16,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gordonklaus/portaudio v0.0.0-20260203164431-765aa7dfa631 h1:8TBHztmhDfAAg34yddptshinXBtDQwgKGlMfdtSFETw= github.com/gordonklaus/portaudio v0.0.0-20260203164431-765aa7dfa631/go.mod h1:esZFQEUwqC+l76f2R8bIWSwXMaPbp79PppwZ1eJhFco= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= @@ -59,12 +63,12 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU= -github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= -github.com/wailsapp/wails/v2 v2.10.1 h1:QWHvWMXII2nI/nXz77gpPG8P3ehl6zKe+u4su5BWIns= -github.com/wailsapp/wails/v2 v2.10.1/go.mod h1:zrebnFV6MQf9kx8HI4iAv63vsR5v67oS7GTEZ7Pz1TY= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= diff --git a/internal/diagnostics/logger.go b/internal/diagnostics/logger.go new file mode 100644 index 0000000..bd19969 --- /dev/null +++ b/internal/diagnostics/logger.go @@ -0,0 +1,115 @@ +package diagnostics + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const maxLogBytes = 2 * 1024 * 1024 + +// Logger writes non-sensitive diagnostic events for support and troubleshooting. +type Logger struct { + mu sync.Mutex + path string +} + +// New creates a diagnostics logger in the app configuration directory. +func New(configDir string) *Logger { + configDir = strings.TrimSpace(configDir) + if configDir == "" { + return nil + } + return &Logger{path: filepath.Join(configDir, "yap.log")} +} + +// Info records an informational diagnostic event. +func (l *Logger) Info(event string, fields map[string]any) { + l.write("info", event, fields) +} + +// Warn records a warning diagnostic event. +func (l *Logger) Warn(event string, fields map[string]any) { + l.write("warn", event, fields) +} + +// Error records an error diagnostic event. +func (l *Logger) Error(event string, fields map[string]any) { + l.write("error", event, fields) +} + +func (l *Logger) write(level string, event string, fields map[string]any) { + if l == nil || strings.TrimSpace(l.path) == "" { + return + } + + l.mu.Lock() + defer l.mu.Unlock() + + if err := os.MkdirAll(filepath.Dir(l.path), 0755); err != nil { + fmt.Printf("Warning: Failed to create diagnostics directory: %v\n", err) + return + } + if err := l.rotateIfNeeded(); err != nil { + fmt.Printf("Warning: Failed to rotate diagnostics log: %v\n", err) + } + + file, err := os.OpenFile(l.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + fmt.Printf("Warning: Failed to open diagnostics log: %v\n", err) + return + } + defer file.Close() + + line := fmt.Sprintf("%s level=%s event=%s", time.Now().Format(time.RFC3339), clean(level), clean(event)) + for key, value := range fields { + line += fmt.Sprintf(" %s=%q", clean(key), cleanValue(value)) + } + line += "\n" + + if _, err := file.WriteString(line); err != nil { + fmt.Printf("Warning: Failed to write diagnostics log: %v\n", err) + } +} + +func (l *Logger) rotateIfNeeded() error { + info, err := os.Stat(l.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if info.Size() < maxLogBytes { + return nil + } + + rotatedPath := l.path + ".1" + _ = os.Remove(rotatedPath) + return os.Rename(l.path, rotatedPath) +} + +func clean(value string) string { + value = strings.TrimSpace(value) + value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "\r", " ") + value = strings.ReplaceAll(value, "\t", " ") + if value == "" { + return "unknown" + } + return value +} + +func cleanValue(value any) string { + text := fmt.Sprint(value) + text = strings.ReplaceAll(text, "\n", " ") + text = strings.ReplaceAll(text, "\r", " ") + text = strings.ReplaceAll(text, "\t", " ") + if len(text) > 500 { + text = text[:500] + "..." + } + return text +} diff --git a/internal/hotkey/hotkey_darwin.go b/internal/hotkey/hotkey_darwin.go index d77b11d..fccab17 100644 --- a/internal/hotkey/hotkey_darwin.go +++ b/internal/hotkey/hotkey_darwin.go @@ -15,13 +15,18 @@ static id gKeyEventMonitor = nil; static id gLocalKeyEventMonitor = nil; static id gLocalFlagsMonitor = nil; static BOOL gHotkeyKeyDown = NO; +static BOOL gBrainCacheKeyDown = NO; static BOOL gCancelKeyEnabled = NO; static UInt16 gCurrentHotkeyCode = 0x3D; // Default: Right Option +static UInt16 gCurrentBrainCacheCode = 0; static UInt16 gCurrentCancelCode = 53; // Default: Escape static BOOL gHotkeyIsModifier = YES; // Is the hotkey a modifier key? +static BOOL gBrainCacheIsModifier = NO; +static BOOL gBrainCacheEnabled = NO; static BOOL gCancelIsModifier = NO; // Is the cancel key a modifier? extern void goHotkeyPressed(void); +extern void goBrainCachePressed(void); extern void goCancelPressed(void); // Common key codes @@ -128,24 +133,25 @@ static void stopAllMonitoring(void) { gLocalFlagsMonitor = nil; } gHotkeyKeyDown = NO; + gBrainCacheKeyDown = NO; gCancelKeyEnabled = NO; } static void startMonitoring(void) { stopAllMonitoring(); - + // Check accessibility permissions first if (!hasAccessibilityPermissions()) { NSLog(@"Cannot start monitoring without accessibility permissions"); return; } - + // Monitor for modifier keys (flagsChanged events) gEventMonitor = [NSEvent addGlobalMonitorForEventsMatchingMask:NSEventMaskFlagsChanged handler:^(NSEvent *event) { UInt16 keyCode = [event keyCode]; NSEventModifierFlags flags = [event modifierFlags]; - + // Check hotkey (if it's a modifier) if (gHotkeyIsModifier && keyCode == gCurrentHotkeyCode) { NSEventModifierFlags modFlag = getModifierFlag(keyCode); @@ -158,7 +164,19 @@ static void startMonitoring(void) { gHotkeyKeyDown = NO; } } - + + if (gBrainCacheEnabled && gBrainCacheIsModifier && keyCode == gCurrentBrainCacheCode) { + NSEventModifierFlags modFlag = getModifierFlag(keyCode); + if (flags & modFlag) { + if (!gBrainCacheKeyDown) { + gBrainCacheKeyDown = YES; + goBrainCachePressed(); + } + } else { + gBrainCacheKeyDown = NO; + } + } + // Check cancel key (if it's a modifier) if (gCancelKeyEnabled && gCancelIsModifier && keyCode == gCurrentCancelCode) { NSEventModifierFlags modFlag = getModifierFlag(keyCode); @@ -167,43 +185,52 @@ static void startMonitoring(void) { } } }]; - + // Monitor for regular keys (keyDown events) gKeyEventMonitor = [NSEvent addGlobalMonitorForEventsMatchingMask:NSEventMaskKeyDown handler:^(NSEvent *event) { UInt16 keyCode = [event keyCode]; - + // Check hotkey (if it's NOT a modifier) if (!gHotkeyIsModifier && keyCode == gCurrentHotkeyCode) { goHotkeyPressed(); } - + + if (gBrainCacheEnabled && !gBrainCacheIsModifier && keyCode == gCurrentBrainCacheCode) { + goBrainCachePressed(); + } + // Check cancel key (if it's NOT a modifier) if (gCancelKeyEnabled && !gCancelIsModifier && keyCode == gCurrentCancelCode) { goCancelPressed(); } }]; - + // Local monitor for regular keys when this app has focus gLocalKeyEventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyDown handler:^NSEvent *(NSEvent *event) { UInt16 keyCode = [event keyCode]; - + // Check hotkey (if it's NOT a modifier) if (!gHotkeyIsModifier && keyCode == gCurrentHotkeyCode) { goHotkeyPressed(); return nil; // Consume event } - + + if (gBrainCacheEnabled && !gBrainCacheIsModifier && keyCode == gCurrentBrainCacheCode) { + goBrainCachePressed(); + return nil; // Consume event + } + // Check cancel key (if it's NOT a modifier) if (gCancelKeyEnabled && !gCancelIsModifier && keyCode == gCurrentCancelCode) { goCancelPressed(); return nil; // Consume event } - + return event; }]; - + // Also add local monitor for modifier keys (flagsChanged) when app has focus // This ensures modifier hotkeys work even when the app window is focused if (gLocalFlagsMonitor != nil) { @@ -214,7 +241,7 @@ static void startMonitoring(void) { handler:^NSEvent *(NSEvent *event) { UInt16 keyCode = [event keyCode]; NSEventModifierFlags flags = [event modifierFlags]; - + // Check hotkey (if it's a modifier) if (gHotkeyIsModifier && keyCode == gCurrentHotkeyCode) { NSEventModifierFlags modFlag = getModifierFlag(keyCode); @@ -227,7 +254,19 @@ static void startMonitoring(void) { gHotkeyKeyDown = NO; } } - + + if (gBrainCacheEnabled && gBrainCacheIsModifier && keyCode == gCurrentBrainCacheCode) { + NSEventModifierFlags modFlag = getModifierFlag(keyCode); + if (flags & modFlag) { + if (!gBrainCacheKeyDown) { + gBrainCacheKeyDown = YES; + goBrainCachePressed(); + } + } else { + gBrainCacheKeyDown = NO; + } + } + // Check cancel key (if it's a modifier) if (gCancelKeyEnabled && gCancelIsModifier && keyCode == gCurrentCancelCode) { NSEventModifierFlags modFlag = getModifierFlag(keyCode); @@ -235,10 +274,10 @@ static void startMonitoring(void) { goCancelPressed(); } } - + return event; }]; - + NSLog(@"Key monitoring started - hotkey: %d, cancel: %d", gCurrentHotkeyCode, gCurrentCancelCode); } @@ -249,6 +288,14 @@ static void setHotkeyCode(UInt16 keyCode) { NSLog(@"Hotkey set to keyCode: %d (isModifier: %d)", keyCode, gHotkeyIsModifier); } +static void setBrainCacheCode(UInt16 keyCode, int enabled) { + gCurrentBrainCacheCode = keyCode; + gBrainCacheIsModifier = isModifierKeyCode(keyCode); + gBrainCacheEnabled = enabled ? YES : NO; + gBrainCacheKeyDown = NO; + NSLog(@"BrainCache hotkey set to keyCode: %d (enabled: %d, isModifier: %d)", keyCode, gBrainCacheEnabled, gBrainCacheIsModifier); +} + static void setCancelCode(UInt16 keyCode) { gCurrentCancelCode = keyCode; gCancelIsModifier = isModifierKeyCode(keyCode); @@ -274,11 +321,14 @@ import ( ) var ( - callbackMu sync.Mutex - hotkeyCallback func() - hotkeyC = make(chan struct{}, 1) - cancelCallbackMu sync.Mutex - cancelCallback func() + callbackMu sync.Mutex + hotkeyCallback func() + hotkeyC = make(chan struct{}, 1) + brainCacheCallbackMu sync.Mutex + brainCacheCallback func() + brainCacheC = make(chan struct{}, 1) + cancelCallbackMu sync.Mutex + cancelCallback func() ) //export goHotkeyPressed @@ -290,6 +340,15 @@ func goHotkeyPressed() { } } +//export goBrainCachePressed +func goBrainCachePressed() { + select { + case brainCacheC <- struct{}{}: + default: + // Channel full, dropping event + } +} + //export goCancelPressed func goCancelPressed() { cancelCallbackMu.Lock() @@ -305,11 +364,12 @@ type Callback func() // Manager handles global hotkey registration type Manager struct { - mu sync.Mutex - running bool - stopC chan struct{} - hotkeyStr string - cancelStr string + mu sync.Mutex + running bool + stopC chan struct{} + hotkeyStr string + brainCacheStr string + cancelStr string } // NewManager creates a new hotkey manager @@ -321,7 +381,7 @@ func NewManager() *Manager { } // Register registers the global hotkey -func (m *Manager) Register(cb Callback) error { +func (m *Manager) Register(cb Callback, brainCacheCb Callback) error { m.mu.Lock() defer m.mu.Unlock() @@ -332,11 +392,20 @@ func (m *Manager) Register(cb Callback) error { callbackMu.Lock() hotkeyCallback = cb callbackMu.Unlock() + brainCacheCallbackMu.Lock() + brainCacheCallback = brainCacheCb + brainCacheCallbackMu.Unlock() // Set the hotkey code keyCode := KeyNameToCode(m.hotkeyStr) C.setHotkeyCode(C.UInt16(keyCode)) - + if m.brainCacheStr != "" { + brainCacheCode := KeyNameToCode(m.brainCacheStr) + C.setBrainCacheCode(C.UInt16(brainCacheCode), C.int(1)) + } else { + C.setBrainCacheCode(C.UInt16(0), C.int(0)) + } + // Set the cancel key code cancelCode := KeyNameToCode(m.cancelStr) C.setCancelCode(C.UInt16(cancelCode)) @@ -357,6 +426,13 @@ func (m *Manager) Register(cb Callback) error { if cb != nil { cb() } + case <-brainCacheC: + brainCacheCallbackMu.Lock() + cb := brainCacheCallback + brainCacheCallbackMu.Unlock() + if cb != nil { + cb() + } case <-m.stopC: return } @@ -386,27 +462,47 @@ func (m *Manager) Unregister() error { func (m *Manager) SetHotkeyType(hotkeyName string) { m.mu.Lock() defer m.mu.Unlock() - + m.hotkeyStr = strings.ToLower(hotkeyName) keyCode := KeyNameToCode(m.hotkeyStr) C.setHotkeyCode(C.UInt16(keyCode)) - + if m.running { C.startMonitoring() } - + fmt.Printf("Hotkey set to: %s (code: %d)\n", m.hotkeyStr, keyCode) } +// SetBrainCacheHotkey sets the BrainCache hotkey by name. Empty disables it. +func (m *Manager) SetBrainCacheHotkey(hotkeyName string) { + m.mu.Lock() + defer m.mu.Unlock() + + m.brainCacheStr = strings.ToLower(strings.TrimSpace(hotkeyName)) + if m.brainCacheStr == "" { + C.setBrainCacheCode(C.UInt16(0), C.int(0)) + } else { + keyCode := KeyNameToCode(m.brainCacheStr) + C.setBrainCacheCode(C.UInt16(keyCode), C.int(1)) + } + + if m.running { + C.startMonitoring() + } + + fmt.Printf("BrainCache hotkey set to: %s\n", m.brainCacheStr) +} + // SetCancelKey sets the cancel hotkey by name func (m *Manager) SetCancelKey(keyName string) { m.mu.Lock() defer m.mu.Unlock() - + m.cancelStr = strings.ToLower(keyName) cancelCode := KeyNameToCode(m.cancelStr) C.setCancelCode(C.UInt16(cancelCode)) - + fmt.Printf("Cancel key set to: %s (code: %d)\n", m.cancelStr, cancelCode) } @@ -422,14 +518,14 @@ func (m *Manager) EnableCancelKey(cb func()) { cancelCallbackMu.Lock() cancelCallback = cb cancelCallbackMu.Unlock() - + C.enableCancelKey() } // DisableCancelKey stops monitoring for the cancel key func (m *Manager) DisableCancelKey() { C.disableCancelKey() - + cancelCallbackMu.Lock() cancelCallback = nil cancelCallbackMu.Unlock() @@ -474,7 +570,7 @@ func KeyNameToCode(name string) uint16 { return 0x3F case "capslock": return 0x39 - + // Special keys case "escape", "esc": return 0x35 @@ -488,7 +584,7 @@ func KeyNameToCode(name string) uint16 { return 0x33 case "forwarddelete": return 0x75 - + // Arrow keys case "left", "arrowleft": return 0x7B @@ -498,7 +594,7 @@ func KeyNameToCode(name string) uint16 { return 0x7E case "down", "arrowdown": return 0x7D - + // Function keys case "f1": return 0x7A @@ -524,7 +620,7 @@ func KeyNameToCode(name string) uint16 { return 0x67 case "f12": return 0x6F - + // Letter keys case "a": return 0x00 @@ -578,7 +674,7 @@ func KeyNameToCode(name string) uint16 { return 0x10 case "z": return 0x06 - + // Number keys case "0": return 0x1D @@ -600,7 +696,7 @@ func KeyNameToCode(name string) uint16 { return 0x1C case "9": return 0x19 - + default: return 0x3D // Default to right option } diff --git a/internal/hotkey/hotkey_linux.go b/internal/hotkey/hotkey_linux.go index 6cfb3d6..5bcf3be 100644 --- a/internal/hotkey/hotkey_linux.go +++ b/internal/hotkey/hotkey_linux.go @@ -15,15 +15,18 @@ static Display *display = NULL; static Window root; static int running = 0; static KeyCode hotkeyCode = 0; +static KeyCode brainCacheCode = 0; +static int brainCacheEnabled = 0; static KeyCode cancelCode = 0; static int cancelEnabled = 0; extern void goHotkeyPressed(void); +extern void goBrainCachePressed(void); extern void goCancelPressed(void); static int initDisplay(void) { if (display != NULL) return 1; - + display = XOpenDisplay(NULL); if (display == NULL) { fprintf(stderr, "Cannot open X display\n"); @@ -45,6 +48,12 @@ static void setHotkeyKeysym(KeySym keysym) { hotkeyCode = XKeysymToKeycode(display, keysym); } +static void setBrainCacheKeysym(KeySym keysym, int enabled) { + if (display == NULL) return; + brainCacheCode = XKeysymToKeycode(display, keysym); + brainCacheEnabled = enabled; +} + static void setCancelKeysym(KeySym keysym) { if (display == NULL) return; cancelCode = XKeysymToKeycode(display, keysym); @@ -61,40 +70,56 @@ static void disableCancel(void) { static void startMonitoring(void) { if (display == NULL) return; running = 1; - + // Grab the hotkey XGrabKey(display, hotkeyCode, AnyModifier, root, True, GrabModeAsync, GrabModeAsync); - + // Also grab common modifier combinations XGrabKey(display, hotkeyCode, Mod2Mask, root, True, GrabModeAsync, GrabModeAsync); XGrabKey(display, hotkeyCode, LockMask, root, True, GrabModeAsync, GrabModeAsync); XGrabKey(display, hotkeyCode, Mod2Mask | LockMask, root, True, GrabModeAsync, GrabModeAsync); + if (brainCacheEnabled) { + XGrabKey(display, brainCacheCode, AnyModifier, root, True, GrabModeAsync, GrabModeAsync); + XGrabKey(display, brainCacheCode, Mod2Mask, root, True, GrabModeAsync, GrabModeAsync); + XGrabKey(display, brainCacheCode, LockMask, root, True, GrabModeAsync, GrabModeAsync); + XGrabKey(display, brainCacheCode, Mod2Mask | LockMask, root, True, GrabModeAsync, GrabModeAsync); + } } static void stopMonitoring(void) { if (display == NULL) return; running = 0; - + XUngrabKey(display, hotkeyCode, AnyModifier, root); XUngrabKey(display, hotkeyCode, Mod2Mask, root); XUngrabKey(display, hotkeyCode, LockMask, root); XUngrabKey(display, hotkeyCode, Mod2Mask | LockMask, root); + if (brainCacheEnabled) { + XUngrabKey(display, brainCacheCode, AnyModifier, root); + XUngrabKey(display, brainCacheCode, Mod2Mask, root); + XUngrabKey(display, brainCacheCode, LockMask, root); + XUngrabKey(display, brainCacheCode, Mod2Mask | LockMask, root); + } } static void processEvents(void) { if (display == NULL || !running) return; - + XEvent event; while (XPending(display) > 0) { XNextEvent(display, &event); - + if (event.type == KeyPress) { KeyCode keycode = event.xkey.keycode; - + if (keycode == hotkeyCode) { goHotkeyPressed(); } - + + if (brainCacheEnabled && keycode == brainCacheCode) { + goBrainCachePressed(); + } + if (cancelEnabled && keycode == cancelCode) { goCancelPressed(); } @@ -121,21 +146,25 @@ type Callback func() // Manager handles global hotkey registration type Manager struct { - mu sync.Mutex - running bool - hotkeyCallback Callback - cancelCallback func() - cancelEnabled bool - stopCh chan struct{} - hotkeyStr string - cancelStr string + mu sync.Mutex + running bool + hotkeyCallback Callback + brainCacheCallback Callback + cancelCallback func() + cancelEnabled bool + stopCh chan struct{} + hotkeyStr string + brainCacheStr string + cancelStr string } var ( - callbackMu sync.Mutex - hotkeyCallback Callback - cancelCallbackMu sync.Mutex - cancelCallback func() + callbackMu sync.Mutex + hotkeyCallback Callback + brainCacheCallbackMu sync.Mutex + brainCacheCallback Callback + cancelCallbackMu sync.Mutex + cancelCallback func() ) //export goHotkeyPressed @@ -148,6 +177,16 @@ func goHotkeyPressed() { } } +//export goBrainCachePressed +func goBrainCachePressed() { + brainCacheCallbackMu.Lock() + cb := brainCacheCallback + brainCacheCallbackMu.Unlock() + if cb != nil { + go cb() + } +} + //export goCancelPressed func goCancelPressed() { cancelCallbackMu.Lock() @@ -167,7 +206,7 @@ func NewManager() *Manager { } // Register registers the global hotkey -func (m *Manager) Register(cb Callback) error { +func (m *Manager) Register(cb Callback, brainCacheCb Callback) error { m.mu.Lock() defer m.mu.Unlock() @@ -182,12 +221,22 @@ func (m *Manager) Register(cb Callback) error { callbackMu.Lock() hotkeyCallback = cb callbackMu.Unlock() + brainCacheCallbackMu.Lock() + brainCacheCallback = brainCacheCb + brainCacheCallbackMu.Unlock() m.hotkeyCallback = cb + m.brainCacheCallback = brainCacheCb // Set the hotkey keysym := KeyNameToKeysym(m.hotkeyStr) C.setHotkeyKeysym(C.KeySym(keysym)) - + if m.brainCacheStr != "" { + brainCacheKeysym := KeyNameToKeysym(m.brainCacheStr) + C.setBrainCacheKeysym(C.KeySym(brainCacheKeysym), C.int(1)) + } else { + C.setBrainCacheKeysym(C.KeySym(0), C.int(0)) + } + // Set the cancel key cancelKeysym := KeyNameToKeysym(m.cancelStr) C.setCancelKeysym(C.KeySym(cancelKeysym)) @@ -201,7 +250,7 @@ func (m *Manager) Register(cb Callback) error { go func() { ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() - + for { select { case <-m.stopCh: @@ -216,6 +265,31 @@ func (m *Manager) Register(cb Callback) error { return nil } +// SetBrainCacheHotkey sets the BrainCache hotkey by name. Empty disables it. +func (m *Manager) SetBrainCacheHotkey(hotkeyName string) { + m.mu.Lock() + defer m.mu.Unlock() + + m.brainCacheStr = strings.ToLower(strings.TrimSpace(hotkeyName)) + if m.running { + C.stopMonitoring() + if m.brainCacheStr != "" { + keysym := KeyNameToKeysym(m.brainCacheStr) + C.setBrainCacheKeysym(C.KeySym(keysym), C.int(1)) + } else { + C.setBrainCacheKeysym(C.KeySym(0), C.int(0)) + } + C.startMonitoring() + } else if m.brainCacheStr != "" { + keysym := KeyNameToKeysym(m.brainCacheStr) + C.setBrainCacheKeysym(C.KeySym(keysym), C.int(1)) + } else { + C.setBrainCacheKeysym(C.KeySym(0), C.int(0)) + } + + fmt.Printf("BrainCache hotkey set to: %s\n", m.brainCacheStr) +} + // Unregister removes the hotkey func (m *Manager) Unregister() error { m.mu.Lock() @@ -237,10 +311,10 @@ func (m *Manager) Unregister() error { func (m *Manager) SetHotkeyType(hotkeyName string) { m.mu.Lock() defer m.mu.Unlock() - + m.hotkeyStr = strings.ToLower(hotkeyName) keysym := KeyNameToKeysym(m.hotkeyStr) - + if m.running { C.stopMonitoring() C.setHotkeyKeysym(C.KeySym(keysym)) @@ -248,7 +322,7 @@ func (m *Manager) SetHotkeyType(hotkeyName string) { } else { C.setHotkeyKeysym(C.KeySym(keysym)) } - + fmt.Printf("Hotkey set to: %s\n", m.hotkeyStr) } @@ -256,11 +330,11 @@ func (m *Manager) SetHotkeyType(hotkeyName string) { func (m *Manager) SetCancelKey(keyName string) { m.mu.Lock() defer m.mu.Unlock() - + m.cancelStr = strings.ToLower(keyName) keysym := KeyNameToKeysym(m.cancelStr) C.setCancelKeysym(C.KeySym(keysym)) - + fmt.Printf("Cancel key set to: %s\n", m.cancelStr) } @@ -276,23 +350,23 @@ func (m *Manager) EnableCancelKey(cb func()) { cancelCallbackMu.Lock() cancelCallback = cb cancelCallbackMu.Unlock() - + m.mu.Lock() m.cancelCallback = cb m.cancelEnabled = true m.mu.Unlock() - + C.enableCancel() } // DisableCancelKey stops monitoring for the cancel key func (m *Manager) DisableCancelKey() { C.disableCancel() - + cancelCallbackMu.Lock() cancelCallback = nil cancelCallbackMu.Unlock() - + m.mu.Lock() m.cancelEnabled = false m.cancelCallback = nil @@ -336,7 +410,7 @@ func KeyNameToKeysym(name string) uint64 { return 0xFFEB // XK_Super_L case "capslock": return 0xFFE5 // XK_Caps_Lock - + // Special keys case "escape", "esc": return 0xFF1B // XK_Escape @@ -350,7 +424,7 @@ func KeyNameToKeysym(name string) uint64 { return 0xFF08 // XK_BackSpace case "delete": return 0xFFFF // XK_Delete - + // Arrow keys case "left", "arrowleft": return 0xFF51 // XK_Left @@ -360,7 +434,7 @@ func KeyNameToKeysym(name string) uint64 { return 0xFF52 // XK_Up case "down", "arrowdown": return 0xFF54 // XK_Down - + // Function keys case "f1": return 0xFFBE @@ -386,7 +460,7 @@ func KeyNameToKeysym(name string) uint64 { return 0xFFC8 case "f12": return 0xFFC9 - + // Letter keys (lowercase) case "a": return 0x0061 @@ -440,7 +514,7 @@ func KeyNameToKeysym(name string) uint64 { return 0x0079 case "z": return 0x007A - + // Number keys case "0": return 0x0030 @@ -462,7 +536,7 @@ func KeyNameToKeysym(name string) uint64 { return 0x0038 case "9": return 0x0039 - + default: return 0xFFEA // Default to right alt } diff --git a/internal/hotkey/hotkey_other.go b/internal/hotkey/hotkey_other.go index a6dc62c..e62b751 100644 --- a/internal/hotkey/hotkey_other.go +++ b/internal/hotkey/hotkey_other.go @@ -24,7 +24,7 @@ func NewManager() *Manager { } // Register registers the global hotkey -func (m *Manager) Register(callback Callback) error { +func (m *Manager) Register(callback Callback, brainCacheCallback Callback) error { m.mu.Lock() defer m.mu.Unlock() @@ -71,6 +71,11 @@ func (m *Manager) SetHotkeyType(hotkeyType string) { // Not supported on this platform } +// SetBrainCacheHotkey sets the BrainCache hotkey (no-op) +func (m *Manager) SetBrainCacheHotkey(hotkeyName string) { + // Not supported on this platform +} + // SetCancelKey sets the cancel key (no-op) func (m *Manager) SetCancelKey(keyName string) { // Not supported on this platform diff --git a/internal/hotkey/hotkey_windows.go b/internal/hotkey/hotkey_windows.go index 233f485..1ce9925 100644 --- a/internal/hotkey/hotkey_windows.go +++ b/internal/hotkey/hotkey_windows.go @@ -90,16 +90,19 @@ type Callback func() // Manager handles global hotkey registration type Manager struct { - mu sync.Mutex - running bool - hotkeyCode uint32 - cancelCode uint32 - hotkeyCallback Callback - cancelCallback func() - cancelEnabled bool - threadId uint32 - hotkeyStr string - cancelStr string + mu sync.Mutex + running bool + hotkeyCode uint32 + brainCacheCode uint32 + cancelCode uint32 + hotkeyCallback Callback + brainCacheCallback Callback + cancelCallback func() + cancelEnabled bool + threadId uint32 + hotkeyStr string + brainCacheStr string + cancelStr string } // Global manager pointer for the callback @@ -138,6 +141,12 @@ func keyboardProc(nCode int32, wParam uintptr, lParam uintptr) uintptr { } } + if m.brainCacheStr != "" && kbStruct.VkCode == m.brainCacheCode { + if m.brainCacheCallback != nil { + go m.brainCacheCallback() + } + } + // Check cancel key if m.cancelEnabled && kbStruct.VkCode == m.cancelCode { if m.cancelCallback != nil { @@ -153,14 +162,18 @@ func keyboardProc(nCode int32, wParam uintptr, lParam uintptr) uintptr { } // Register registers the global hotkey -func (m *Manager) Register(cb Callback) error { +func (m *Manager) Register(cb Callback, brainCacheCb Callback) error { m.mu.Lock() if m.running { m.mu.Unlock() return nil } m.hotkeyCallback = cb + m.brainCacheCallback = brainCacheCb m.hotkeyCode = KeyNameToCode(m.hotkeyStr) + if m.brainCacheStr != "" { + m.brainCacheCode = KeyNameToCode(m.brainCacheStr) + } m.cancelCode = KeyNameToCode(m.cancelStr) m.mu.Unlock() @@ -234,6 +247,21 @@ func (m *Manager) Register(cb Callback) error { return nil } +// SetBrainCacheHotkey sets the BrainCache hotkey by name. Empty disables it. +func (m *Manager) SetBrainCacheHotkey(hotkeyName string) { + m.mu.Lock() + defer m.mu.Unlock() + + m.brainCacheStr = strings.ToLower(strings.TrimSpace(hotkeyName)) + if m.brainCacheStr != "" { + m.brainCacheCode = KeyNameToCode(m.brainCacheStr) + } else { + m.brainCacheCode = 0 + } + + fmt.Printf("BrainCache hotkey set to: %s (code: %d)\n", m.brainCacheStr, m.brainCacheCode) +} + // Unregister removes the hotkey func (m *Manager) Unregister() error { m.mu.Lock() diff --git a/internal/models/config.go b/internal/models/config.go index c329d00..8a18939 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -35,6 +35,15 @@ type Config struct { // Cancel hotkey - key to cancel recording, default: "escape" CancelHotkey string `json:"cancelHotkey"` + // Obsidian vault path for notes + ObsidianVaultPath string `json:"obsidianVaultPath,omitempty"` + + // Obsidian note name template. {{date}} expands to YYYY-MM-DD. + ObsidianNoteName string `json:"obsidianNoteName,omitempty"` + + // ObsidianExtensionInstalled indicates whether the Obsidian integration is enabled + ObsidianExtensionInstalled bool `json:"obsidianExtensionInstalled"` + // Sound enabled for recording start/stop SoundEnabled *bool `json:"soundEnabled,omitempty"` @@ -193,6 +202,37 @@ func (cm *ConfigManager) SetCancelHotkey(hotkey string) error { return cm.Save() } +// SetObsidianVaultPath updates the Obsidian vault path setting +func (cm *ConfigManager) SetObsidianVaultPath(path string) error { + cm.config.ObsidianVaultPath = path + return cm.Save() +} + +// SetObsidianNoteName updates the Obsidian note filename template. +func (cm *ConfigManager) SetObsidianNoteName(name string) error { + cm.config.ObsidianNoteName = name + return cm.Save() +} + +// IsObsidianExtensionInstalled returns whether the Obsidian integration is enabled. +func (cm *ConfigManager) IsObsidianExtensionInstalled() bool { + return cm.config.ObsidianExtensionInstalled || cm.config.ObsidianVaultPath != "" +} + +// InstallObsidianExtension enables the Obsidian integration. +func (cm *ConfigManager) InstallObsidianExtension() error { + cm.config.ObsidianExtensionInstalled = true + return cm.Save() +} + +// UninstallObsidianExtension disables the Obsidian integration and clears its settings. +func (cm *ConfigManager) UninstallObsidianExtension() error { + cm.config.ObsidianExtensionInstalled = false + cm.config.ObsidianVaultPath = "" + cm.config.ObsidianNoteName = "" + return cm.Save() +} + // SetSoundEnabled updates the sound enabled setting func (cm *ConfigManager) SetSoundEnabled(enabled bool) error { cm.config.SoundEnabled = &enabled diff --git a/internal/obsidian/writer.go b/internal/obsidian/writer.go new file mode 100644 index 0000000..53ee99b --- /dev/null +++ b/internal/obsidian/writer.go @@ -0,0 +1,105 @@ +package obsidian + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + yapFolder = "Yap" + DefaultNoteName = "Transcriptions {{date}}" + datePlaceholder = "{{date}}" + defaultDateFormat = "2006-01-02" + defaultMarkdownExt = ".md" +) + +// AppendTranscription appends a transcript to the daily note in an Obsidian vault. +func AppendTranscription(vaultPath string, noteName string, transcript string, capturedAt time.Time) (string, error) { + vaultPath = strings.TrimSpace(vaultPath) + if vaultPath == "" { + return "", fmt.Errorf("set your Obsidian vault path in Settings") + } + + info, err := os.Stat(vaultPath) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("Obsidian vault folder not found") + } + return "", fmt.Errorf("could not read Obsidian vault folder: %w", err) + } + if !info.IsDir() { + return "", fmt.Errorf("Obsidian vault path must be a folder") + } + + date := capturedAt.Format(defaultDateFormat) + noteDir := filepath.Join(vaultPath, yapFolder) + if err := os.MkdirAll(noteDir, 0755); err != nil { + return "", fmt.Errorf("could not create Yap folder in Obsidian vault: %w", err) + } + + fileName := ResolveNoteFileName(noteName, capturedAt) + notePath := filepath.Join(noteDir, fileName) + contentEntry := formatEntry(transcript, capturedAt) + + if _, err := os.Stat(notePath); os.IsNotExist(err) { + title := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + content := fmt.Sprintf("---\nsource: yap\ntype: transcriptions\ndate: %s\n---\n\n# %s\n\n%s", date, title, contentEntry) + if err := os.WriteFile(notePath, []byte(content), 0644); err != nil { + return "", fmt.Errorf("could not create Obsidian note: %w", err) + } + return notePath, nil + } else if err != nil { + return "", fmt.Errorf("could not inspect Obsidian note: %w", err) + } + + file, err := os.OpenFile(notePath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return "", fmt.Errorf("could not open Obsidian note: %w", err) + } + defer file.Close() + + if _, err := file.WriteString(contentEntry); err != nil { + return "", fmt.Errorf("could not append to Obsidian note: %w", err) + } + + return notePath, nil +} + +func formatEntry(transcript string, capturedAt time.Time) string { + return fmt.Sprintf("## %s\n\n%s\n\n", capturedAt.Format("15:04"), strings.TrimSpace(transcript)) +} + +// ResolveNoteFileName returns a safe Markdown filename for the configured note name. +func ResolveNoteFileName(noteName string, capturedAt time.Time) string { + name := strings.TrimSpace(noteName) + if name == "" { + name = DefaultNoteName + } + name = strings.ReplaceAll(name, datePlaceholder, capturedAt.Format(defaultDateFormat)) + name = sanitizeFileName(name) + if !strings.HasSuffix(strings.ToLower(name), defaultMarkdownExt) { + name += defaultMarkdownExt + } + return name +} + +func sanitizeFileName(name string) string { + name = strings.TrimSpace(name) + for _, invalid := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", "\n", "\r", "\t"} { + name = strings.ReplaceAll(name, invalid, " ") + } + name = strings.Join(strings.Fields(name), " ") + name = strings.Trim(name, ". ") + if name == "" { + return strings.ReplaceAll(DefaultNoteName, datePlaceholder, "") + } + return name +} + +// TranscriptionsRelativePath returns the vault-relative note path for a capture date. +func TranscriptionsRelativePath(noteName string, capturedAt time.Time) string { + return filepath.Join(yapFolder, ResolveNoteFileName(noteName, capturedAt)) +} diff --git a/internal/obsidian/writer_test.go b/internal/obsidian/writer_test.go new file mode 100644 index 0000000..0b3d0b3 --- /dev/null +++ b/internal/obsidian/writer_test.go @@ -0,0 +1,127 @@ +package obsidian + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestAppendTranscriptionCreatesDailyNote(t *testing.T) { + vaultPath := t.TempDir() + capturedAt := time.Date(2026, 6, 4, 14, 32, 0, 0, time.Local) + + notePath, err := AppendTranscription(vaultPath, "", " Transcribed text goes here. ", capturedAt) + if err != nil { + t.Fatalf("AppendTranscription returned error: %v", err) + } + + wantPath := filepath.Join(vaultPath, "Yap", "Transcriptions 2026-06-04.md") + if notePath != wantPath { + t.Fatalf("notePath = %q, want %q", notePath, wantPath) + } + + contentBytes, err := os.ReadFile(notePath) + if err != nil { + t.Fatalf("failed to read note: %v", err) + } + + want := "---\nsource: yap\ntype: transcriptions\ndate: 2026-06-04\n---\n\n# Transcriptions 2026-06-04\n\n## 14:32\n\nTranscribed text goes here.\n\n" + if string(contentBytes) != want { + t.Fatalf("content mismatch:\n%s", string(contentBytes)) + } +} + +func TestAppendTranscriptionAppendsToExistingDailyNote(t *testing.T) { + vaultPath := t.TempDir() + firstCapture := time.Date(2026, 6, 4, 14, 32, 0, 0, time.Local) + secondCapture := time.Date(2026, 6, 4, 16, 8, 0, 0, time.Local) + + if _, err := AppendTranscription(vaultPath, "", "First capture.", firstCapture); err != nil { + t.Fatalf("first AppendTranscription returned error: %v", err) + } + notePath, err := AppendTranscription(vaultPath, "", "Second capture.", secondCapture) + if err != nil { + t.Fatalf("second AppendTranscription returned error: %v", err) + } + + contentBytes, err := os.ReadFile(notePath) + if err != nil { + t.Fatalf("failed to read note: %v", err) + } + content := string(contentBytes) + + if strings.Count(content, "---\nsource: yap") != 1 { + t.Fatalf("expected one frontmatter block, got content:\n%s", content) + } + if !strings.Contains(content, "## 14:32\n\nFirst capture.") { + t.Fatalf("missing first capture:\n%s", content) + } + if !strings.Contains(content, "## 16:08\n\nSecond capture.") { + t.Fatalf("missing second capture:\n%s", content) + } + + want := "---\nsource: yap\ntype: transcriptions\ndate: 2026-06-04\n---\n\n# Transcriptions 2026-06-04\n\n## 14:32\n\nFirst capture.\n\n## 16:08\n\nSecond capture.\n\n" + if content != want { + t.Fatalf("content mismatch:\n%s", content) + } +} + +func TestAppendTranscriptionRequiresVaultPath(t *testing.T) { + _, err := AppendTranscription(" ", "", "text", time.Now()) + if err == nil { + t.Fatal("expected error for empty vault path") + } + if !strings.Contains(err.Error(), "set your Obsidian vault path") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAppendTranscriptionRequiresVaultFolder(t *testing.T) { + vaultFile := filepath.Join(t.TempDir(), "vault.md") + if err := os.WriteFile(vaultFile, []byte("not a folder"), 0644); err != nil { + t.Fatalf("failed to create vault file: %v", err) + } + + _, err := AppendTranscription(vaultFile, "", "text", time.Now()) + if err == nil { + t.Fatal("expected error for vault path that is not a folder") + } + if !strings.Contains(err.Error(), "must be a folder") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAppendTranscriptionUsesCustomStaticNoteName(t *testing.T) { + vaultPath := t.TempDir() + capturedAt := time.Date(2026, 6, 4, 14, 32, 0, 0, time.Local) + + notePath, err := AppendTranscription(vaultPath, "Meeting Notes", "A static note.", capturedAt) + if err != nil { + t.Fatalf("AppendTranscription returned error: %v", err) + } + + wantPath := filepath.Join(vaultPath, "Yap", "Meeting Notes.md") + if notePath != wantPath { + t.Fatalf("notePath = %q, want %q", notePath, wantPath) + } + + contentBytes, err := os.ReadFile(notePath) + if err != nil { + t.Fatalf("failed to read note: %v", err) + } + if !strings.Contains(string(contentBytes), "# Meeting Notes") { + t.Fatalf("expected custom title, got:\n%s", string(contentBytes)) + } +} + +func TestResolveNoteFileNameExpandsDateAndSanitizesPath(t *testing.T) { + capturedAt := time.Date(2026, 6, 4, 14, 32, 0, 0, time.Local) + + got := ResolveNoteFileName("Calls/{{date}}: Team", capturedAt) + want := "Calls 2026-06-04 Team.md" + if got != want { + t.Fatalf("ResolveNoteFileName = %q, want %q", got, want) + } +}