wiseguru_ReWrite-Voice-Notes/src/ui/modal.ts
2026-07-07 15:06:43 -07:00

593 lines
20 KiB
TypeScript

import { App, Modal, Notice, Platform } from 'obsidian';
import type ReWritePlugin from '../main';
import { runPipeline, PipelineSource } from '../pipeline';
import { isMediaRecorderAvailable, resolveActiveProfile } from '../platform';
import { Recorder } from '../recorder';
import { DestinationOverride, EnvironmentProfile, InsertMode, NoteTemplate } from '../types';
import { isProfileConfigured, isProfileConfiguredForText, renderSetupCard } from './setup-card';
import { resolveActiveTextSource } from './text-source';
import { formatDuration, runBackgroundPipeline, stageLabel } from './pipeline-progress';
import { pickDefaultTemplateId } from '../templates-folder';
// Continuous silence (ms) before the Record UI warns about a muted / dead mic.
const SILENCE_WARNING_MS = 3000;
const SILENCE_WARNING_TEXT = 'No audio detected. Check that your microphone is on and not muted.';
export class ReWriteModal extends Modal {
private templateId: string;
private activeTab: 'record' | 'paste' | 'fromNote' = 'record';
private recorder: Recorder | null = null;
private timerHandle: number | null = null;
private running = false;
// Hoisted from the old renderRecordTab closure local: a mid-recording render() (tab
// switch, template change, destination edit) used to rebuild the record tab with a
// fresh "Record" button while the old recorder/timer kept running with no way to stop
// them. Tab bar / template select / destination controls are now disabled while this
// (or `running`) is true, and the recording survives re-renders since it lives on the
// instance rather than a tab-local closure.
private isRecording = false;
private currentSource: PipelineSource | null = null;
private destinationOverride: DestinationOverride | null = null;
private destinationExpanded = false;
private contextHint = '';
private contextExpanded = false;
constructor(
app: App,
private readonly plugin: ReWritePlugin,
initialTemplateId?: string,
) {
super(app);
this.templateId = initialTemplateId ?? pickDefaultTemplateId(this.plugin.settings, this.plugin.templates);
}
onOpen(): void {
this.modalEl.addClass('rewrite-modal');
this.render();
}
onClose(): void {
this.releaseCapture();
this.contentEl.empty();
}
// While a recording is in progress or a pipeline is running, template/destination/tab
// controls that would call render() must be disabled: a mid-flight render() would
// rebuild the tab body and orphan the recorder or the in-flight execute() progress UI.
private isLocked(): boolean {
return this.isRecording || this.running;
}
private render(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h2', { text: 'ReWrite' });
const { kind, profile } = resolveActiveProfile(this.plugin.settings);
const profileLabel = kind === 'desktop' ? 'Desktop' : 'Mobile';
if (this.plugin.templates.length === 0) {
contentEl.createEl('p', {
text: 'No templates are configured. Open settings to set up your templates folder.',
});
const btn = contentEl.createEl('button', { text: 'Open settings' });
btn.addEventListener('click', () => {
this.close();
this.openSettingsTab();
});
return;
}
this.renderTemplateSelector(contentEl);
this.renderDestinationSelector(contentEl);
this.renderContextSelector(contentEl);
this.renderTabBar(contentEl);
const tabBody = contentEl.createDiv({ cls: 'rewrite-tab-body' });
if (this.plugin.encryptionStatus.locked) {
this.renderLockedBanner(tabBody);
return;
}
if (this.activeTab === 'fromNote') {
if (!isProfileConfiguredForText(profile)) {
this.renderSetupCardInTab(tabBody, profile, profileLabel, 'text');
return;
}
this.renderFromNoteTab(tabBody);
return;
}
if (!isProfileConfigured(profile)) {
this.renderSetupCardInTab(tabBody, profile, profileLabel, 'voice');
return;
}
if (this.activeTab === 'record') {
if (profile.transcriptionProvider === 'none') {
tabBody.createEl('p', {
text: 'Recording is disabled because this profile has no transcription provider configured. Switch to one of the other tabs above, or pick a transcription provider in settings.',
});
return;
}
this.renderRecordTab(tabBody);
} else {
this.renderPasteTab(tabBody);
}
}
private renderSetupCardInTab(
parent: HTMLElement,
profile: EnvironmentProfile,
profileLabel: string,
purpose: 'voice' | 'text',
): void {
renderSetupCard({
container: parent,
profile,
profileLabel,
purpose,
onSaved: async () => {
await this.plugin.saveSettings();
this.render();
},
onOpenSettings: () => {
this.close();
this.openSettingsTab();
},
});
}
private renderLockedBanner(parent: HTMLElement): void {
const card = parent.createDiv({ cls: 'rewrite-setup-card rewrite-locked-card' });
card.createEl('h3', { text: 'API keys are locked' });
card.createEl('p', {
text: 'Your API keys are encrypted with a passphrase. Unlock them to record or process text.',
});
const actions = card.createDiv({ cls: 'rewrite-setup-actions' });
const unlockBtn = actions.createEl('button', { text: 'Unlock', cls: 'mod-cta' });
unlockBtn.addEventListener('click', () => {
this.plugin.promptUnlock(() => this.render());
});
const settingsBtn = actions.createEl('button', { text: 'Open settings' });
settingsBtn.addEventListener('click', () => {
this.close();
this.openSettingsTab();
});
}
private renderTemplateSelector(parent: HTMLElement): void {
const wrap = parent.createDiv({ cls: 'rewrite-template-row' });
wrap.createEl('label', { text: 'Template' });
const select = wrap.createEl('select');
const ids = this.plugin.templates.map((t) => t.id);
if (!ids.includes(this.templateId)) {
this.templateId = ids[0] ?? '';
}
for (const t of this.plugin.templates) {
const opt = select.createEl('option', { text: t.name });
opt.value = t.id;
if (t.id === this.templateId) opt.selected = true;
}
select.disabled = this.isLocked();
select.addEventListener('change', () => {
if (this.isLocked()) return;
this.templateId = select.value;
this.destinationOverride = null;
this.destinationExpanded = false;
this.contextHint = '';
this.contextExpanded = false;
this.render();
});
}
private renderDestinationSelector(parent: HTMLElement): void {
const template = this.activeTemplate();
if (!template) return;
const effectiveMode = this.destinationOverride?.insertMode ?? template.insertMode;
const effectiveFolder = this.destinationOverride?.newFileFolder ?? template.newFileFolder;
const effectiveName = this.destinationOverride?.newFileNameTemplate ?? template.newFileNameTemplate;
const hasOverride = this.destinationOverride !== null;
const details = parent.createEl('details', { cls: 'rewrite-destination-row' });
details.open = this.destinationExpanded || hasOverride;
details.addEventListener('toggle', () => {
this.destinationExpanded = details.open;
});
const summary = details.createEl('summary', { cls: 'rewrite-destination-summary' });
const summaryQualifier = hasOverride ? 'Custom' : 'Default';
summary.createSpan({ cls: 'rewrite-destination-summary-label', text: 'Destination: ' });
summary.createSpan({
cls: 'rewrite-destination-summary-value',
text: `${summaryQualifier} (${describeDestination(effectiveMode, effectiveFolder, effectiveName)})`,
});
const body = details.createDiv({ cls: 'rewrite-destination-body' });
const select = body.createEl('select');
const modes: Array<{ id: InsertMode; label: string }> = [
{ id: 'cursor', label: 'Cursor (active editor)' },
{ id: 'newFile', label: 'New file' },
{ id: 'append', label: 'Append to active note' },
];
for (const m of modes) {
const opt = select.createEl('option', { text: m.label });
opt.value = m.id;
if (m.id === effectiveMode) opt.selected = true;
}
const locked = this.isLocked();
select.disabled = locked;
select.addEventListener('change', () => {
if (this.isLocked()) return;
this.setDestinationOverride({ insertMode: select.value as InsertMode });
this.destinationExpanded = true;
this.render();
});
if (effectiveMode === 'newFile') {
const folderLabel = body.createEl('label', { text: 'Folder', cls: 'rewrite-destination-sublabel' });
const folderInput = folderLabel.createEl('input', { type: 'text' });
folderInput.value = effectiveFolder;
folderInput.placeholder = '(vault root)';
folderInput.disabled = locked;
folderInput.addEventListener('change', () => {
this.setDestinationOverride({ newFileFolder: folderInput.value });
});
const nameLabel = body.createEl('label', { text: 'Filename template', cls: 'rewrite-destination-sublabel' });
const nameInput = nameLabel.createEl('input', { type: 'text' });
nameInput.value = effectiveName;
nameInput.placeholder = 'ReWrite {{date}} {{time}}';
nameInput.disabled = locked;
nameInput.addEventListener('change', () => {
this.setDestinationOverride({ newFileNameTemplate: nameInput.value });
});
}
if (this.destinationOverride) {
const reset = body.createEl('button', { text: 'Reset to template default', cls: 'rewrite-destination-reset' });
reset.disabled = locked;
reset.addEventListener('click', () => {
if (this.isLocked()) return;
this.destinationOverride = null;
this.destinationExpanded = false;
this.render();
});
}
}
private activeTemplate(): NoteTemplate | undefined {
return this.plugin.templates.find((t) => t.id === this.templateId);
}
private setDestinationOverride(patch: DestinationOverride): void {
const template = this.activeTemplate();
if (!template) return;
const current: DestinationOverride = this.destinationOverride ?? {
insertMode: template.insertMode,
newFileFolder: template.newFileFolder,
newFileNameTemplate: template.newFileNameTemplate,
};
this.destinationOverride = {
insertMode: patch.insertMode ?? current.insertMode,
newFileFolder: patch.newFileFolder ?? current.newFileFolder,
newFileNameTemplate: patch.newFileNameTemplate ?? current.newFileNameTemplate,
};
}
private renderContextSelector(parent: HTMLElement): void {
const template = this.activeTemplate();
if (!template?.enableContextHint) return;
const details = parent.createEl('details', { cls: 'rewrite-context-row' });
details.open = this.contextExpanded;
details.addEventListener('toggle', () => {
this.contextExpanded = details.open;
});
const summary = details.createEl('summary', { cls: 'rewrite-context-summary' });
summary.createSpan({ cls: 'rewrite-context-summary-label', text: 'Context: ' });
summary.createSpan({
cls: 'rewrite-context-summary-value',
text: this.contextHint.trim() ? 'Set' : 'None (optional)',
});
const body = details.createDiv({ cls: 'rewrite-context-body' });
const textarea = body.createEl('textarea', { cls: 'rewrite-context-input' });
textarea.rows = Platform.isMobile ? 2 : 3;
textarea.placeholder = 'Who is speaking and what this recording is (for example a lecture by one professor, or a meeting with several teammates)';
textarea.value = this.contextHint;
textarea.addEventListener('input', () => {
this.contextHint = textarea.value;
});
}
private renderTabBar(parent: HTMLElement): void {
const tabs = parent.createDiv({ cls: 'rewrite-tabs' });
const record = tabs.createEl('button', { text: 'Record', cls: 'rewrite-tab' });
const paste = tabs.createEl('button', { text: 'Paste', cls: 'rewrite-tab' });
const fromNote = tabs.createEl('button', { text: 'From note', cls: 'rewrite-tab' });
if (this.activeTab === 'record') record.addClass('is-active');
else if (this.activeTab === 'paste') paste.addClass('is-active');
else fromNote.addClass('is-active');
const locked = this.isLocked();
record.disabled = locked;
paste.disabled = locked;
fromNote.disabled = locked;
record.addEventListener('click', () => {
if (this.isLocked()) return;
this.activeTab = 'record';
this.render();
});
paste.addEventListener('click', () => {
if (this.isLocked()) return;
this.activeTab = 'paste';
this.render();
});
fromNote.addEventListener('click', () => {
if (this.isLocked()) return;
this.activeTab = 'fromNote';
this.render();
});
}
private renderRecordTab(parent: HTMLElement): void {
if (!isMediaRecorderAvailable()) {
parent.createEl('p', {
text: 'Audio recording is not supported in this environment. Use the paste tab instead.',
});
return;
}
const button = parent.createEl('button', {
text: 'Record',
cls: 'mod-cta rewrite-record-button',
});
const indicator = parent.createDiv({ cls: 'rewrite-recording-indicator' });
const dot = indicator.createSpan({ cls: 'rewrite-pulse-dot' });
dot.hide();
const timer = indicator.createSpan({ cls: 'rewrite-timer', text: '0:00' });
const warning = parent.createDiv({
cls: 'rewrite-silence-warning',
text: SILENCE_WARNING_TEXT,
});
warning.hide();
// isRecording is hoisted onto the instance (this.isRecording) rather than kept as a tab-local
// closure variable so it survives a render() the instant one is disallowed to fire: tab bar,
// template select, and destination controls are disabled via isLocked() for as long as it (or
// this.running) is true, so this record button is the only interactive control left.
const handleClick = async (): Promise<void> => {
// Only guard on a pipeline being in flight, not on isRecording: this button is what
// toggles isRecording, so gating on isLocked() here would make Stop unreachable the
// moment recording starts (isLocked() is true precisely because isRecording is true).
if (this.running) return;
if (!this.isRecording) {
try {
await this.beginCapture();
} catch (e) {
new Notice(e instanceof Error ? e.message : String(e));
return;
}
this.isRecording = true;
button.setText('Stop');
dot.show();
this.startTimerLoop(timer, warning);
} else {
button.disabled = true;
try {
const source = await this.endCapture();
this.isRecording = false;
button.setText('Record');
dot.hide();
warning.hide();
this.stopTimerLoop();
this.startRecordingPipeline(source);
} catch (e) {
new Notice(e instanceof Error ? e.message : String(e));
this.isRecording = false;
button.setText('Record');
dot.hide();
warning.hide();
this.stopTimerLoop();
} finally {
button.disabled = false;
}
}
};
button.addEventListener('click', () => {
void handleClick();
});
}
private renderPasteTab(parent: HTMLElement): void {
const textarea = parent.createEl('textarea', { cls: 'rewrite-paste' });
// Fewer rows on mobile so the submit button stays above the soft keyboard.
textarea.rows = Platform.isMobile ? 4 : 10;
const button = parent.createEl('button', { text: 'Clean up', cls: 'mod-cta' });
button.addEventListener('click', () => {
if (this.running) return;
const text = textarea.value.trim();
if (!text) {
new Notice('Paste some text first.');
return;
}
button.disabled = true;
void this.execute({ kind: 'paste', text }).finally(() => { button.disabled = false; });
});
textarea.focus();
}
private renderFromNoteTab(parent: HTMLElement): void {
const previewEl = parent.createEl('p', { cls: 'rewrite-from-note-preview' });
const source = resolveActiveTextSource(this.app);
if (!source) {
previewEl.setText('No active Markdown note. Open a note (or select text) to use this.');
} else if (source.scope === 'selection') {
previewEl.setText(`Selection: ${source.text.length.toLocaleString()} chars`);
} else {
previewEl.setText(`Whole note: ${source.text.length.toLocaleString()} chars`);
}
const button = parent.createEl('button', { text: 'Run', cls: 'mod-cta' });
if (!source) button.disabled = true;
button.addEventListener('click', () => {
if (this.running) return;
const fresh = resolveActiveTextSource(this.app);
if (!fresh) {
new Notice('Open a Markdown note or select text first.');
return;
}
const trimmed = fresh.text.trim();
if (!trimmed) {
new Notice('Source text is empty.');
return;
}
button.disabled = true;
void this.execute({ kind: 'text', text: fresh.text }).finally(() => { button.disabled = false; });
});
}
private async beginCapture(): Promise<void> {
this.recorder = new Recorder();
await this.recorder.start(this.plugin.settings.recordingFormat);
}
private async endCapture(): Promise<PipelineSource> {
if (!this.recorder) throw new Error('No active recording.');
const result = await this.recorder.stop();
this.recorder = null;
return { kind: 'audio', audio: result.blob, durationMs: result.durationMs };
}
private startTimerLoop(timerEl: HTMLElement, warningEl: HTMLElement): void {
this.timerHandle = window.setInterval(() => {
const recorder = this.recorder;
const ms = recorder?.getElapsedMs() ?? 0;
timerEl.setText(formatDuration(ms));
const silent = (recorder?.getSilentMs() ?? 0) > SILENCE_WARNING_MS;
if (silent) warningEl.show();
else warningEl.hide();
}, 250);
}
private stopTimerLoop(): void {
if (this.timerHandle !== null) {
window.clearInterval(this.timerHandle);
this.timerHandle = null;
}
}
private releaseCapture(): void {
this.stopTimerLoop();
this.recorder?.cancel();
this.recorder = null;
}
// Recorded-audio path: close the modal immediately and run the pipeline detached, reporting
// progress and errors through a Notice (mirrors runAudioFilePipeline). The recording is
// persisted to the vault before transcription, so the saved file is the recovery path on
// error; no inline Retry is needed. Paste / From note keep execute()'s in-modal flow because
// they have no persisted recovery.
private startRecordingPipeline(source: PipelineSource): void {
const template = this.activeTemplate();
if (!template) {
new Notice('Please pick a template.');
return;
}
const { profile } = resolveActiveProfile(this.plugin.settings);
const destinationOverride = this.destinationOverride ?? undefined;
const contextHint = this.contextHint.trim() || undefined;
const plugin = this.plugin;
const app = this.app;
this.close();
void runBackgroundPipeline(
plugin,
{
app,
settings: plugin.settings,
host: plugin,
profile,
template,
source,
destinationOverride,
contextHint,
},
{ startMessage: 'ReWrite: working...', templateId: template.id },
);
}
private async execute(source: PipelineSource): Promise<void> {
if (this.running) return;
const template = this.plugin.templates.find((t) => t.id === this.templateId);
if (!template) {
new Notice('Please pick a template.');
return;
}
this.currentSource = source;
this.running = true;
const progress = this.contentEl.createDiv({ cls: 'rewrite-progress' });
progress.setText('Working...');
const { profile } = resolveActiveProfile(this.plugin.settings);
try {
await runPipeline({
app: this.app,
settings: this.plugin.settings,
host: this.plugin,
profile,
template,
source,
destinationOverride: this.destinationOverride ?? undefined,
contextHint: this.contextHint.trim() || undefined,
onStage: (stage) => progress.setText(stageLabel(stage)),
});
this.plugin.settings.lastUsedTemplateId = template.id;
await this.plugin.saveSettings();
new Notice('ReWrite complete.');
this.close();
} catch (e) {
progress.remove();
const message = e instanceof Error ? e.message : String(e);
new Notice(message);
this.renderRetry(message);
} finally {
this.running = false;
}
}
private renderRetry(message: string): void {
const retry = this.contentEl.createDiv({ cls: 'rewrite-retry' });
retry.createEl('p', { text: message });
const button = retry.createEl('button', { text: 'Retry', cls: 'mod-cta' });
button.addEventListener('click', () => {
retry.remove();
if (this.currentSource) {
void this.execute(this.currentSource);
}
});
}
private openSettingsTab(): void {
const setting = (this.app as unknown as {
setting?: { open(): void; openTabById(id: string): void };
}).setting;
if (!setting) return;
setting.open();
setting.openTabById(this.plugin.manifest.id);
}
}
function describeDestination(mode: InsertMode, folder: string, nameTemplate: string): string {
switch (mode) {
case 'cursor':
return 'Cursor (active editor)';
case 'append':
return 'Append to active note';
case 'newFile': {
const folderPart = folder.trim() || '(vault root)';
const namePart = nameTemplate.trim() || 'ReWrite {{date}} {{time}}';
return `New file: ${folderPart}/${namePart}`;
}
}
}