From 0c6cd44d72867df28479b0d2d79db985a940ea15 Mon Sep 17 00:00:00 2001 From: WiseGuru <42100212+WiseGuru@users.noreply.github.com> Date: Sun, 24 May 2026 21:38:29 -0700 Subject: [PATCH] f4 --- CLAUDE.md | 17 ++++--- docs/FEATURES.md | 47 ++----------------- src/main.ts | 61 ++++++++++++++++++++++++- src/pipeline.ts | 4 +- src/ui/modal.ts | 96 +++++++++++++++++++++++++++++++-------- src/ui/setup-card.ts | 76 +++++++++++++++++-------------- src/ui/template-picker.ts | 50 ++++++++++++++++++++ src/ui/text-source.ts | 56 +++++++++++++++++++++++ 8 files changed, 304 insertions(+), 103 deletions(-) create mode 100644 src/ui/template-picker.ts create mode 100644 src/ui/text-source.ts diff --git a/CLAUDE.md b/CLAUDE.md index 562a534..4200f08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 412a9a7..e8cf4a8 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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 `
` "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. diff --git a/src/main.ts b/src/main.ts index 0c05881..2a6ff1d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 ?? ''; + } } diff --git a/src/pipeline.ts b/src/pipeline.ts index 821e983..f2706ee 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -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 { const source = params.source; switch (source.kind) { case 'paste': + case 'text': return source.text; case 'webspeech': return source.transcript; diff --git a/src/ui/modal.ts b/src/ui/modal.ts index a4cbf7e..c0d2a2c 100644 --- a/src/ui/modal.ts +++ b/src/ui/modal.ts @@ -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, diff --git a/src/ui/setup-card.ts b/src/ui/setup-card.ts index ea17284..858d1fa 100644 --- a/src/ui/setup-card.ts +++ b/src/ui/setup-card.ts @@ -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; 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) diff --git a/src/ui/template-picker.ts b/src/ui/template-picker.ts new file mode 100644 index 0000000..1d9cae7 --- /dev/null +++ b/src/ui/template-picker.ts @@ -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(); + } +} diff --git a/src/ui/text-source.ts b/src/ui/text-source.ts new file mode 100644 index 0000000..db2122e --- /dev/null +++ b/src/ui/text-source.ts @@ -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 { + 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}`); + } +}