mirror of
https://github.com/evdboom/Bindery.git
synced 2026-07-22 06:49:36 +00:00
feat: add context menu and ribbon actions for quick access to common features
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
e4773fdf28
commit
e4e0003cb8
7 changed files with 370 additions and 33 deletions
159
bindery-merge/test/tool-locate.test.ts
Normal file
159
bindery-merge/test/tool-locate.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = '<!-- Bindery: Review stop -->';
|
|||
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<void> {
|
||||
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<string | null> {
|
||||
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 {
|
||||
|
|
|
|||
6
obsidian-plugin/src/obsidian.d.ts
vendored
6
obsidian-plugin/src/obsidian.d.ts
vendored
|
|
@ -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<unknown>;
|
||||
saveData(data: unknown): Promise<void>;
|
||||
addRibbonIcon(icon: string, title: string, callback: () => void): HTMLElement;
|
||||
addCommand(command: Command): void;
|
||||
addSettingTab(tab: unknown): void;
|
||||
registerEvent(eventRef: EventRef): void;
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>;
|
||||
const events = workspaceOn.mock.calls.map((c: unknown[]) => c[0]);
|
||||
expect(events).toContain('editor-menu');
|
||||
expect(events).toContain('file-menu');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
3
obsidian-plugin/versions.json
Normal file
3
obsidian-plugin/versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "1.4.0"
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue