feat: v4.6.0 — AI assistant, collaboration, knowledge base, and 15 more features

- AI Assistant plugin: multi-provider chat (OpenAI/Anthropic/Ollama/LM Studio),
  summarize/improve/translate commands, proofread via ai:analyze; calls
  proxied through main so API keys stay out of the renderer
- Collaboration plugin: anchor-based comments in .comments/ sidecars with
  drift detection and F8 navigation
- Local knowledge base: [[wiki-links]] with click-to-create + Backlinks panel
- Crash recovery: debounced session snapshots with restore prompt on launch
- Version history: pre-save snapshots, History panel with restore/diff/delete
- Real PDF encryption: swap pdf-lib for @cantoo/pdf-lib (probe-driven UI)
- XLSX export (native workbooks via JSZip), ODT headers/footers + page size
- Offline KaTeX (bundled CSS+fonts), local-first PlantUML rendering
- Editor: vim mode toggle, snippet Tab-expansion, zen word-goal setter,
  writing heatmap, writing-studio panels wired with rail icons
- Quick Note global scratchpad (Ctrl+Alt+Q), markdownconverter:// deep links,
  REPL first-run confirmation
- Fix: Ctrl+Shift+P collision, pandoc converter availability check, CLI
  dangling --css/--reference-doc flags, dead converter button

8 new test suites; 613 tests green; lint clean
This commit is contained in:
2026-09-05 20:48:39 +05:30
parent b83ba91731
commit c4dcbd8caf
105 changed files with 5695 additions and 195 deletions
+84
View File
@@ -0,0 +1,84 @@
/**
* AI Assistant prompt builder + proofread parser tests (pure module, jsdom
* not required but harmless under the default environment).
*/
const {
buildTaskPrompt,
buildProofreadPrompt,
parseProofreadIssues,
} = require('../src/plugins/built-in/ai-assistant/prompts');
describe('AI Assistant prompts', () => {
describe('buildTaskPrompt', () => {
it('builds a summarize prompt embedding the text', () => {
const { system, user } = buildTaskPrompt('summarize', 'the text here');
expect(system).toMatch(/summar/i);
expect(user).toContain('the text here');
});
it('passes the target language through for translate', () => {
const { user } = buildTaskPrompt('translate', 'bonjour', 'English');
expect(user).toContain('English');
expect(user).toContain('bonjour');
});
it('throws on unknown actions', () => {
expect(() => buildTaskPrompt('nope', 'x')).toThrow(/Unknown AI task/);
});
it('tolerates missing text', () => {
expect(buildTaskPrompt('explain', undefined).user).toBe('Explain the following:\n\n');
});
});
describe('buildProofreadPrompt', () => {
it('asks for a strict JSON array', () => {
const { system, user } = buildProofreadPrompt('Some text.');
expect(system).toMatch(/JSON array/);
expect(user).toContain('Some text.');
});
});
describe('parseProofreadIssues', () => {
it('parses a clean JSON array', () => {
const issues = parseProofreadIssues(
'[{"type":"Spelling","message":"teh","suggestion":"the"}]'
);
expect(issues).toEqual([{ type: 'spelling', message: 'teh', suggestion: 'the' }]);
});
it('strips code fences and surrounding prose', () => {
const issues = parseProofreadIssues(
'Here you go:\n```json\n[{"type":"grammar","message":"run on","suggestion":"split"}]\n```\nDone!'
);
expect(issues).toHaveLength(1);
expect(issues[0].suggestion).toBe('split');
});
it('tolerates trailing commas', () => {
const issues = parseProofreadIssues(
'[{"type":"style","message":"wordy","suggestion":"cut",},]'
);
expect(issues).toHaveLength(1);
});
it('returns [] for empty/garbage input instead of throwing', () => {
expect(parseProofreadIssues('')).toEqual([]);
expect(parseProofreadIssues('no json at all')).toEqual([]);
expect(parseProofreadIssues('{"not":"an array"}')).toEqual([]);
expect(parseProofreadIssues(null)).toEqual([]);
});
it('drops malformed entries and caps the list length', () => {
const many = Array.from({ length: 300 }, (_, i) => ({
type: 'grammar',
message: `m${i}`,
suggestion: 's',
}));
expect(parseProofreadIssues(JSON.stringify(many))).toHaveLength(100);
const mixed = parseProofreadIssues('[{"message":"ok","suggestion":"x"},null,42,{"foo":1}]');
// null/number entries dropped; {foo:1} dropped for lacking message+ suggestion
expect(mixed).toHaveLength(1);
});
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* Collaboration comment-store tests — pure logic with in-memory IO fakes,
* covering persistence, navigation ordering, and anchor drift detection.
*/
const path = require('path');
const store = require('../src/plugins/built-in/collaboration/comment-store');
/** In-memory IO fake shaped like the plugin's IPC adapters. */
function makeIo(initial = {}) {
const files = new Map(Object.entries(initial));
return {
files,
readFile: (p) => (files.has(p) ? files.get(p) : null),
writeFile: (p, c) => {
files.set(p, c);
},
fileExists: (p) => files.has(p),
ensureDirectory: () => {},
};
}
describe('comment-store', () => {
const doc = path.join('/docs', 'notes.md');
const sidecar = path.join('/docs', '.comments', 'notes.md.json');
describe('commentsFilePathFor', () => {
it('places the sidecar in .comments/ next to the document', () => {
expect(store.commentsFilePathFor(doc, path)).toBe(sidecar);
expect(store.commentsFilePathFor('/a/b/c.md', path)).toBe(
path.join('/a/b/.comments', 'c.md.json')
);
});
});
describe('load/save round-trip', () => {
it('returns [] for documents with no sidecar yet', () => {
expect(store.loadComments(doc, makeIo(), path)).toEqual([]);
});
it('returns [] for corrupt JSON instead of throwing', () => {
const io = makeIo({ [sidecar]: '{oops' });
expect(store.loadComments(doc, io, path)).toEqual([]);
});
it('persists and reloads comments sorted by line', () => {
const io = makeIo();
const comments = [];
store.addComment(comments, { line: 12, anchorText: 'para two', text: 'expand this' });
store.addComment(comments, { line: 3, anchorText: 'intro', text: 'strong opener' });
store.saveComments(doc, comments, io, path);
const loaded = store.loadComments(doc, io, path);
expect(loaded.map((c) => c.line)).toEqual([3, 12]);
expect(loaded[0].text).toBe('strong opener');
expect(loaded[0].resolved).toBe(false);
});
it('normalizes malformed entries on load', () => {
const io = makeIo({
[sidecar]: JSON.stringify({
comments: [
{ line: '7', text: 42 },
{ line: 2, text: 'ok', author: 'ana' },
],
}),
});
const loaded = store.loadComments(doc, io, path);
// '7' (string line) is dropped; the valid one survives normalization
expect(loaded).toHaveLength(1);
expect(loaded[0]).toMatchObject({ line: 2, author: 'ana' });
});
});
describe('mutators', () => {
it('addComment clamps lines and caps field lengths', () => {
const comments = [];
const c = store.addComment(comments, {
line: -5,
anchorText: 'x'.repeat(500),
text: 'y'.repeat(5000),
});
expect(comments).toHaveLength(1);
expect(c.line).toBe(1);
expect(c.anchorText.length).toBeLessThanOrEqual(200);
expect(c.text.length).toBeLessThanOrEqual(4000);
expect(c.id).toBeTruthy();
});
it('toggleResolved flips state and returns null for unknown ids', () => {
const comments = [];
const c = store.addComment(comments, { line: 1, text: 'hi' });
expect(store.toggleResolved(comments, c.id)).toBe(true);
expect(store.toggleResolved(comments, c.id)).toBe(false);
expect(store.toggleResolved(comments, 'missing')).toBeNull();
});
it('deleteComment reports whether anything was removed', () => {
const comments = [];
const c = store.addComment(comments, { line: 1, text: 'hi' });
expect(store.deleteComment(comments, c.id)).toBe(true);
expect(comments).toHaveLength(0);
expect(store.deleteComment(comments, c.id)).toBe(false);
});
});
describe('nextUnresolved (F8 navigation)', () => {
it('returns the first open comment below the cursor line', () => {
const comments = [
{ id: 'a', line: 5, resolved: false },
{ id: 'b', line: 20, resolved: false },
];
expect(store.nextUnresolved(comments, 7).id).toBe('b');
});
it('wraps to the top when no open comments are below', () => {
const comments = [
{ id: 'a', line: 5, resolved: false },
{ id: 'b', line: 20, resolved: true },
];
expect(store.nextUnresolved(comments, 10).id).toBe('a');
});
it('returns null when everything is resolved or empty', () => {
expect(store.nextUnresolved([{ id: 'a', line: 1, resolved: true }], 1)).toBeNull();
expect(store.nextUnresolved([], 1)).toBeNull();
});
});
describe('anchorStatus (drift detection)', () => {
const base = { line: 2, anchorText: 'original text' };
it('reports ok when the line still matches', () => {
expect(store.anchorStatus(base, ['first', 'original text', 'third'])).toBe('ok');
});
it('reports changed when the line text differs', () => {
expect(store.anchorStatus(base, ['first', 'edited text', 'third'])).toBe('changed');
});
it('reports missing when the line is beyond the document', () => {
expect(store.anchorStatus({ ...base, line: 99 }, ['only'])).toBe('missing');
expect(store.anchorStatus(base, [])).toBe('missing');
});
it('reports moved for comments without an anchor snippet', () => {
expect(store.anchorStatus({ line: 1, anchorText: '' }, ['whatever'])).toBe('moved');
});
});
});
+140
View File
@@ -0,0 +1,140 @@
/**
* @jest-environment node
*
* AiProviders tests with a stubbed fetch — no network access. Verifies the
* provider dispatch, auth handling, timeouts, and the user-safe error paths.
*/
const {
complete,
resolveSettings,
AiProviderError,
PROVIDER_DEFAULTS,
MAX_PROMPT_CHARS,
} = require('../../src/main/AiProviders');
/** Build a fetch stub returning the given JSON with status 200. */
function okFetch(body) {
return jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => body,
});
}
describe('AiProviders', () => {
describe('resolveSettings', () => {
it('applies provider defaults for base URL and model', () => {
const s = resolveSettings({ provider: 'ollama' });
expect(s.baseUrl).toBe(PROVIDER_DEFAULTS.ollama.baseUrl);
expect(s.model).toBe(PROVIDER_DEFAULTS.ollama.defaultModel);
expect(s.apiKey).toBe('');
});
it('does not require an API key for local providers', () => {
expect(() => resolveSettings({ provider: 'lmstudio' })).not.toThrow();
expect(() => resolveSettings({ provider: 'ollama' })).not.toThrow();
});
it('requires an API key for remote providers', () => {
expect(() => resolveSettings({ provider: 'openai' })).toThrow(AiProviderError);
expect(() => resolveSettings({ provider: 'anthropic' })).toThrow(/API key/);
});
it('rejects unknown providers and non-http(s) base URLs', () => {
expect(() => resolveSettings({ provider: 'nope' })).toThrow(/Unknown AI provider/);
expect(() =>
resolveSettings({ provider: 'openai-compatible', apiKey: 'k', baseUrl: 'file:///etc' })
).toThrow(/Invalid API base URL/);
});
it('clamps temperature into [0, 2]', () => {
expect(resolveSettings({ provider: 'ollama', temperature: 99 }).temperature).toBe(2);
expect(resolveSettings({ provider: 'ollama', temperature: -5 }).temperature).toBe(0);
});
});
describe('complete', () => {
const messages = [{ role: 'user', content: 'hello' }];
it('rejects requests with no messages', async () => {
await expect(complete({ provider: 'ollama', messages: [] })).rejects.toThrow(/No messages/);
});
it('rejects prompts over the size cap', async () => {
await expect(
complete({
provider: 'ollama',
messages: [{ role: 'user', content: 'x'.repeat(MAX_PROMPT_CHARS + 1) }],
})
).rejects.toThrow(/too large/i);
});
it('sends the OpenAI chat-completions shape with a bearer key', async () => {
const fetchImpl = okFetch({ choices: [{ message: { content: 'hi there' } }] });
const result = await complete(
{ provider: 'openai', apiKey: 'sk-test', messages },
{ fetchImpl }
);
expect(result.content).toBe('hi there');
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('https://api.openai.com/v1/chat/completions');
expect(init.headers.Authorization).toBe('Bearer sk-test');
const body = JSON.parse(init.body);
expect(body.messages[0].role).toBe('user');
});
it('prepends the system prompt as a system message for OpenAI-style providers', async () => {
const fetchImpl = okFetch({ choices: [{ message: { content: 'ok' } }] });
await complete({ provider: 'ollama', system: 'be brief', messages }, { fetchImpl });
const body = JSON.parse(fetchImpl.mock.calls[0][1].body);
expect(body.messages[0]).toEqual({ role: 'system', content: 'be brief' });
});
it('sends the Anthropic messages shape with x-api-key header', async () => {
const fetchImpl = okFetch({
content: [{ type: 'text', text: 'claude says hi' }],
});
const result = await complete(
{ provider: 'anthropic', apiKey: 'ak-test', system: 'be nice', messages },
{ fetchImpl }
);
expect(result.content).toBe('claude says hi');
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('https://api.anthropic.com/v1/messages');
expect(init.headers['x-api-key']).toBe('ak-test');
expect(init.headers['anthropic-version']).toBe('2023-06-01');
const body = JSON.parse(init.body);
expect(body.system).toBe('be nice');
});
it('surfaces HTTP failures as user-safe errors without the body', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 401 });
await expect(
complete({ provider: 'openai', apiKey: 'bad', messages }, { fetchImpl })
).rejects.toThrow(/HTTP 401/);
});
it('rejects unexpected response shapes', async () => {
const fetchImpl = okFetch({ unexpected: true });
await expect(
complete({ provider: 'openai', apiKey: 'k', messages }, { fetchImpl })
).rejects.toThrow(/unexpected response/i);
});
it('surfaces aborts as timeouts', async () => {
const fetchImpl = jest.fn(
(_url, init) =>
new Promise((_res, rej) => {
init.signal.addEventListener('abort', () =>
rej(new Error('The operation was aborted'))
);
})
);
await expect(
complete({ provider: 'ollama', messages }, { fetchImpl, timeoutMs: 30 })
).rejects.toThrow();
});
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* @jest-environment node
*
* OdtStyling.js tests: page-size patching and header/footer injection into a
* Pandoc-style ODT package. Fixtures are minimal zips built with PizZip,
* mirroring the structure Pandoc's ODT writer emits (styles.xml with a "pm1"
* page layout and a "Standard" master page).
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const PizZip = require('pizzip');
const {
setOdtPageSize,
addHeaderFooterToOdt,
parseDimensionsMm,
escapeOdtText,
} = require('../../src/main/OdtStyling');
// Minimal Pandoc-like styles.xml: page layout pm1 + Standard master page
const STYLES_XML = `<?xml version="1.0" encoding="utf-8"?>
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
office:version="1.2">
<office:automatic-styles>
<style:page-layout style:name="pm1">
<style:page-layout-properties fo:margin-top="0.787in" fo:page-width="8.267in" fo:page-height="11.69in" style:print-orientation="portrait"/>
</style:page-layout>
</office:automatic-styles>
<office:master-styles>
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
</office:master-styles>
</office:document-styles>`;
/**
* Write a minimal .odt fixture containing just styles.xml.
* @param {string} filePath destination
* @param {string} [stylesXml] styles.xml content (defaults to STYLES_XML)
*/
function writeOdtFixture(filePath, stylesXml = STYLES_XML) {
const zip = new PizZip();
zip.file('mimetype', 'application/vnd.oasis.opendocument.text');
zip.file('styles.xml', stylesXml);
zip.file('content.xml', '<office:document-content/>');
fs.writeFileSync(filePath, zip.generate({ type: 'nodebuffer' }));
}
/** Read styles.xml back out of a written fixture. */
function readStylesXml(filePath) {
return new PizZip(fs.readFileSync(filePath)).file('styles.xml').asText();
}
describe('OdtStyling', () => {
let tmpDir, odtPath;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odtstyle_'));
odtPath = path.join(tmpDir, 'doc.odt');
writeOdtFixture(odtPath);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('parseDimensionsMm', () => {
it('parses "210×297mm" (U+00D7 separator) into numbers', () => {
expect(parseDimensionsMm('210×297mm')).toEqual({ widthMm: 210, heightMm: 297 });
});
it('returns null for unparseable input', () => {
expect(parseDimensionsMm('8.5in')).toBeNull();
expect(parseDimensionsMm(undefined)).toBeNull();
});
});
describe('escapeOdtText', () => {
it('escapes XML-significant characters', () => {
expect(escapeOdtText('a<b>&"c"')).toBe('a&lt;b&gt;&amp;&quot;c&quot;');
});
});
describe('setOdtPageSize', () => {
it('rewrites width/height/orientation on the existing page layout (A4 portrait)', async () => {
await setOdtPageSize(odtPath, {
size: 'a4',
orientation: 'portrait',
pageSizes: { a4: { dimensions: '210×297mm' } },
});
const xml = readStylesXml(odtPath);
expect(xml).toContain('fo:page-width="210mm"');
expect(xml).toContain('fo:page-height="297mm"');
expect(xml).toContain('style:print-orientation="portrait"');
// Old inch-based attributes must be gone (no duplicate size attrs)
expect(xml).not.toContain('fo:page-width="8.267in"');
});
it('swaps dimensions for landscape', async () => {
await setOdtPageSize(odtPath, {
size: 'a4',
orientation: 'landscape',
pageSizes: { a4: { dimensions: '210×297mm' } },
});
const xml = readStylesXml(odtPath);
expect(xml).toContain('fo:page-width="297mm"');
expect(xml).toContain('fo:page-height="210mm"');
expect(xml).toContain('style:print-orientation="landscape"');
});
it('falls back to custom mm dimensions and A4 default', async () => {
await setOdtPageSize(odtPath, { customWidth: '148', customHeight: '210' });
expect(readStylesXml(odtPath)).toContain('fo:page-width="148mm"');
writeOdtFixture(odtPath);
await setOdtPageSize(odtPath, {});
expect(readStylesXml(odtPath)).toContain('fo:page-width="210mm"');
});
});
describe('addHeaderFooterToOdt', () => {
it('is a no-op when disabled', async () => {
await addHeaderFooterToOdt(odtPath, { enabled: false, header: { left: 'X' } });
expect(readStylesXml(odtPath)).not.toContain('<style:header');
});
it('injects header and footer regions into the Standard master page', async () => {
await addHeaderFooterToOdt(odtPath, {
enabled: true,
header: { left: 'My Doc', right: 'Draft <v2>' },
footer: { center: 'Page $PAGE$ of $TOTAL$' },
});
const xml = readStylesXml(odtPath);
expect(xml).toContain('<style:header>');
expect(xml).toContain('<style:region-left');
expect(xml).toContain('My Doc');
// User text must be XML-escaped
expect(xml).toContain('Draft &lt;v2&gt;');
expect(xml).toContain('<text:page-number>');
expect(xml).toContain('<text:page-count>');
// The self-closing master page must have been expanded to hold children
expect(xml).toMatch(/<style:master-page[^>]*>[\s\S]*<\/style:master-page>/);
});
it('does nothing when all regions are empty', async () => {
await addHeaderFooterToOdt(odtPath, {
enabled: true,
header: { left: '', center: '', right: '' },
footer: {},
});
expect(readStylesXml(odtPath)).toBe(STYLES_XML);
});
it('creates a master page when the package has none', async () => {
const bareXml = STYLES_XML.replace(
/ <office:master-styles>[\s\S]*<\/office:master-styles>/,
''
);
writeOdtFixture(odtPath, bareXml);
await addHeaderFooterToOdt(odtPath, {
enabled: true,
footer: { center: 'hi' },
});
const xml = readStylesXml(odtPath);
expect(xml).toContain('<office:master-styles>');
expect(xml).toContain('style:name="Standard"');
expect(xml).toContain('hi');
});
it('replaces existing header/footer content on re-export', async () => {
await addHeaderFooterToOdt(odtPath, { enabled: true, header: { left: 'first' } });
await addHeaderFooterToOdt(odtPath, { enabled: true, header: { left: 'second' } });
const xml = readStylesXml(odtPath);
expect(xml).toContain('second');
expect(xml).not.toContain('first');
// Exactly one header element, not nested duplicates
expect(xml.match(/<style:header>/g)).toHaveLength(1);
});
});
});
+1 -1
View File
@@ -17,7 +17,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const sharp = require('sharp');
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
const { PDFDocument, StandardFonts, rgb } = require('@cantoo/pdf-lib');
const PDFOperations = require('../../src/main/PDFOperations');
const {
runPDFBatchOperation,
+67 -37
View File
@@ -14,7 +14,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const sharp = require('sharp');
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
const { PDFDocument, StandardFonts, rgb } = require('@cantoo/pdf-lib');
const PDFOperations = require('../../src/main/PDFOperations');
describe('PDFOperations - Task 15 new operations', () => {
@@ -386,7 +386,7 @@ describe('PDFOperations - Task 16 form field fill/flatten', () => {
});
});
describe('PDFOperations - Task 27 honest encryption failure', () => {
describe('PDFOperations - real encryption (@cantoo/pdf-lib)', () => {
let tmpDir, inputPath;
beforeEach(async () => {
@@ -402,16 +402,16 @@ describe('PDFOperations - Task 27 honest encryption failure', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects the bundled pdf-lib as encryption-incapable via the module-load probe', async () => {
// Pins the Task 27 premise: pdf-lib 1.17.1's save() ignores password
// options (SaveOptions has no such fields), so the probe — which saves a
// tiny document with a userPassword and checks the bytes for /Encrypt —
// must report false. If this fails after a library swap, the probe
// re-enabled the ops and the honest-failure tests below no longer apply.
await expect(PDFOperations.pdfEncryptionSupported).resolves.toBe(false);
it('detects the encryption-capable library via the module-load probe', async () => {
// The @cantoo/pdf-lib fork adds doc.encrypt(); the probe — which encrypts
// a tiny document and checks the bytes for /Encrypt — must report true so
// the password ops are enabled in the UI. If this fails after a library
// change, the ops fall back to honest failure and these tests no longer
// apply.
await expect(PDFOperations.pdfEncryptionSupported).resolves.toBe(true);
});
it('pdfEncrypt fails honestly without writing an output file', async () => {
it('pdfEncrypt writes an encrypted PDF that requires the password', async () => {
const outputPath = path.join(tmpDir, 'encrypted.pdf');
const result = await PDFOperations.pdfEncrypt({
inputPath,
@@ -421,60 +421,90 @@ describe('PDFOperations - Task 27 honest encryption failure', () => {
permissions: { printing: true },
});
expect(result.success).toBe(false);
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
expect(fs.existsSync(outputPath)).toBe(false);
expect(result.success).toBe(true);
expect(fs.existsSync(outputPath)).toBe(true);
const bytes = fs.readFileSync(outputPath);
expect(bytes.includes('/Encrypt')).toBe(true);
// Correct password opens it; wrong password is rejected.
const opened = await PDFDocument.load(bytes, { password: 'secret' });
expect(opened.getPageCount()).toBe(1);
await expect(PDFDocument.load(bytes, { password: 'wrong' })).rejects.toThrow();
});
it('pdfDecrypt fails honestly without writing an output file', async () => {
it('pdfDecrypt removes password protection and writes a clean PDF', async () => {
const encryptedPath = path.join(tmpDir, 'encrypted.pdf');
const encryptResult = await PDFOperations.pdfEncrypt({
inputPath,
outputPath: encryptedPath,
userPassword: 'secret',
permissions: { printing: true },
});
expect(encryptResult.success).toBe(true);
const outputPath = path.join(tmpDir, 'decrypted.pdf');
const result = await PDFOperations.pdfDecrypt({
inputPath,
inputPath: encryptedPath,
outputPath,
password: 'secret',
});
expect(result.success).toBe(true);
const bytes = fs.readFileSync(outputPath);
expect(bytes.includes('/Encrypt')).toBe(false);
const opened = await PDFDocument.load(bytes);
expect(opened.getPageCount()).toBe(1);
});
it('pdfDecrypt rejects a wrong password without writing output', async () => {
const encryptedPath = path.join(tmpDir, 'encrypted.pdf');
await PDFOperations.pdfEncrypt({
inputPath,
outputPath: encryptedPath,
userPassword: 'secret',
permissions: {},
});
const outputPath = path.join(tmpDir, 'decrypted.pdf');
const result = await PDFOperations.pdfDecrypt({
inputPath: encryptedPath,
outputPath,
password: 'wrong',
});
expect(result.success).toBe(false);
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
expect(result.error).toMatch(/password/i);
expect(fs.existsSync(outputPath)).toBe(false);
});
it('pdfSetPermissions fails honestly without writing an output file', async () => {
it('pdfSetPermissions writes an encrypted PDF with an owner password', async () => {
const outputPath = path.join(tmpDir, 'permissions.pdf');
const result = await PDFOperations.pdfSetPermissions({
inputPath,
outputPath,
ownerPassword: 'owner-secret',
permissions: { printing: true },
permissions: { printing: true, copying: false },
});
expect(result.success).toBe(false);
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
expect(fs.existsSync(outputPath)).toBe(false);
expect(result.success).toBe(true);
const bytes = fs.readFileSync(outputPath);
expect(bytes.includes('/Encrypt')).toBe(true);
// Owner password grants full access, so loading with it must succeed.
const opened = await PDFDocument.load(bytes, { password: 'owner-secret' });
expect(opened.getPageCount()).toBe(1);
});
it('fails honestly even before reading the input, so a missing input reports unavailability', async () => {
const result = await PDFOperations.pdfEncrypt({
inputPath: path.join(tmpDir, 'missing.pdf'),
outputPath: path.join(tmpDir, 'never-written.pdf'),
userPassword: 'secret',
});
expect(result.success).toBe(false);
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
expect(fs.existsSync(path.join(tmpDir, 'never-written.pdf'))).toBe(false);
});
it('executeOperation routes the password ops to the honest failure', async () => {
it('executeOperation routes encrypt to the real implementation', async () => {
const outputPath = path.join(tmpDir, 'exec-encrypted.pdf');
const result = await PDFOperations.executeOperation('encrypt', {
inputPath,
outputPath: path.join(tmpDir, 'exec-encrypted.pdf'),
outputPath,
userPassword: 'secret',
permissions: { printing: true },
});
expect(result.success).toBe(false);
expect(result.message).toBe(PDFOperations.PDF_ENCRYPTION_UNAVAILABLE_MESSAGE);
expect(result.success).toBe(true);
expect(fs.readFileSync(outputPath).includes('/Encrypt')).toBe(true);
});
it('the module-load probe does not affect other operations', async () => {
+102
View File
@@ -0,0 +1,102 @@
/**
* @jest-environment node
*
* VersionHistory tests with a temp-dir-backed IO bundle: capture-before-save
* semantics, per-document isolation, pruning, and id validation.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const {
saveVersion,
listVersions,
readVersion,
deleteVersion,
isValidVersionId,
folderFor,
DEFAULT_MAX_KEEP,
} = require('../../src/main/VersionHistory');
describe('VersionHistory', () => {
let tmpDir, io;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vh_'));
io = { rootDir: path.join(tmpDir, 'versions'), fs, pathUtil: path, crypto };
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('saves and lists versions newest-first with metadata', () => {
const v1 = saveVersion({ docPath: '/docs/a.md', content: 'one', label: 'before save', io });
saveVersion({ docPath: '/docs/a.md', content: 'one two', label: 'before save', io });
const versions = listVersions({ docPath: '/docs/a.md', io });
expect(versions).toHaveLength(2);
expect(versions[0].createdAt).toBeGreaterThanOrEqual(v1.createdAt);
expect(versions[0].wordCount).toBe(2);
expect(versions.every((v) => v.label === 'before save')).toBe(true);
});
it('round-trips version content', () => {
const { id } = saveVersion({ docPath: '/docs/a.md', content: '# hello\n\nworld', io });
expect(readVersion({ docPath: '/docs/a.md', id, io })).toBe('# hello\n\nworld');
});
it('isolates documents by path', () => {
saveVersion({ docPath: '/docs/a.md', content: 'A', io });
saveVersion({ docPath: '/docs/b.md', content: 'B', io });
expect(listVersions({ docPath: '/docs/a.md', io })).toHaveLength(1);
expect(listVersions({ docPath: '/docs/b.md', io })).toHaveLength(1);
expect(listVersions({ docPath: '/docs/other.md', io })).toHaveLength(0);
});
it('prunes the oldest versions beyond maxKeep', () => {
const docPath = '/docs/many.md';
for (let i = 0; i < DEFAULT_MAX_KEEP + 5; i++) {
saveVersion({ docPath, content: `v${i}`, label: `v${i}`, io });
}
const versions = listVersions({ docPath, io });
expect(versions).toHaveLength(DEFAULT_MAX_KEEP);
// Newest survive; the oldest five labels are gone
expect(versions[0].label).toBe(`v${DEFAULT_MAX_KEEP + 4}`);
expect(versions.some((v) => v.label === 'v0')).toBe(false);
});
it('deleteVersion removes the entry and its blob', () => {
const docPath = '/docs/del.md';
const { id } = saveVersion({ docPath, content: 'x', io });
expect(deleteVersion({ docPath, id, io })).toBe(true);
expect(listVersions({ docPath, io })).toHaveLength(0);
expect(() => readVersion({ docPath, id, io })).toThrow();
expect(deleteVersion({ docPath, id, io })).toBe(false);
});
it('rejects malformed version ids on read (path traversal guard)', () => {
saveVersion({ docPath: '/docs/a.md', content: 'x', io });
expect(() => readVersion({ docPath: '/docs/a.md', id: '../../etc/passwd', io })).toThrow(
/Invalid version id/
);
expect(() => readVersion({ docPath: '/docs/a.md', id: 'UPPER!!', io })).toThrow();
});
it('isValidVersionId accepts generated ids and rejects hostile ones', () => {
expect(isValidVersionId('m3x1e2a-abc123')).toBe(true);
expect(isValidVersionId('../etc')).toBe(false);
expect(isValidVersionId('')).toBe(false);
expect(isValidVersionId(null)).toBe(false);
});
it('folderFor is deterministic and hex-safe', () => {
expect(folderFor('/docs/a.md', path, crypto)).toBe(
path.join(
'by-path',
crypto.createHash('sha1').update('/docs/a.md').digest('hex').slice(0, 16)
)
);
expect(folderFor('/docs/WEIRDname!!.md', path, crypto)).toMatch(/^[\\/a-z0-9-]+$/i);
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* @jest-environment node
*
* XlsxExporter tests: verify the generated OOXML package structure by
* reopening the buffer with JSZip and checking the key parts (workbook sheet
* list, per-sheet cell XML, content types). Uses only string assertions —
* no Excel engine needed.
*/
const JSZip = require('jszip');
const {
buildXlsx,
buildSheetXml,
columnLetter,
looksNumeric,
} = require('../../src/main/XlsxExporter');
describe('XlsxExporter', () => {
describe('columnLetter', () => {
it('maps 0-based indexes to Excel letters', () => {
expect(columnLetter(0)).toBe('A');
expect(columnLetter(25)).toBe('Z');
expect(columnLetter(26)).toBe('AA');
expect(columnLetter(27)).toBe('AB');
expect(columnLetter(52)).toBe('BA');
});
});
describe('looksNumeric', () => {
it('accepts integers and decimals, rejects text and leading zeros', () => {
expect(looksNumeric('42')).toBe(true);
expect(looksNumeric('-3.14')).toBe(true);
expect(looksNumeric('007')).toBe(false); // keeps identifier-looking values as text
expect(looksNumeric('1,000')).toBe(false);
expect(looksNumeric('abc')).toBe(false);
expect(looksNumeric('')).toBe(false);
});
});
describe('buildSheetXml', () => {
it('emits inline strings and numeric cells with row/column refs', () => {
const xml = buildSheetXml([
['Name', 'Score'],
['Ada', '99'],
]);
expect(xml).toContain(
'<c r="A1" t="inlineStr"><is><t xml:space="preserve">Name</t></is></c>'
);
expect(xml).toContain('<c r="B2" t="n"><v>99</v></c>');
expect(xml).toContain('<row r="1">');
});
it('escapes XML-significant characters in cells', () => {
const xml = buildSheetXml([['<b>&"quotes"</b>']]);
expect(xml).toContain('&lt;b&gt;&amp;&quot;quotes&quot;&lt;/b&gt;');
expect(xml).not.toContain('<b>');
});
});
describe('buildXlsx', () => {
it('creates a workbook with one sheet per table and all required parts', async () => {
const buffer = await buildXlsx([
[
['h1', 'h2'],
['a', '1'],
],
[['only', 'column']],
]);
const zip = await JSZip.loadAsync(buffer);
const parts = Object.keys(zip.files).sort();
expect(parts).toContain('[Content_Types].xml');
expect(parts).toContain('_rels/.rels');
expect(parts).toContain('xl/workbook.xml');
expect(parts).toContain('xl/_rels/workbook.xml.rels');
expect(parts).toContain('xl/styles.xml');
expect(parts).toContain('xl/worksheets/sheet1.xml');
expect(parts).toContain('xl/worksheets/sheet2.xml');
const workbook = await zip.file('xl/workbook.xml').async('string');
expect(workbook).toContain('name="Table 1"');
expect(workbook).toContain('name="Table 2"');
expect(workbook).toContain('r:id="rId2"');
const rels = await zip.file('xl/_rels/workbook.xml.rels').async('string');
expect(rels).toContain('Target="worksheets/sheet1.xml"');
expect(rels).toContain('Target="styles.xml"');
const sheet1 = await zip.file('xl/worksheets/sheet1.xml').async('string');
expect(sheet1).toContain('inlineStr');
});
it('rejects empty table lists', async () => {
await expect(buildXlsx([])).rejects.toThrow(/No tables/);
});
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* SessionStore (crash recovery) unit tests. Uses an in-memory Storage stub so
* the pure capture/save/load/clear logic can be tested without jsdom
* localStorage behavior, plus a quota-failing stub for the error paths.
*/
const {
captureSession,
saveSession,
loadSession,
clearSession,
STORAGE_KEY,
} = require('../src/utils/session-store');
/** Minimal synchronous Storage stub. */
function makeStorage({ quotaFails = false } = {}) {
const map = new Map();
return {
setItem(k, v) {
if (quotaFails) throw new Error('QuotaExceededError');
map.set(k, v);
},
getItem: (k) => (map.has(k) ? map.get(k) : null),
removeItem(k) {
map.delete(k);
},
_map: map,
};
}
// Helper: build a tab map like TabManager's
function tabMapOf(...tabs) {
const map = new Map();
tabs.forEach((t) => map.set(t.id, t));
return map;
}
describe('SessionStore', () => {
describe('captureSession', () => {
it('captures full content for dirty tabs and paths only for clean file tabs', () => {
const session = captureSession(
tabMapOf(
{ id: 1, title: 'notes.md', filePath: '/tmp/notes.md', isDirty: true, type: 'markdown' },
{
id: 2,
title: 'readme.md',
filePath: '/tmp/readme.md',
isDirty: false,
type: 'markdown',
},
{ id: 3, title: 'Untitled', filePath: null, isDirty: true, type: 'markdown' }
),
1,
(id) => `content-of-${id}`
);
expect(session.version).toBe(1);
expect(session.activeTabId).toBe(1);
expect(session.tabs).toHaveLength(3);
const dirty = session.tabs.find((t) => t.id === 1);
expect(dirty.content).toBe('content-of-1');
expect(dirty.isDirty).toBe(true);
const clean = session.tabs.find((t) => t.id === 2);
expect(clean.content).toBeUndefined();
expect(clean.filePath).toBe('/tmp/readme.md');
const untitled = session.tabs.find((t) => t.id === 3);
expect(untitled.content).toBe('content-of-3');
});
it('drops empty untitled tabs', () => {
const session = captureSession(
tabMapOf({ id: 1, title: 'Untitled', filePath: null, isDirty: false, type: 'markdown' }),
1,
() => ''
);
expect(session.tabs).toHaveLength(0);
});
it('keeps PDF tabs as path-only entries', () => {
const session = captureSession(
tabMapOf({
id: 1,
title: 'doc.pdf',
filePath: '/tmp/doc.pdf',
isDirty: false,
type: 'pdf',
}),
1,
() => ''
);
expect(session.tabs).toHaveLength(1);
expect(session.tabs[0].type).toBe('pdf');
expect(session.tabs[0].filePath).toBe('/tmp/doc.pdf');
expect('content' in session.tabs[0]).toBe(false);
});
it('stops capturing content once the size budget is exhausted', () => {
const big = 'x'.repeat(1024 * 1024); // 1MB each
const session = captureSession(
tabMapOf(
{ id: 1, title: 'a', filePath: null, isDirty: true, type: 'markdown' },
{ id: 2, title: 'b', filePath: null, isDirty: true, type: 'markdown' },
{ id: 3, title: 'c', filePath: null, isDirty: true, type: 'markdown' }
),
3,
() => big
);
// 2MB budget holds exactly two 1MB buffers
const captured = session.tabs.filter((t) => typeof t.content === 'string');
expect(captured.length).toBeLessThanOrEqual(2);
});
});
describe('saveSession / loadSession', () => {
it('round-trips a snapshot', () => {
const storage = makeStorage();
const session = captureSession(
tabMapOf({ id: 1, title: 'x', filePath: null, isDirty: true, type: 'markdown' }),
1,
() => 'hello'
);
saveSession(storage, session);
expect(loadSession(storage)).toEqual(session);
});
it('returns null when nothing is stored', () => {
expect(loadSession(makeStorage())).toBeNull();
});
it('returns null for corrupt JSON', () => {
const storage = makeStorage();
storage.setItem(STORAGE_KEY, '{not json');
expect(loadSession(storage)).toBeNull();
});
it('returns null for unknown versions or empty tab lists', () => {
const storage = makeStorage();
storage.setItem(STORAGE_KEY, JSON.stringify({ version: 99, tabs: [{}] }));
expect(loadSession(storage)).toBeNull();
storage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, tabs: [] }));
expect(loadSession(storage)).toBeNull();
});
it('swallows quota errors and clears any stale snapshot', () => {
const storage = makeStorage({ quotaFails: true });
expect(() => saveSession(storage, { version: 1, tabs: [] })).not.toThrow();
});
});
describe('clearSession', () => {
it('removes the stored snapshot', () => {
const storage = makeStorage();
storage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, tabs: [{}] }));
clearSession(storage);
expect(storage.getItem(STORAGE_KEY)).toBeNull();
});
});
});
+158
View File
@@ -0,0 +1,158 @@
/**
* WikiLinks + Backlinks unit tests (pure string/graph logic, no IO).
*/
const path = require('path');
const { renderWikiLinksInHtml, resolveTargetPath, docNameFor } = require('../src/utils/wiki-links');
const {
collectMarkdownFiles,
extractOutgoingLinks,
findBacklinks,
} = require('../src/utils/backlinks');
describe('wiki-links', () => {
describe('renderWikiLinksInHtml', () => {
it('converts [[target]] into a wiki-link anchor', () => {
const html = renderWikiLinksInHtml('<p>see [[My Note]] now</p>');
expect(html).toContain('data-wiki-target="My Note"');
expect(html).toContain('>My Note</a>');
expect(html).not.toContain('[[');
});
it('uses the alias as label when [[target|alias]]', () => {
const html = renderWikiLinksInHtml('<p>[[My Note|the note]]</p>');
expect(html).toContain('data-wiki-target="My Note"');
expect(html).toContain('>the note</a>');
});
it('ignores the heading fragment in [[target#section]]', () => {
const html = renderWikiLinksInHtml('<p>[[Note#Details]]</p>');
expect(html).toContain('data-wiki-target="Note"');
});
it('leaves code blocks and inline code untouched', () => {
const html = renderWikiLinksInHtml(
'<p>real [[Link]]</p><pre><code>fake [[Code]]</code></pre><p><code>[[Inline]]</code></p>'
);
expect(html).toContain('data-wiki-target="Link"');
expect(html).not.toContain('data-wiki-target="Code"');
expect(html).not.toContain('data-wiki-target="Inline"');
expect(html).toContain('[[Code]]');
});
it('escapes HTML in targets and labels', () => {
const html = renderWikiLinksInHtml('<p>[[A<b>&Note|lbl"&x]]</p>');
expect(html).not.toContain('<b>&Note');
expect(html).toContain('data-wiki-target=');
});
it('passes through HTML without [[ unchanged (fast path)', () => {
expect(renderWikiLinksInHtml('<p>plain</p>')).toBe('<p>plain</p>');
});
});
describe('resolveTargetPath', () => {
it('appends .md in the current directory', () => {
expect(resolveTargetPath('Note', '/docs', path)).toBe(path.join('/docs', 'Note.md'));
});
it('keeps an existing markdown extension', () => {
expect(resolveTargetPath('Note.md', '/docs', path)).toBe(path.join('/docs', 'Note.md'));
expect(resolveTargetPath('Note.markdown', '/docs', path)).toBe(
path.join('/docs', 'Note.markdown')
);
});
it('supports relative subpaths', () => {
expect(resolveTargetPath('sub/Note', '/docs', path)).toBe(
path.join('/docs', 'sub', 'Note.md')
);
});
it('rejects absolute and traversing targets', () => {
expect(resolveTargetPath('/etc/passwd', '/docs', path)).toBeNull();
expect(resolveTargetPath('C:/win', '/docs', path)).toBeNull();
expect(resolveTargetPath('../outside', '/docs', path)).toBeNull();
expect(resolveTargetPath('', '/docs', path)).toBeNull();
});
});
describe('docNameFor', () => {
it('strips the markdown extension', () => {
expect(docNameFor('/docs/My Note.md', path)).toBe('My Note');
expect(docNameFor('/docs/My Note.markdown', path)).toBe('My Note');
expect(docNameFor(null, path)).toBeNull();
});
});
});
describe('backlinks', () => {
describe('extractOutgoingLinks', () => {
it('collects unique targets including aliased/sectioned links', () => {
const links = extractOutgoingLinks('[[A]] and [[B|x]] and [[A#sec]] and [[C]]');
expect(links.sort()).toEqual(['A', 'B', 'C']);
});
});
describe('collectMarkdownFiles', () => {
it('walks folders breadth-first, skipping hidden and vendor dirs, capped', async () => {
const tree = {
'/vault': [
{ name: 'a.md', isDirectory: false, path: '/vault/a.md' },
{ name: '.git', isDirectory: true, path: '/vault/.git' },
{ name: 'node_modules', isDirectory: true, path: '/vault/node_modules' },
{ name: 'sub', isDirectory: true, path: '/vault/sub' },
],
'/vault/sub': [{ name: 'b.markdown', isDirectory: false, path: '/vault/sub/b.markdown' }],
// .git/node_modules never requested
};
const listDir = async (dir) => ({ entries: tree[dir] || [] });
const files = await collectMarkdownFiles('/vault', listDir);
expect(files.sort()).toEqual(['/vault/a.md', '/vault/sub/b.markdown']);
});
it('respects the file cap', async () => {
const entries = Array.from({ length: 20 }, (_, i) => ({
name: `f${i}.md`,
isDirectory: false,
path: `/v/f${i}.md`,
}));
const files = await collectMarkdownFiles('/v', async () => ({ entries }), { maxFiles: 5 });
expect(files).toHaveLength(5);
});
});
describe('findBacklinks', () => {
it('finds linking documents with line numbers and context', async () => {
const files = ['/v/a.md', '/v/b.md', '/v/current.md'];
const contents = {
'/v/a.md': 'intro\nsee [[Current Note]] here',
'/v/b.md': 'no links here',
};
const readFile = async (p) => contents[p] ?? null;
const links = await findBacklinks({
docName: 'Current Note',
docPath: '/v/current.md',
files,
readFile,
});
expect(links).toEqual([
expect.objectContaining({ path: '/v/a.md', line: 2, context: 'see [[Current Note]] here' }),
]);
});
it('matches .md-suffixed and case-insensitive references, misses self', async () => {
const contents = {
'/v/a.md': '[[current note.md]]',
'/v/current.md': 'self ref [[Current Note]]',
};
const links = await findBacklinks({
docName: 'Current Note',
docPath: '/v/current.md',
files: ['/v/a.md', '/v/current.md'],
readFile: async (p) => contents[p] ?? null,
});
expect(links).toHaveLength(1);
expect(links[0].path).toBe('/v/a.md');
});
});
});