This commit is contained in:
WiseGuru 2026-05-24 21:38:29 -07:00
parent 8fe298e5a1
commit 0c6cd44d72
8 changed files with 304 additions and 103 deletions

View file

@ -56,9 +56,11 @@ src/
│ ├── tab.ts # PluginSettingTab: active profile, two profile sections, templates, recording
│ └── default-templates.ts # The 5 seeded templates (General cleanup, Todo list, etc.)
├── ui/
│ ├── modal.ts # Main modal: template select + Record/Paste tabs + setup-card injection
│ ├── setup-card.ts # Inline blocker when active profile is unconfigured
│ └── quick-record.ts # QuickRecordController + floating mini-UI for the Quick Record command
│ ├── modal.ts # Main modal: template select + Record/Paste/From note tabs + setup-card injection
│ ├── setup-card.ts # Inline blocker when active profile is unconfigured (voice vs text purpose)
│ ├── quick-record.ts # QuickRecordController + floating mini-UI for the Quick Record command
│ ├── template-picker.ts # Lightweight modal for picking a template (used by Process text command and editor menu)
│ └── text-source.ts # resolveActiveTextSource + runTextPipeline helpers for text-source flows
├── transcription/
│ ├── index.ts # TranscriptionProvider interface + createTranscriptionProvider()
│ ├── openai.ts # Whisper-shape POST (also used by openai-compatible + groq)
@ -77,11 +79,13 @@ src/
[src/pipeline.ts](src/pipeline.ts) runs three stages with `onStage` callbacks for UI:
1. **Transcribe**: `audio``createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` (text passes through). Short-circuited when the source is `webspeech` (the transcript was already captured live by `src/webspeech.ts` during recording).
1. **Transcribe**: `audio``createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` or `text` (input passes through). Short-circuited when the source is `webspeech` (the transcript was already captured live by `src/webspeech.ts` during recording).
2. **Cleanup**: `createLLMProvider(profile.llmProvider).complete(template.prompt, transcript, config)`. On error, the raw transcript is copied to the clipboard before re-throwing, so the user keeps their words.
3. **Insert**: `src/insert.ts` routes to `cursor` / `newFile` / `append` per the template. `cursor` falls back to `append` when no editor is active; `append` falls back to `newFile` when no markdown file exists. `{{date}}` / `{{time}}` in filename templates expand via Obsidian's `moment`.
The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed by both [src/ui/modal.ts](src/ui/modal.ts) and [src/ui/quick-record.ts](src/ui/quick-record.ts).
The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed by [src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts), and [src/ui/text-source.ts](src/ui/text-source.ts) (the `runTextPipeline` helper for command + editor-menu entry points).
The `PipelineSource` union has four variants: `audio` (recorded blob), `paste` (textarea input), `webspeech` (live-captured transcript), `text` (input from an existing note via selection or whole body). Text-source flows skip transcription entirely and only require the LLM half of the profile.
## Provider system
@ -106,8 +110,9 @@ Registered in [src/main.ts](src/main.ts):
- **`rewrite-plugin:open-modal`** ("Open"): opens the main modal with the last-used template selected.
- **`rewrite-plugin:quick-record`** ("Quick record"): starts a recording immediately with a floating mini-UI (no modal). Second press toggles to Stop. On unconfigured profile or capture-API unavailability, opens the modal instead. On post-capture pipeline error, opens the modal so the user can retry (LLM-stage failures leave the raw transcript on the clipboard).
- **`rewrite-plugin:process-text`** ("Process text with template"): runs a template over the active editor's selection (or the whole note body if there's no selection). Opens a template quick-picker, then runs the pipeline in the background with progress shown via `Notice`. Gates on LLM-only configuration; opens the main modal's setup card when not configured. Bails with a Notice when no Markdown editor is active.
Plus an `addRibbonIcon('mic', 'ReWrite', ...)` that opens the modal.
Plus an editor-menu item "ReWrite with template..." registered via `workspace.on('editor-menu', ...)` and an `addRibbonIcon('mic', 'ReWrite', ...)` that opens the modal.
## Code style

View file

@ -74,51 +74,12 @@ Once Phase A is solid:
---
### 3. Act on existing text in a note
**Goal:** apply a ReWrite template to text that's already in a markdown note, no recording involved. Use case: you used a separate STT app (or just typed) to get a wall of text into a note, now you want ReWrite to turn it into a daily note, a todo list, a summary, etc.
This is the same pipeline minus the transcribe stage. [src/pipeline.ts](../src/pipeline.ts) already short-circuits transcription for `source.kind === 'paste'` and `source.kind === 'webspeech'`; a new `source.kind === 'text'` slots in identically.
**Entry points (three, mirroring how voice has three):**
1. **Command palette: `rewrite-plugin:process-text` ("Process text with template").** Picks template, then runs on the editor's current selection if there is one, otherwise the whole note body. Bails with a Notice if no markdown editor is active.
2. **Editor context menu item: "ReWrite with template..."** Registered via `this.registerEvent(this.app.workspace.on('editor-menu', ...))`. Opens a template quick-picker (a small modal listing templates by name). Operates on selection if present, otherwise whole note.
3. **New tab in the existing modal: "From note".** Joins the existing "Record" and "Paste" tabs in [src/ui/modal.ts](../src/ui/modal.ts). Shows a preview of what will be processed ("Selection: 247 chars" or "Whole note: 1,832 chars"), template dropdown, Run button. Reachable via the ribbon icon and the existing `open-modal` command, so the feature is discoverable without anyone reading docs.
**Source resolution:**
- If an editor selection is non-empty → use selection.
- Else if the active leaf is a markdown view → use full note body.
- Else → Notice "Open a markdown note or select text to use this command." Bail.
**Output / insert behavior:**
- Honors the template's `insertMode` (cursor / newFile / append), same as voice. If the template says "create new file in Daily/", that's where the cleaned output goes regardless of where the source text came from. Consistent mental model: templates own *where output lives*.
- The source text is **not modified by default**. Output goes per template; user manually deletes the source if they want. Reason: silently mutating the source on every invocation is destructive and surprising.
- One exception worth considering: a per-invocation "Replace source after processing" checkbox in the modal/quick-picker (defaults off). Not a template setting because it's about the source, not the output. **Open question:** ship without this in v1 of the feature, add only if users actually ask. The default-off "Keep both" behavior is safer.
**Pipeline change:**
- New source variant in [src/pipeline.ts](../src/pipeline.ts): `{ kind: 'text', text: string }`. Skips transcribe stage exactly like `paste` does. Cleanup + insert run unchanged.
- The LLM-error clipboard fallback (currently copies the raw transcript when cleanup fails) still applies: copies the source text so the user doesn't lose context if the LLM call dies.
- `AbortSignal` plumbed through as today.
**Setup-card / config gating:**
- The "From note" tab and the new commands only need the LLM provider configured, not transcription. Setup card logic in [src/ui/setup-card.ts](../src/ui/setup-card.ts) currently blocks on *both* being configured. Either: split the gating per entry point (cleaner) or: only block on LLM for text-source entries (simpler). Pick when implementing.
**Touch points:** [src/pipeline.ts](../src/pipeline.ts) (add `text` source variant), [src/main.ts](../src/main.ts) (register new command + editor-menu event), [src/ui/modal.ts](../src/ui/modal.ts) (add "From note" tab), new file `src/ui/template-picker.ts` for the quick-picker modal used by the command + context menu, [src/ui/setup-card.ts](../src/ui/setup-card.ts) (relax gating for text-source flow), [src/types.ts](../src/types.ts) (extend source union).
**Out of scope (for this feature):**
- Per-template "this template only operates on text" / "this template only operates on voice" gating. All templates work with both source types in v1; if a user creates a template whose prompt only makes sense for raw transcripts, that's on them.
- Multi-select / multi-note batch processing. One source at a time.
---
## Done
### Act on existing text in a note
Added a `{ kind: 'text'; text: string }` source variant to the pipeline (skips transcription, same path as `paste`). Three entry points: `rewrite-plugin:process-text` command, an editor-menu item "ReWrite with template...", and a new "From note" tab in the main modal. All three resolve text from the active editor (selection if non-empty, otherwise whole note body) and open a lightweight template quick-picker before running. Setup-card gating split into voice (full check) vs text (LLM-only check) via `isProfileConfiguredForText` and a `purpose` param on `renderSetupCard`; the modal's per-tab rendering picks the right gate. Helpers live in [src/ui/text-source.ts](../src/ui/text-source.ts) (resolution + `runTextPipeline`) and [src/ui/template-picker.ts](../src/ui/template-picker.ts) (the quick-picker modal). Shipped without the per-invocation "Replace source" checkbox; can revisit if users ask.
### Collapse API key settings: per-profile only, hide rarely-changed fields under "Advanced"
Removed the "Global API keys" section and the by-family `apiKeys` map. Each profile now owns its own transcription and LLM keys directly on `transcriptionConfig.apiKey` / `llmConfig.apiKey`. The per-profile fields are no longer framed as "overrides"; they're the only slot. "Transcription language" and "LLM max tokens" moved into a per-profile `<details>` "Advanced" disclosure. The resolver functions (`resolveTranscriptionApiKey`, `resolveLLMApiKey`) and family helpers (`transcriptionProviderFamily`, `llmProviderFamily`) are gone, along with the `ProviderFamily` type. `secrets.json.nosync` now only contains `profile:{kind}:{side}` IDs.

View file

@ -1,8 +1,10 @@
import { Plugin } from 'obsidian';
import { Notice, Plugin } from 'obsidian';
import { loadSettings, saveSettings } from './settings';
import { ReWriteSettingTab } from './settings/tab';
import { ReWriteModal } from './ui/modal';
import { QuickRecordController, startQuickRecord } from './ui/quick-record';
import { resolveActiveTextSource, resolveTextFromEditor, runTextPipeline, TextResolution } from './ui/text-source';
import { TemplatePickerModal } from './ui/template-picker';
import { GlobalSettings } from './types';
export default class ReWritePlugin extends Plugin {
@ -32,6 +34,24 @@ export default class ReWritePlugin extends Plugin {
void this.toggleQuickRecord();
},
});
this.addCommand({
id: 'process-text',
name: 'Process text with template',
callback: () => {
this.processTextWithTemplate();
},
});
this.registerEvent(this.app.workspace.on('editor-menu', (menu, editor) => {
menu.addItem((item) => {
item.setTitle('ReWrite with template...');
item.setIcon('mic');
item.onClick(() => {
this.processTextWithTemplate(resolveTextFromEditor(editor));
});
});
}));
}
onunload(): void {
@ -56,4 +76,43 @@ export default class ReWritePlugin extends Plugin {
this.activeQuickRecord = null;
});
}
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.settings.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.settings.templates,
defaultTemplateId: this.pickDefaultTemplateId(),
previewText,
onPick: (template) => {
void runTextPipeline(this, template, source.text);
},
}).open();
}
private pickDefaultTemplateId(): string {
const s = this.settings;
if (s.lastUsedTemplateId && s.templates.some((t) => t.id === s.lastUsedTemplateId)) {
return s.lastUsedTemplateId;
}
if (s.defaultTemplateId && s.templates.some((t) => t.id === s.defaultTemplateId)) {
return s.defaultTemplateId;
}
return s.templates[0]?.id ?? '';
}
}

View file

@ -9,7 +9,8 @@ export type PipelineStage = 'transcribe' | 'cleanup' | 'insert';
export type PipelineSource =
| { kind: 'audio'; audio: Blob }
| { kind: 'paste'; text: string }
| { kind: 'webspeech'; transcript: string };
| { kind: 'webspeech'; transcript: string }
| { kind: 'text'; text: string };
export interface PipelineParams {
app: App;
@ -50,6 +51,7 @@ async function collectTranscript(params: PipelineParams): Promise<string> {
const source = params.source;
switch (source.kind) {
case 'paste':
case 'text':
return source.text;
case 'webspeech':
return source.transcript;

View file

@ -4,11 +4,13 @@ import { runPipeline, PipelineSource, PipelineStage } from '../pipeline';
import { isMediaRecorderAvailable, isWebSpeechAvailable, resolveActiveProfile } from '../platform';
import { Recorder } from '../recorder';
import { startWebSpeech, WebSpeechSession } from '../webspeech';
import { isProfileConfigured, renderSetupCard } from './setup-card';
import { EnvironmentProfile } from '../types';
import { isProfileConfigured, isProfileConfiguredForText, renderSetupCard } from './setup-card';
import { resolveActiveTextSource } from './text-source';
export class ReWriteModal extends Modal {
private templateId: string;
private activeTab: 'record' | 'paste' = 'record';
private activeTab: 'record' | 'paste' | 'fromNote' = 'record';
private recorder: Recorder | null = null;
private webSpeech: WebSpeechSession | null = null;
private timerHandle: number | null = null;
@ -51,22 +53,7 @@ export class ReWriteModal extends Modal {
contentEl.createEl('h2', { text: 'ReWrite' });
const { kind, profile } = resolveActiveProfile(this.plugin.settings);
if (!isProfileConfigured(profile)) {
renderSetupCard({
container: contentEl,
profile,
profileLabel: kind === 'desktop' ? 'Desktop' : 'Mobile',
onSaved: async () => {
await this.plugin.saveSettings();
this.render();
},
onOpenSettings: () => {
this.close();
this.openSettingsTab();
},
});
return;
}
const profileLabel = kind === 'desktop' ? 'Desktop' : 'Mobile';
if (this.plugin.settings.templates.length === 0) {
contentEl.createEl('p', {
@ -83,6 +70,21 @@ export class ReWriteModal extends Modal {
this.renderTemplateSelector(contentEl);
this.renderTabBar(contentEl);
const tabBody = contentEl.createDiv({ cls: 'rewrite-tab-body' });
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') {
this.renderRecordTab(tabBody, profile.transcriptionProvider === 'webspeech', profile.transcriptionConfig.language);
} else {
@ -90,6 +92,28 @@ export class ReWriteModal extends Modal {
}
}
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 renderTemplateSelector(parent: HTMLElement): void {
const wrap = parent.createDiv({ cls: 'rewrite-template-row' });
wrap.createEl('label', { text: 'Template' });
@ -112,8 +136,10 @@ export class ReWriteModal extends Modal {
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 paste.addClass('is-active');
else if (this.activeTab === 'paste') paste.addClass('is-active');
else fromNote.addClass('is-active');
record.addEventListener('click', () => {
this.activeTab = 'record';
this.render();
@ -122,6 +148,10 @@ export class ReWriteModal extends Modal {
this.activeTab = 'paste';
this.render();
});
fromNote.addEventListener('click', () => {
this.activeTab = 'fromNote';
this.render();
});
}
private renderRecordTab(parent: HTMLElement, isWebSpeech: boolean, language: string): void {
@ -204,6 +234,34 @@ export class ReWriteModal extends Modal {
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', () => {
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;
}
void this.execute({ kind: 'text', text: fresh.text });
});
}
private async beginCapture(
isWebSpeech: boolean,
language: string,

View file

@ -26,6 +26,10 @@ export function isProfileConfigured(profile: EnvironmentProfile): boolean {
if (!profile.transcriptionConfig.apiKey) return false;
if (tx === 'openai-compatible' && !profile.transcriptionConfig.baseUrl.trim()) return false;
}
return isProfileConfiguredForText(profile);
}
export function isProfileConfiguredForText(profile: EnvironmentProfile): boolean {
if (!profile.llmConfig.model) return false;
if (!profile.llmConfig.apiKey) return false;
if (profile.llmProvider === 'openai-compatible' && !profile.llmConfig.baseUrl.trim()) return false;
@ -36,60 +40,66 @@ export interface SetupCardParams {
container: HTMLElement;
profile: EnvironmentProfile;
profileLabel: string;
purpose?: 'voice' | 'text';
onSaved: () => Promise<void>;
onOpenSettings: () => void;
}
export function renderSetupCard(params: SetupCardParams): void {
const { container, profile, profileLabel } = params;
const purpose = params.purpose ?? 'voice';
const card = container.createDiv({ cls: 'rewrite-setup-card' });
card.createEl('h3', { text: 'Setup required' });
card.createEl('p', {
text: `Your ${profileLabel} profile needs a transcription provider and an LLM. Fill in the basics here or open settings for full configuration.`,
text: purpose === 'text'
? `Your ${profileLabel} profile needs an LLM provider to process text. Fill in the basics here or open settings.`
: `Your ${profileLabel} profile needs a transcription provider and an LLM. Fill in the basics here or open settings for full configuration.`,
});
new Setting(card)
.setName('Transcription provider')
.addDropdown((dd) => {
for (const opt of TRANSCRIPTION_OPTIONS) dd.addOption(opt.id, opt.label);
dd.setValue(profile.transcriptionProvider);
dd.onChange((v) => {
profile.transcriptionProvider = v as TranscriptionProviderID;
profile.transcriptionConfig.apiKey = '';
container.empty();
renderSetupCard(params);
});
});
if (profile.transcriptionProvider !== 'webspeech') {
if (purpose === 'voice') {
new Setting(card)
.setName('Transcription model')
.setDesc(modelPlaceholderForTranscription(profile.transcriptionProvider))
.addText((t) => {
t.setValue(profile.transcriptionConfig.model);
t.onChange((v) => {
profile.transcriptionConfig.model = v;
.setName('Transcription provider')
.addDropdown((dd) => {
for (const opt of TRANSCRIPTION_OPTIONS) dd.addOption(opt.id, opt.label);
dd.setValue(profile.transcriptionProvider);
dd.onChange((v) => {
profile.transcriptionProvider = v as TranscriptionProviderID;
profile.transcriptionConfig.apiKey = '';
container.empty();
renderSetupCard(params);
});
});
if (profile.transcriptionProvider === 'openai-compatible') {
if (profile.transcriptionProvider !== 'webspeech') {
new Setting(card)
.setName('Transcription base URL')
.setDesc('e.g. http://localhost:8080 (whisper.cpp, faster-whisper-server)')
.setName('Transcription model')
.setDesc(modelPlaceholderForTranscription(profile.transcriptionProvider))
.addText((t) => {
t.setValue(profile.transcriptionConfig.baseUrl);
t.setValue(profile.transcriptionConfig.model);
t.onChange((v) => {
profile.transcriptionConfig.baseUrl = v;
profile.transcriptionConfig.model = v;
});
});
}
renderApiKeyField(card, {
label: 'Transcription API key',
placeholder: 'Saved securely on this device',
getValue: () => profile.transcriptionConfig.apiKey,
setValue: (v) => { profile.transcriptionConfig.apiKey = v; },
});
if (profile.transcriptionProvider === 'openai-compatible') {
new Setting(card)
.setName('Transcription base URL')
.setDesc('e.g. http://localhost:8080 (whisper.cpp, faster-whisper-server)')
.addText((t) => {
t.setValue(profile.transcriptionConfig.baseUrl);
t.onChange((v) => {
profile.transcriptionConfig.baseUrl = v;
});
});
}
renderApiKeyField(card, {
label: 'Transcription API key',
placeholder: 'Saved securely on this device',
getValue: () => profile.transcriptionConfig.apiKey,
setValue: (v) => { profile.transcriptionConfig.apiKey = v; },
});
}
}
new Setting(card)

50
src/ui/template-picker.ts Normal file
View file

@ -0,0 +1,50 @@
import { App, Modal } from 'obsidian';
import { NoteTemplate } from '../types';
export interface TemplatePickerParams {
app: App;
templates: NoteTemplate[];
defaultTemplateId: string;
previewText: string;
onPick: (template: NoteTemplate) => void;
}
export class TemplatePickerModal extends Modal {
constructor(private readonly params: TemplatePickerParams) {
super(params.app);
}
onOpen(): void {
this.modalEl.addClass('rewrite-modal');
const { contentEl } = this;
contentEl.createEl('h2', { text: 'Pick a template' });
if (this.params.previewText) {
contentEl.createEl('p', {
text: this.params.previewText,
cls: 'rewrite-template-picker-preview',
});
}
if (this.params.templates.length === 0) {
contentEl.createEl('p', { text: 'No templates configured. Add one in settings.' });
return;
}
const list = contentEl.createDiv({ cls: 'rewrite-template-picker-list' });
for (const template of this.params.templates) {
const item = list.createEl('button', {
text: template.name || '(unnamed)',
cls: 'rewrite-template-picker-item',
});
if (template.id === this.params.defaultTemplateId) item.addClass('mod-cta');
item.addEventListener('click', () => {
this.close();
this.params.onPick(template);
});
}
}
onClose(): void {
this.contentEl.empty();
}
}

56
src/ui/text-source.ts Normal file
View file

@ -0,0 +1,56 @@
import { App, Editor, MarkdownView, Notice } from 'obsidian';
import type ReWritePlugin from '../main';
import { NoteTemplate } from '../types';
import { resolveActiveProfile } from '../platform';
import { runPipeline } from '../pipeline';
import { isProfileConfiguredForText } from './setup-card';
import { ReWriteModal } from './modal';
export interface TextResolution {
text: string;
scope: 'selection' | 'note';
}
export function resolveTextFromEditor(editor: Editor): TextResolution {
const selection = editor.getSelection();
if (selection) return { text: selection, scope: 'selection' };
return { text: editor.getValue(), scope: 'note' };
}
export function resolveActiveTextSource(app: App): TextResolution | null {
const view = app.workspace.getActiveViewOfType(MarkdownView);
if (!view) return null;
return resolveTextFromEditor(view.editor);
}
export async function runTextPipeline(
plugin: ReWritePlugin,
template: NoteTemplate,
text: string,
): Promise<void> {
const settings = plugin.settings;
const { profile } = resolveActiveProfile(settings);
if (!isProfileConfiguredForText(profile)) {
new Notice('ReWrite: configure an LLM provider before processing text.');
new ReWriteModal(plugin.app, plugin).open();
return;
}
const progress = new Notice('ReWrite: processing text...', 0);
try {
await runPipeline({
app: plugin.app,
settings,
profile,
template,
source: { kind: 'text', text },
});
progress.hide();
plugin.settings.lastUsedTemplateId = template.id;
await plugin.saveSettings();
new Notice('ReWrite complete.');
} catch (e) {
progress.hide();
const message = e instanceof Error ? e.message : String(e);
new Notice(`ReWrite: ${message}`);
}
}