diff --git a/bindery-merge/test/tool-locate.test.ts b/bindery-merge/test/tool-locate.test.ts new file mode 100644 index 0000000..945da1c --- /dev/null +++ b/bindery-merge/test/tool-locate.test.ts @@ -0,0 +1,159 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { clearLocateCache, locateTool, locateToolPath } from '../src/tool-locate'; + +const tempDirs: string[] = []; + +function makeDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-merge-locate-test-')); + tempDirs.push(dir); + return dir; +} + +beforeEach(() => { + clearLocateCache(); +}); + +afterEach(() => { + clearLocateCache(); + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('locateTool', () => { + it('prefers explicit setting when the file exists', () => { + const dir = makeDir(); + const fakePandoc = path.join(dir, process.platform === 'win32' ? 'pandoc.exe' : 'pandoc'); + fs.writeFileSync(fakePandoc, ''); + + const result = locateTool('pandoc', fakePandoc); + expect(result.path).toBe(fakePandoc); + expect(result.source).toBe('setting'); + }); + + it('returns the literal setting even when the file is missing (surfaces clear error to user)', () => { + const dir = makeDir(); + const missing = path.join(dir, 'does-not-exist.exe'); + + const result = locateTool('pandoc', missing); + expect(result.path).toBe(missing); + expect(result.source).toBe('setting'); + }); + + it('treats empty string and default command as "unset"', () => { + const r1 = locateTool('pandoc', ''); + const r2 = locateTool('pandoc', 'pandoc'); + expect(['path', 'default', 'fallback']).toContain(r1.source); + expect(['path', 'default', 'fallback']).toContain(r2.source); + }); + + it('falls back to the command name when nothing is found', () => { + const result = locateTool('libreoffice', undefined); + expect(typeof result.path).toBe('string'); + expect(result.path.length).toBeGreaterThan(0); + }); + + it('locateToolPath returns the same string as locateTool.path', () => { + const setting = 'pandoc'; + expect(locateToolPath('pandoc', setting)).toBe(locateTool('pandoc', setting).path); + }); + + it('caches repeated lookups', () => { + const dir = makeDir(); + const fake = path.join(dir, process.platform === 'win32' ? 'pandoc.exe' : 'pandoc'); + fs.writeFileSync(fake, ''); + + const r1 = locateTool('pandoc', fake); + fs.rmSync(fake); + const r2 = locateTool('pandoc', fake); + expect(r2.path).toBe(r1.path); + }); + + it('clearLocateCache forces fresh resolution on next call', () => { + const dir = makeDir(); + const fake = path.join(dir, process.platform === 'win32' ? 'pandoc.exe' : 'pandoc'); + fs.writeFileSync(fake, ''); + + locateTool('pandoc', fake); + clearLocateCache(); + const result = locateTool('pandoc', fake); + expect(result.path).toBe(fake); + expect(result.source).toBe('setting'); + }); + + it('resolves libreoffice with explicit setting', () => { + const dir = makeDir(); + const fakePath = path.join(dir, process.platform === 'win32' ? 'soffice.exe' : 'soffice'); + fs.writeFileSync(fakePath, ''); + + const result = locateTool('libreoffice', fakePath); + expect(result.path).toBe(fakePath); + expect(result.source).toBe('setting'); + }); + + it('treats "libreoffice" as default setting (triggers auto-detect)', () => { + const result = locateTool('libreoffice', 'libreoffice'); + expect(['path', 'default', 'fallback']).toContain(result.source); + }); + + it('treats "soffice" as default setting for libreoffice (triggers auto-detect)', () => { + const result = locateTool('libreoffice', 'soffice'); + expect(['path', 'default', 'fallback']).toContain(result.source); + }); + + it('returns fallback for pandoc when no setting and nothing on PATH or well-known', () => { + const result = locateTool('pandoc', undefined); + expect(typeof result.path).toBe('string'); + expect(result.path.length).toBeGreaterThan(0); + expect(['path', 'default', 'fallback']).toContain(result.source); + }); + + it('returns setting source when path exists but is NOT a known default', () => { + const dir = makeDir(); + const customPath = path.join(dir, 'my-custom-pandoc'); + fs.writeFileSync(customPath, ''); + + const result = locateTool('pandoc', customPath); + expect(result.path).toBe(customPath); + expect(result.source).toBe('setting'); + }); + + it('returns setting source even for non-existent custom path (to surface clear error)', () => { + const result = locateTool('pandoc', '/some/custom/path/to/pandoc'); + expect(result.path).toBe('/some/custom/path/to/pandoc'); + expect(result.source).toBe('setting'); + }); + + it('cache uses different entries for pandoc vs libreoffice', () => { + const dir = makeDir(); + const fakePandoc = path.join(dir, 'custom-pandoc'); + const fakeSoffice = path.join(dir, 'custom-soffice'); + fs.writeFileSync(fakePandoc, ''); + fs.writeFileSync(fakeSoffice, ''); + + const pandocResult = locateTool('pandoc', fakePandoc); + const libreResult = locateTool('libreoffice', fakeSoffice); + + expect(pandocResult.path).toBe(fakePandoc); + expect(libreResult.path).toBe(fakeSoffice); + expect(pandocResult.path).not.toBe(libreResult.path); + }); + + it('re-resolves when setting changes from default to explicit', () => { + locateTool('pandoc', ''); + + clearLocateCache(); + + const dir = makeDir(); + const explicit = path.join(dir, 'explicit-pandoc'); + fs.writeFileSync(explicit, ''); + const result = locateTool('pandoc', explicit); + + expect(result.path).toBe(explicit); + expect(result.source).toBe('setting'); + }); +}); diff --git a/obsidian-plugin/src/main.ts b/obsidian-plugin/src/main.ts index de2b4e3..2fae37f 100644 --- a/obsidian-plugin/src/main.ts +++ b/obsidian-plugin/src/main.ts @@ -14,10 +14,10 @@ import type { OutputType } from '@bindery/merge'; import { mergeBook } from './merge'; import { formatFile } from './formatter'; import { exportBook, resolveBookRoot } from './exporter'; -import { setupAiFiles, type AiTarget, ALL_SKILLS } from './ai-setup'; -import { readSettings, writeSettings, readTranslations, addDialectRule, addTranslationEntry, addLanguage, findProbableUsWords } from './workspace'; +import { setupAiFiles, ALL_SKILLS } from './ai-setup'; +import { readSettings, addDialectRule, addTranslationEntry, addLanguage, findProbableUsWords } from './workspace'; import { BinderySettingsTab, DEFAULT_SETTINGS, type BinderySettings } from './settings-tab'; -import { Plugin } from 'obsidian'; +import { Notice, Plugin } from 'obsidian'; import type { Editor, TFile } from 'obsidian'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -28,6 +28,50 @@ const REVIEW_STOP_MARKER = ''; export default class BinderyPlugin extends Plugin { settings: BinderySettings = { ...DEFAULT_SETTINGS }; + private notify(message: string): void { + // Prefer user-facing notices to console logs to keep the developer console clean. + new Notice(message); + } + + private addContextMenuItems(menu: unknown, forEditor: boolean): void { + const m = menu as { + addSeparator?: () => void; + addItem: (cb: (item: { + setTitle: (title: string) => unknown; + setIcon?: (icon: string) => unknown; + onClick: (fn: () => void) => unknown; + }) => void) => void; + }; + + m.addSeparator?.(); + + m.addItem((item) => { + item.setTitle('Bindery: Merge chapters to all formats'); + item.setIcon?.('book-open'); + item.onClick(() => { void this.mergeBook(['md', 'docx', 'epub', 'pdf']); }); + }); + + if (forEditor) { + m.addItem((item) => { + item.setTitle('Bindery: Format document'); + item.setIcon?.('wand'); + item.onClick(() => { void this.formatActive(); }); + }); + } + + m.addItem((item) => { + item.setTitle('Bindery: Find probable US to UK words'); + item.setIcon?.('languages'); + item.onClick(() => { void this.findUsToUkCommand(); }); + }); + + m.addItem((item) => { + item.setTitle('Bindery: Generate AI assistant files'); + item.setIcon?.('sparkles'); + item.onClick(() => { void this.setupAiCommand(); }); + }); + } + private getVaultBasePath(): string { const basePath = this.app.vault.adapter?.basePath; if (typeof basePath !== 'string' || !basePath.trim()) { @@ -151,6 +195,27 @@ export default class BinderyPlugin extends Plugin { callback: () => void this.copyMcpSnippet(), }); + this.addRibbonIcon('book-open', 'Bindery: Merge chapters to all formats', () => { + void this.mergeBook(['md', 'docx', 'epub', 'pdf']); + }); + this.addRibbonIcon('wand', 'Bindery: Format active note', () => { + void this.formatActive(); + }); + this.addRibbonIcon('languages', 'Bindery: Find probable US to UK words', () => { + void this.findUsToUkCommand(); + }); + + // Right-click menus in editor and file explorer. + const workspaceOn = this.app.workspace?.on; + if (workspaceOn) { + this.registerEvent(workspaceOn.call(this.app.workspace, 'editor-menu', (menu: unknown) => { + this.addContextMenuItems(menu, true); + })); + this.registerEvent(workspaceOn.call(this.app.workspace, 'file-menu', (menu: unknown) => { + this.addContextMenuItems(menu, false); + })); + } + this.addSettingTab(new BinderySettingsTab(this.app, this)); } @@ -159,7 +224,6 @@ export default class BinderyPlugin extends Plugin { const vaultPath = this.getVaultBasePath(); const bookRoot = resolveBookRoot(vaultPath, this.settings.bookRoot); - console.log('Bindery: Merging chapters…'); const result = await mergeBook( this.app, this.app.vault, @@ -170,25 +234,27 @@ export default class BinderyPlugin extends Plugin { ); const names = result.outputs.map((p: string) => path.basename(p)).join(', '); - console.log(`✓ Merged → ${names}`); + this.notify(`Merged: ${names}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`✗ Merge failed: ${message}`); + this.notify(`Merge failed: ${message}`); } } private async formatActive(): Promise { const file = this.app.workspace?.getActiveFile?.(); - if (!file || file.extension !== 'md') { - console.log('No markdown file active'); + if (file?.extension !== 'md') { + this.notify('No markdown file active'); return; } try { await formatFile(this.app.vault, file); - console.log('✓ Formatted'); + this.notify('Formatted active note'); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`✗ Format failed: ${message}`); + this.notify(`Format failed: ${message}`); } } @@ -199,10 +265,11 @@ export default class BinderyPlugin extends Plugin { const result = await setupAiFiles(this.app, bookRoot, ['claude', 'copilot'], ALL_SKILLS, false); const msg = `Generated: ${result.created.length}, Skipped: ${result.skipped.length}`; - console.log(`✓ AI setup: ${msg}`); + this.notify(`AI setup complete. ${msg}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`✗ AI setup failed: ${message}`); + this.notify(`AI setup failed: ${message}`); } } @@ -221,10 +288,11 @@ export default class BinderyPlugin extends Plugin { if (!to) return; addDialectRule(bookRoot, language, from, to); - console.log(`✓ Added dialect rule: ${from} → ${to}`); + this.notify(`Added dialect rule: ${from} -> ${to}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`✗ Failed: ${message}`); + this.notify(`Action failed: ${message}`); } } @@ -245,10 +313,11 @@ export default class BinderyPlugin extends Plugin { } addTranslationEntry(bookRoot, term, translations); - console.log(`✓ Added translation entry: ${term}`); + this.notify(`Added translation entry: ${term}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`✗ Failed: ${message}`); + this.notify(`Action failed: ${message}`); } } @@ -264,10 +333,11 @@ export default class BinderyPlugin extends Plugin { if (!folderName) return; addLanguage(bookRoot, code, folderName); - console.log(`✓ Added language: ${code}`); + this.notify(`Added language: ${code}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`✗ Failed: ${message}`); + this.notify(`Action failed: ${message}`); } } @@ -278,14 +348,15 @@ export default class BinderyPlugin extends Plugin { const translationsPath = path.join(bookRoot, '.bindery', 'translations.json'); if (!fs.existsSync(translationsPath)) { - console.log('translations.json not found'); + this.notify('translations.json not found'); return; } - console.log('Translations file:', translationsPath); + this.notify(`Translations file: ${translationsPath}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`✗ Failed: ${message}`); + this.notify(`Action failed: ${message}`); } } @@ -293,7 +364,7 @@ export default class BinderyPlugin extends Plugin { try { const file = this.app.workspace?.getActiveFile?.(); if (!file) { - console.log('No file active'); + this.notify('No file active'); return; } @@ -301,21 +372,22 @@ export default class BinderyPlugin extends Plugin { const words = findProbableUsWords(content); if (words.length === 0) { - console.log('No probable US words found'); + this.notify('No probable US words found'); return; } const list = words.slice(0, 10).join(', ') + (words.length > 10 ? `, +${words.length - 10} more` : ''); - console.log(`Found: ${list}`); + this.notify(`Found: ${list}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`✗ Failed: ${message}`); + this.notify(`Action failed: ${message}`); } } private async promptString(prompt: string, defaultValue: string = ''): Promise { return new Promise((resolve) => { - const result = window.prompt(prompt, defaultValue); + const result = globalThis.prompt(prompt, defaultValue); resolve(result); }); } @@ -395,8 +467,7 @@ export default class BinderyPlugin extends Plugin { await clipboard.writeText(snippet); return; } - // Fallback when clipboard API is unavailable in runtime/tests. - console.log(snippet); + this.notify('Clipboard unavailable; unable to copy MCP snippet.'); } showMcpSnippet(): string { diff --git a/obsidian-plugin/src/obsidian.d.ts b/obsidian-plugin/src/obsidian.d.ts index 573fd00..6e7ad6c 100644 --- a/obsidian-plugin/src/obsidian.d.ts +++ b/obsidian-plugin/src/obsidian.d.ts @@ -35,9 +35,14 @@ declare module 'obsidian' { vault: Vault; workspace?: { getActiveFile(): TFile | null; + on(event: string, callback: (...args: unknown[]) => unknown): EventRef; }; } + export class Notice { + constructor(message: string, timeout?: number); + } + export interface Command { id: string; name: string; @@ -50,6 +55,7 @@ declare module 'obsidian' { constructor(app: App); loadData(): Promise; saveData(data: unknown): Promise; + addRibbonIcon(icon: string, title: string, callback: () => void): HTMLElement; addCommand(command: Command): void; addSettingTab(tab: unknown): void; registerEvent(eventRef: EventRef): void; diff --git a/obsidian-plugin/test/main.test.ts b/obsidian-plugin/test/main.test.ts index 84ee4ab..bcfb80e 100644 --- a/obsidian-plugin/test/main.test.ts +++ b/obsidian-plugin/test/main.test.ts @@ -10,6 +10,16 @@ import * as fs from 'node:fs'; // Must be declared before any imports that pull in obsidian transitively. vi.mock('obsidian', () => { + class Notice { + message: string; + timeout?: number; + + constructor(message: string, timeout?: number) { + this.message = message; + this.timeout = timeout; + } + } + class Plugin { app: App; @@ -32,16 +42,20 @@ vi.mock('obsidian', () => { editorCallback?: (editor: unknown) => void; }): void { return; } + addRibbonIcon(_icon: string, _title: string, _callback: () => void): HTMLElement { + return {} as HTMLElement; + } + addSettingTab(_tab: unknown): void { return; } registerEvent(_eventRef: unknown): void { return; } } - return { Plugin }; + return { Plugin, Notice }; }); import BinderyPlugin from '../src/main'; -import type { App, Vault, Command, Editor } from 'obsidian'; +import type { App, Vault, Editor } from 'obsidian'; // ─── Mock helpers ───────────────────────────────────────────────────────────── @@ -53,7 +67,13 @@ function makeApp(vaultPath: string, vaultName = 'TestVault'): App { on: vi.fn().mockReturnValue({}), adapter: { basePath: vaultPath }, } as unknown as Vault; - return { vault }; + return { + vault, + workspace: { + getActiveFile: () => null, + on: vi.fn().mockReturnValue({}), + }, + } as unknown as App; } function makeEditor(lineText: string, cursorCh: number, selection = ''): Editor { @@ -238,13 +258,14 @@ describe('review marker commands', () => { const app = makeApp('/vault', 'MyVault'); app.workspace = { getActiveFile: () => ({ path: 'Story/ch1.md', extension: 'md', name: 'ch1.md', basename: 'ch1' }), + on: vi.fn().mockReturnValue({}), }; const bp = new BinderyPlugin(app); const addCommandSpy = vi.spyOn(bp, 'addCommand'); await bp.onload(); - const commands = addCommandSpy.mock.calls.map((call) => call[0] as Command); + const commands = addCommandSpy.mock.calls.map((call) => call[0]); const ids = commands.map((c) => c.id); expect(ids).toContain('start-review-marker'); expect(ids).toContain('stop-review-marker'); @@ -254,12 +275,13 @@ describe('review marker commands', () => { const app = makeApp('/vault', 'MyVault'); app.workspace = { getActiveFile: () => ({ path: 'Story/ch1.md', extension: 'md', name: 'ch1.md', basename: 'ch1' }), + on: vi.fn().mockReturnValue({}), }; const bp = new BinderyPlugin(app); const addCommandSpy = vi.spyOn(bp, 'addCommand'); await bp.onload(); - const commands = addCommandSpy.mock.calls.map((call) => call[0] as Command); + const commands = addCommandSpy.mock.calls.map((call) => call[0]); const start = commands.find((c) => c.id === 'start-review-marker'); const editor = makeEditor('abc', 0, ''); @@ -275,12 +297,13 @@ describe('review marker commands', () => { const app = makeApp('/vault', 'MyVault'); app.workspace = { getActiveFile: () => ({ path: 'Story/ch1.md', extension: 'md', name: 'ch1.md', basename: 'ch1' }), + on: vi.fn().mockReturnValue({}), }; const bp = new BinderyPlugin(app); const addCommandSpy = vi.spyOn(bp, 'addCommand'); await bp.onload(); - const commands = addCommandSpy.mock.calls.map((call) => call[0] as Command); + const commands = addCommandSpy.mock.calls.map((call) => call[0]); const start = commands.find((c) => c.id === 'start-review-marker'); const editor = makeEditor('abc', 0, 'X'); @@ -295,12 +318,13 @@ describe('review marker commands', () => { const app = makeApp('/vault', 'MyVault'); app.workspace = { getActiveFile: () => ({ path: 'Story/ch1.md', extension: 'md', name: 'ch1.md', basename: 'ch1' }), + on: vi.fn().mockReturnValue({}), }; const bp = new BinderyPlugin(app); const addCommandSpy = vi.spyOn(bp, 'addCommand'); await bp.onload(); - const commands = addCommandSpy.mock.calls.map((call) => call[0] as Command); + const commands = addCommandSpy.mock.calls.map((call) => call[0]); const stop = commands.find((c) => c.id === 'stop-review-marker'); const editor = makeEditor('abc', 0, ''); @@ -316,12 +340,13 @@ describe('review marker commands', () => { const app = makeApp('/vault', 'MyVault'); app.workspace = { getActiveFile: () => ({ path: 'Story/ch1.txt', extension: 'txt', name: 'ch1.txt', basename: 'ch1' }), + on: vi.fn().mockReturnValue({}), }; const bp = new BinderyPlugin(app); const addCommandSpy = vi.spyOn(bp, 'addCommand'); await bp.onload(); - const commands = addCommandSpy.mock.calls.map((call) => call[0] as Command); + const commands = addCommandSpy.mock.calls.map((call) => call[0]); const start = commands.find((c) => c.id === 'start-review-marker'); const editor = makeEditor('abc', 0, ''); @@ -335,12 +360,13 @@ describe('review marker commands', () => { const app = makeApp('/vault', 'MyVault'); app.workspace = { getActiveFile: () => ({ path: 'Story/ch1.txt', extension: 'txt', name: 'ch1.txt', basename: 'ch1' }), + on: vi.fn().mockReturnValue({}), }; const bp = new BinderyPlugin(app); const addCommandSpy = vi.spyOn(bp, 'addCommand'); await bp.onload(); - const commands = addCommandSpy.mock.calls.map((call) => call[0] as Command); + const commands = addCommandSpy.mock.calls.map((call) => call[0]); const stop = commands.find((c) => c.id === 'stop-review-marker'); const editor = makeEditor('abc', 0, ''); @@ -353,12 +379,13 @@ describe('review marker commands', () => { const app = makeApp('/vault', 'MyVault'); app.workspace = { getActiveFile: () => ({ path: 'Story/ch1.md', extension: 'md', name: 'ch1.md', basename: 'ch1' }), + on: vi.fn().mockReturnValue({}), }; const bp = new BinderyPlugin(app); const addCommandSpy = vi.spyOn(bp, 'addCommand'); await bp.onload(); - const commands = addCommandSpy.mock.calls.map((call) => call[0] as Command); + const commands = addCommandSpy.mock.calls.map((call) => call[0]); const stop = commands.find((c) => c.id === 'stop-review-marker'); // Cursor at end of 'abc' (ch=3 === lineText.length=3) const editor = makeEditor('abc', 3, ''); @@ -375,12 +402,13 @@ describe('review marker commands', () => { const app = makeApp('/vault', 'MyVault'); app.workspace = { getActiveFile: () => ({ path: 'Story/ch1.md', extension: 'md', name: 'ch1.md', basename: 'ch1' }), + on: vi.fn().mockReturnValue({}), }; const bp = new BinderyPlugin(app); const addCommandSpy = vi.spyOn(bp, 'addCommand'); await bp.onload(); - const commands = addCommandSpy.mock.calls.map((call) => call[0] as Command); + const commands = addCommandSpy.mock.calls.map((call) => call[0]); const start = commands.find((c) => c.id === 'start-review-marker'); // Cursor at end of 'abc' (ch=3 === lineText.length=3) const editor = makeEditor('abc', 3, ''); @@ -393,3 +421,32 @@ describe('review marker commands', () => { ); }); }); + +describe('ribbon actions', () => { + it('registers ribbon shortcuts for merge, format, and word scan', async () => { + const app = makeApp('/vault', 'MyVault'); + const bp = new BinderyPlugin(app); + const ribbonSpy = vi.spyOn(bp, 'addRibbonIcon'); + + await bp.onload(); + + const titles = ribbonSpy.mock.calls.map((call) => call[1]); + expect(titles).toContain('Bindery: Merge chapters to all formats'); + expect(titles).toContain('Bindery: Format active note'); + expect(titles).toContain('Bindery: Find probable US to UK words'); + }); +}); + +describe('context menu actions', () => { + it('registers editor-menu and file-menu hooks', async () => { + const app = makeApp('/vault', 'MyVault'); + const bp = new BinderyPlugin(app); + + await bp.onload(); + + const workspaceOn = app.workspace?.on as ReturnType; + const events = workspaceOn.mock.calls.map((c: unknown[]) => c[0]); + expect(events).toContain('editor-menu'); + expect(events).toContain('file-menu'); + }); +}); diff --git a/obsidian-plugin/versions.json b/obsidian-plugin/versions.json new file mode 100644 index 0000000..cdffaed --- /dev/null +++ b/obsidian-plugin/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.4.0" +} diff --git a/vscode-ext/package.json b/vscode-ext/package.json index b7e4001..6a2494a 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -157,6 +157,11 @@ "title": "Setup AI Assistant Files", "category": "Bindery" }, + { + "command": "bindery.quickActions", + "title": "Quick Actions", + "category": "Bindery" + }, { "command": "bindery.formatDocument", "title": "Format Typography", @@ -1001,6 +1006,11 @@ } ], "editor/title": [ + { + "command": "bindery.quickActions", + "when": "resourceLangId == markdown", + "group": "navigation@95" + }, { "submenu": "bindery.exportMenu", "when": "resourceLangId == markdown", diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 99c3836..2872be8 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -865,6 +865,36 @@ async function findProbableUsToUkWordsCommand() { } } +// ─── Command: Quick actions (status-bar ribbon entry point) ───────────────── + +async function quickActionsCommand() { + const picked = await vscode.window.showQuickPick( + [ + { label: 'Merge chapters → All formats', command: 'bindery.mergeAll' }, + { label: 'Merge chapters → Markdown', command: 'bindery.mergeMarkdown' }, + { label: 'Merge chapters → DOCX', command: 'bindery.mergeDocx' }, + { label: 'Merge chapters → EPUB', command: 'bindery.mergeEpub' }, + { label: 'Merge chapters → PDF', command: 'bindery.mergePdf' }, + { label: 'Format document', command: 'bindery.formatDocument' }, + { label: 'Format folder', command: 'bindery.formatFolder' }, + { label: 'Setup AI assistant files', command: 'bindery.setupAI' }, + { label: 'Find probable US to UK words', command: 'bindery.findProbableUsToUkWords' }, + { label: 'Add dialect rule', command: 'bindery.addDialect' }, + { label: 'Add translation (glossary)', command: 'bindery.addTranslation' }, + { label: 'Add language', command: 'bindery.addLanguage' }, + { label: 'Open translations.json', command: 'bindery.openTranslations' }, + { label: 'Initialize workspace', command: 'bindery.init' }, + { label: 'Register MCP server', command: 'bindery.registerMcp' }, + ], + { + title: 'Bindery quick actions', + placeHolder: 'Choose an action', + } + ); + if (!picked) { return; } + await vscode.commands.executeCommand(picked.command); +} + // ─── Merge helpers ──────────────────────────────────────────────────────────── function buildMergeOptions( @@ -1298,6 +1328,7 @@ export function activate(context: vscode.ExtensionContext) { subscriptions.push( vscode.commands.registerCommand('bindery.init', () => initWorkspaceCommand(context)), vscode.commands.registerCommand('bindery.setupAI', () => setupAiCommand(context)), + vscode.commands.registerCommand('bindery.quickActions', quickActionsCommand), vscode.commands.registerCommand('bindery.formatDocument', formatDocumentCommand), vscode.commands.registerCommand('bindery.formatFolder', formatFolderCommand), vscode.commands.registerCommand('bindery.mergeMarkdown', () => mergeCommand(['md'])), @@ -1345,8 +1376,8 @@ export function activate(context: vscode.ExtensionContext) { // Status bar — shown when a markdown file is active const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); statusBar.text = '$(book) Bindery'; - statusBar.tooltip = 'Bindery: Merge Chapters → All Formats'; - statusBar.command = 'bindery.mergeAll'; + statusBar.tooltip = 'Bindery: Quick actions'; + statusBar.command = 'bindery.quickActions'; subscriptions.push(statusBar); const updateStatusBar = () => {