import { Editor, MarkdownFileInfo, MarkdownView, Notice, Platform, Plugin, TFile } from 'obsidian'; import { hydrateSecrets, loadSettings, saveSettings } from './settings'; import { ReWriteSettingTab } from './settings/tab'; import { ReWriteModal } from './ui/modal'; import { PassphraseModal } from './ui/passphrase-modal'; import { QuickRecordController, startQuickRecord } from './ui/quick-record'; import { resolveActiveTextSource, resolveTextFromEditor, runTextPipeline, TextResolution } from './ui/text-source'; import { TemplatePickerModal } from './ui/template-picker'; import { AudioFilePickerModal } from './ui/audio-file-picker'; import { collectAudioFiles, isAudioFile, runAudioFilePipeline } from './ui/audio-source'; import { WhisperStatusBar } from './ui/whisper-status-bar'; import { GlobalSettings, KnownNoun, NoteTemplate, PipelineHost } from './types'; import { WhisperHost } from './whisper-host'; import { bindWhisperHost } from './transcription/whisper-local'; import { resolveActiveProfile } from './platform'; import { isPathInTemplatesFolder, loadTemplatesFromFolder } from './templates-folder'; import { isPathSharedCore, loadSharedCoreFromFile } from './shared-core'; import { isPathAssistantPrompt, loadAssistantPromptFromFile } from './assistant-prompt'; import { isPathKnownNouns, loadKnownNounsFromFile } from './known-nouns'; import { changeEncryptionMode, EncryptionStatus, getEncryptionStatus, unlockSecrets, warmSecretStorage } from './secrets'; export default class ReWritePlugin extends Plugin implements PipelineHost { settings!: GlobalSettings; whisperHost!: WhisperHost; templates: NoteTemplate[] = []; sharedCore: string | null = null; assistantPrompt: string | null = null; knownNouns: KnownNoun[] = []; encryptionStatus!: EncryptionStatus; private activeQuickRecord: QuickRecordController | null = null; private unlockListeners = new Set<() => void>(); async onload(): Promise { this.settings = await loadSettings(this); // Warm the secret-storage probe before reading status so a first run (no envelope yet) // can default to secret storage when it is available. await warmSecretStorage(this); this.encryptionStatus = await getEncryptionStatus(this); this.whisperHost = new WhisperHost(this); bindWhisperHost(this.whisperHost); if (Platform.isDesktop) { void this.whisperHost.probe(this.settings.localWhisper).then((snap) => { if (snap.status === 'running' && snap.ownership === 'adopted') { new Notice(`ReWrite: adopted whisper-server from previous session (pid ${snap.pid ?? '?'}).`); } else if (snap.status === 'external') { new Notice(`ReWrite: detected external whisper-server on ${snap.baseUrl}. Transcription will use it; ReWrite won't stop it.`); } }).catch((e) => { console.error('ReWrite: whisper-host probe failed', e); }); } this.addSettingTab(new ReWriteSettingTab(this.app, this)); this.addRibbonIcon('mic', 'ReWrite', () => { this.openModal(); }); this.addCommand({ id: 'open-modal', name: 'Open', callback: () => { this.openModal(); }, }); this.addCommand({ id: 'quick-record', name: 'Quick record (last used)', callback: () => { void this.toggleQuickRecord(); }, }); this.addCommand({ id: 'quick-record-fixed', name: 'Quick record (set template)', callback: () => { void this.toggleQuickRecord({ fixed: true }); }, }); this.addCommand({ id: 'process-text', name: 'Process text with template', callback: () => { this.processTextWithTemplate(); }, }); this.addCommand({ id: 'reprocess-audio', name: 'Reprocess audio file with template', callback: () => { this.reprocessAudioFile(); }, }); this.addCommand({ id: 'start-whisper-host', name: 'Start local whisper.cpp server', checkCallback: (checking) => { if (!Platform.isDesktop) return false; if (resolveActiveProfile(this.settings).profile.transcriptionProvider !== 'whisper-local') return false; const status = this.whisperHost.status(); if (status !== 'stopped' && status !== 'crashed') return false; if (!checking) { void this.startWhisperHost(); } return true; }, }); this.addCommand({ id: 'stop-whisper-host', name: 'Stop local whisper.cpp server', checkCallback: (checking) => { if (!Platform.isDesktop) return false; const status = this.whisperHost.status(); // Don't offer Stop for 'external'; ReWrite never kills processes it didn't start. if (status !== 'running' && status !== 'starting') return false; if (!checking) { void this.stopWhisperHost(); } return true; }, }); if (Platform.isDesktop) { const statusBar = new WhisperStatusBar(this, this.addStatusBarItem()); statusBar.start(); } this.registerEvent(this.app.workspace.on('editor-menu', (menu, editor, info) => { menu.addItem((item) => { item.setTitle('ReWrite with template...'); item.setIcon('mic'); item.onClick(() => { this.processTextWithTemplate(resolveTextFromEditor(editor)); }); }); const audioUnderCursor = this.findAudioEmbedUnderCursor(editor, info); if (audioUnderCursor) { menu.addItem((item) => { item.setTitle('Reprocess audio with template...'); item.setIcon('mic'); item.onClick(() => { this.openTemplatePickerForAudio(audioUnderCursor); }); }); } })); this.registerEvent(this.app.workspace.on('file-menu', (menu, file) => { if (!isAudioFile(file)) return; menu.addItem((item) => { item.setTitle('Reprocess audio with template...'); item.setIcon('mic'); item.onClick(() => { this.openTemplatePickerForAudio(file); }); }); })); this.registerEvent(this.app.vault.on('create', (file) => this.onVaultFileChanged(file.path))); this.registerEvent(this.app.vault.on('modify', (file) => this.onVaultFileChanged(file.path))); this.registerEvent(this.app.vault.on('delete', (file) => this.onVaultFileChanged(file.path))); this.registerEvent(this.app.vault.on('rename', (file, oldPath) => { this.onVaultFileChanged(file.path); this.onVaultFileChanged(oldPath); })); this.app.workspace.onLayoutReady(() => { void this.refreshTemplates(); void this.refreshSharedCore(); void this.refreshAssistantPrompt(); void this.refreshKnownNouns(); }); } onunload(): void { this.activeQuickRecord?.cancel(); this.activeQuickRecord = null; void this.whisperHost?.stop(); } async saveSettings(): Promise { await saveSettings(this, this.settings); } async refreshEncryptionStatus(): Promise { this.encryptionStatus = await getEncryptionStatus(this); return this.encryptionStatus; } onSecretsUnlocked(cb: () => void): () => void { this.unlockListeners.add(cb); return () => this.unlockListeners.delete(cb); } notifySecretsUnlocked(): void { for (const cb of this.unlockListeners) { try { cb(); } catch (e) { console.error('ReWrite: secrets-unlocked listener failed', e); } } } promptUnlock(onUnlocked?: () => void): void { // Unconfigured passphrase mode (no keychain device, first run) reports // locked === true but has no passphrase yet: prompt to CREATE one rather // than unlock. Configured-but-locked takes the unlock path. if (!this.encryptionStatus.configured) { new PassphraseModal({ app: this.app, title: 'Set a passphrase', description: 'No OS keychain is available on this device, so your API keys are encrypted with a passphrase you set. Store it in your password manager; there is no recovery if you forget it.', confirmLabel: 'Save', requireConfirm: true, enforceStrength: true, onSubmit: async (pass) => { await changeEncryptionMode(this, 'passphrase', pass); await hydrateSecrets(this, this.settings); await this.refreshEncryptionStatus(); this.notifySecretsUnlocked(); onUnlocked?.(); new Notice('ReWrite: passphrase set. API keys are now encrypted.'); }, }).open(); return; } new PassphraseModal({ app: this.app, title: 'Unlock API keys', description: 'Enter your passphrase to decrypt the API keys stored on this device.', confirmLabel: 'Unlock', onSubmit: async (pass) => { const ok = await unlockSecrets(this, pass); if (!ok) throw new Error('Incorrect passphrase.'); await hydrateSecrets(this, this.settings); await this.refreshEncryptionStatus(); this.notifySecretsUnlocked(); onUnlocked?.(); new Notice('ReWrite: API keys unlocked.'); }, }).open(); } async refreshTemplates(): Promise { this.templates = await loadTemplatesFromFolder(this.app, this.settings.templatesFolderPath); } async refreshSharedCore(): Promise { this.sharedCore = await loadSharedCoreFromFile(this.app, this.settings.sharedCorePath); } async refreshAssistantPrompt(): Promise { this.assistantPrompt = await loadAssistantPromptFromFile(this.app, this.settings.assistantPromptPath); } async refreshKnownNouns(): Promise { this.knownNouns = await loadKnownNounsFromFile(this.app, this.settings.knownNounsPath); } private onVaultFileChanged(path: string): void { if (this.isInTemplatesFolder(path)) { void this.refreshTemplates(); } if (isPathSharedCore(path, this.settings.sharedCorePath)) { void this.refreshSharedCore(); } if (isPathAssistantPrompt(path, this.settings.assistantPromptPath)) { void this.refreshAssistantPrompt(); } if (isPathKnownNouns(path, this.settings.knownNounsPath)) { void this.refreshKnownNouns(); } } private isInTemplatesFolder(path: string): boolean { return isPathInTemplatesFolder(path, this.settings.templatesFolderPath); } private openModal(): void { new ReWriteModal(this.app, this).open(); } private async startWhisperHost(): Promise { try { await this.whisperHost.start(this.settings.localWhisper); } catch (e) { console.error('ReWrite: whisper-host start failed', e); new Notice(e instanceof Error ? e.message : String(e)); } } private async stopWhisperHost(): Promise { try { await this.whisperHost.stop(); } catch (e) { console.error('ReWrite: whisper-host stop failed', e); new Notice(e instanceof Error ? e.message : String(e)); } } private async toggleQuickRecord(opts?: { fixed?: boolean }): Promise { if (this.activeQuickRecord) { await this.activeQuickRecord.finish(); return; } const commandId = `${this.manifest.id}:${opts?.fixed ? 'quick-record-fixed' : 'quick-record'}`; let template: NoteTemplate | undefined; if (opts?.fixed) { template = this.templates.find((t) => t.id === this.settings.quickRecordTemplateId); if (!template) { new Notice('ReWrite: choose a quick record template in settings.'); return; } } this.activeQuickRecord = await startQuickRecord(this, () => { this.activeQuickRecord = null; }, { template, commandId }); } private processTextWithTemplate(preResolved?: TextResolution): void { const source = preResolved ?? resolveActiveTextSource(this.app); if (!source) { new Notice('Open a Markdown note or select text to use this command.'); return; } if (!source.text.trim()) { new Notice('Source text is empty.'); return; } if (this.templates.length === 0) { new Notice('Add a template in settings first.'); return; } const previewText = source.scope === 'selection' ? `Selection: ${source.text.length.toLocaleString()} chars` : `Whole note: ${source.text.length.toLocaleString()} chars`; new TemplatePickerModal({ app: this.app, templates: this.templates, defaultTemplateId: this.pickDefaultTemplateId(), previewText, onPick: (template) => { void runTextPipeline(this, template, source.text); }, }).open(); } private reprocessAudioFile(preSelected?: TFile): void { if (this.templates.length === 0) { new Notice('Add a template in settings first.'); return; } if (preSelected) { this.openTemplatePickerForAudio(preSelected); return; } const files = collectAudioFiles(this.app); if (files.length === 0) { new Notice('No audio files found in this vault.'); return; } new AudioFilePickerModal({ app: this.app, files, onPick: (file) => { this.openTemplatePickerForAudio(file); }, }).open(); } private openTemplatePickerForAudio(file: TFile): void { if (this.templates.length === 0) { new Notice('Add a template in settings first.'); return; } new TemplatePickerModal({ app: this.app, templates: this.templates, defaultTemplateId: this.pickDefaultTemplateId(), previewText: `Audio: ${file.path}`, showContext: this.templates.some((t) => t.enableContextHint), onPick: (template, contextHint) => { void runAudioFilePipeline(this, template, file, template.enableContextHint ? contextHint : undefined); }, }).open(); } private findAudioEmbedUnderCursor(editor: Editor, info: MarkdownView | MarkdownFileInfo): TFile | null { const cursor = editor.getCursor(); const line = editor.getLine(cursor.line); const re = /!\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g; const sourcePath = info instanceof MarkdownView ? info.file?.path ?? '' : info.file?.path ?? ''; let match: RegExpExecArray | null; while ((match = re.exec(line)) !== null) { const start = match.index; const end = start + match[0].length; if (cursor.ch < start || cursor.ch > end) continue; const linkpath = match[1]?.trim(); if (!linkpath) continue; const resolved = this.app.metadataCache.getFirstLinkpathDest(linkpath, sourcePath); if (isAudioFile(resolved)) return resolved; } return null; } private pickDefaultTemplateId(): string { const s = this.settings; if (s.lastUsedTemplateId && this.templates.some((t) => t.id === s.lastUsedTemplateId)) { return s.lastUsedTemplateId; } if (s.defaultTemplateId && this.templates.some((t) => t.id === s.defaultTemplateId)) { return s.defaultTemplateId; } return this.templates[0]?.id ?? ''; } }