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
+240
View File
@@ -0,0 +1,240 @@
/**
* AI provider adapters for the AI Assistant plugin (main process side).
*
* All network calls happen here in the main process, never in the renderer:
* - the renderer's CSP does not need to whitelist AI endpoints
* - API keys never cross the IPC boundary into the renderer
* - every provider gets the same timeout/size/error sanitization rules
*
* Supported providers:
* - `openai` → https://api.openai.com/v1 (chat completions)
* - `anthropic` → https://api.anthropic.com (messages API)
* - `ollama` → http://localhost:11434/v1 (OpenAI-compatible)
* - `lmstudio` → http://localhost:1234/v1 (OpenAI-compatible)
* - `openai-compatible`→ any baseUrl speaking the OpenAI chat schema
*
* The module takes an injectable `fetchImpl` (defaulting to global fetch) so
* tests can stub the network without monkey-patching.
*
* @module AiProviders
*/
/* global AbortController */
// Hard caps shared by every provider: a runaway selection (or a hostile
// plugin caller) must not be able to push a 50MB "prompt" at an API.
const MAX_PROMPT_CHARS = 200 * 1024;
const DEFAULT_TIMEOUT_MS = 120000;
// Sensible defaults per provider; every field is overridable via settings.
const PROVIDER_DEFAULTS = {
openai: { baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o-mini' },
anthropic: { baseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-latest' },
ollama: { baseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3.1' },
lmstudio: { baseUrl: 'http://localhost:1234/v1', defaultModel: 'local-model' },
'openai-compatible': { baseUrl: '', defaultModel: '' },
};
/** Provider ids that speak the OpenAI chat-completions schema. */
const OPENAI_STYLE = new Set(['openai', 'ollama', 'lmstudio', 'openai-compatible']);
class AiProviderError extends Error {
/**
* @param {string} message User-safe message (never include keys or raw HTML)
* @param {string} code Machine-readable code for the renderer
*/
constructor(message, code) {
super(message);
this.name = 'AiProviderError';
this.code = code;
}
}
/**
* Validate and normalize a completion request shared by all providers.
* @returns {{provider:string, baseUrl:string, apiKey:string, model:string,
* temperature:number}} resolved settings
* @throws {AiProviderError} on unknown provider / missing key / bad URL
*/
function resolveSettings({
provider,
baseUrl,
apiKey,
model,
temperature,
requireKey = true,
} = {}) {
const defaults = PROVIDER_DEFAULTS[provider];
if (!defaults) {
throw new AiProviderError(
`Unknown AI provider "${provider}". Configure the AI Assistant plugin first.`,
'not_configured'
);
}
// Local providers (ollama/lmstudio) don't need a key; remote ones do.
const needsKey = requireKey && provider !== 'ollama' && provider !== 'lmstudio';
if (needsKey && !apiKey) {
throw new AiProviderError(
`The "${provider}" provider needs an API key. Add one in AI Assistant settings.`,
'missing_key'
);
}
// Only allow http(s) base URLs to avoid file:// and other exotic schemes
const resolvedBase = (baseUrl || defaults.baseUrl || '').replace(/\/+$/, '');
if (!/^https?:\/\//.test(resolvedBase)) {
throw new AiProviderError(`Invalid API base URL for provider "${provider}".`, 'bad_base_url');
}
return {
provider,
baseUrl: resolvedBase,
apiKey: apiKey || '',
model: model || defaults.defaultModel,
temperature: typeof temperature === 'number' ? Math.max(0, Math.min(2, temperature)) : 0.7,
};
}
/**
* Call a chat-completion style provider (OpenAI schema).
* @returns {Promise<string>} assistant message text
*/
async function callOpenAiStyle(settings, { system, messages }, fetchImpl, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const body = {
model: settings.model,
temperature: settings.temperature,
messages: [
...(system ? [{ role: 'system', content: system }] : []),
...messages.map((m) => ({ role: m.role, content: m.content })),
],
};
const headers = { 'Content-Type': 'application/json' };
// Local servers accept (and ignore) bearer keys; harmless to send always
if (settings.apiKey) headers.Authorization = `Bearer ${settings.apiKey}`;
const response = await fetchImpl(`${settings.baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) {
throw new AiProviderError(
`AI request failed (HTTP ${response.status}). Check the model name, API key, and base URL.`,
`http_${response.status}`
);
}
const data = await response.json();
const text = data?.choices?.[0]?.message?.content;
if (typeof text !== 'string') {
throw new AiProviderError(
'AI provider returned an unexpected response shape.',
'bad_response'
);
}
return text;
} finally {
clearTimeout(timer);
}
}
/**
* Call Anthropic's messages API.
* @returns {Promise<string>} first text block of the reply
*/
async function callAnthropic(settings, { system, messages }, fetchImpl, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
// Anthropic splits system prompts out of the message list
const body = {
model: settings.model,
max_tokens: 4096,
temperature: settings.temperature,
system: system || undefined,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
};
const response = await fetchImpl(`${settings.baseUrl}/v1/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': settings.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) {
throw new AiProviderError(
`AI request failed (HTTP ${response.status}). Check the model name, API key, and base URL.`,
`http_${response.status}`
);
}
const data = await response.json();
const text = Array.isArray(data?.content)
? data.content
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('')
: '';
if (!text) {
throw new AiProviderError(
'AI provider returned an unexpected response shape.',
'bad_response'
);
}
return text;
} finally {
clearTimeout(timer);
}
}
/**
* Run a chat completion against the configured provider.
*
* @param {object} request - { provider, baseUrl, apiKey, model, temperature,
* system, messages: [{role, content}] }
* @param {object} [options] - { fetchImpl, timeoutMs }
* @returns {Promise<{content: string}>}
* @throws {AiProviderError} with user-safe messages (code field for UI logic)
*/
async function complete(request, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch;
if (typeof fetchImpl !== 'function') {
throw new AiProviderError('No fetch implementation available.', 'no_fetch');
}
const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
if (!Array.isArray(request?.messages) || request.messages.length === 0) {
throw new AiProviderError('No messages provided.', 'no_messages');
}
const totalChars =
(request.system?.length || 0) +
request.messages.reduce((n, m) => n + (m?.content?.length || 0), 0);
if (totalChars > MAX_PROMPT_CHARS) {
throw new AiProviderError(
'Prompt is too large (over 200KB). Try a smaller selection.',
'prompt_too_large'
);
}
const settings = resolveSettings(request);
if (OPENAI_STYLE.has(settings.provider)) {
const content = await callOpenAiStyle(settings, request, fetchImpl, timeoutMs);
return { content };
}
const content = await callAnthropic(settings, request, fetchImpl, timeoutMs);
return { content };
}
module.exports = {
complete,
resolveSettings,
AiProviderError,
PROVIDER_DEFAULTS,
MAX_PROMPT_CHARS,
};
+272
View File
@@ -0,0 +1,272 @@
/**
* ODT post-export styling: page size + headers/footers.
*
* Pandoc's ODT writer always emits a `styles.xml` containing:
* - an automatic-styles section with a page layout (typically "pm1") holding
* `<style:page-layout-properties>` (width/height/margins), and
* - `<office:master-styles>` with a `Standard` master page that references
* that page layout. Page headers/footers in ODF attach to the master page
* as `<style:header>` / `<style:footer>` children, each split into
* `<style:region-left|center|right>` regions.
*
* This module patches those two spots in-place with PizZip string surgery
* (the same technique `addHeaderFooterToDocx`/`setDocxPageSize` use for DOCX).
* All edits are best-effort: a malformed or unexpected styles.xml logs and
* leaves the file untouched rather than corrupting the export.
*
* Exposed for tests: `parseDimensionsMm`, `escapeOdtText`.
*
* @module OdtStyling
*/
const fs = require('fs');
/**
* Escape text for safe embedding inside ODF XML text nodes.
* ODF uses standard XML escaping; quotes are escaped too so values can also be
* used inside attribute values safely.
*
* @param {string} text Raw user text
* @returns {string} XML-safe text
*/
function escapeOdtText(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Parse a PAGE_SIZES `dimensions` string ("210×297mm" — U+00D7 separator)
* into millimeter numbers. Returns null when the string is unparseable so
* callers can fall back to A4.
*
* @param {string} dimensions e.g. "210×297mm"
* @returns {{widthMm: number, heightMm: number}|null}
*/
function parseDimensionsMm(dimensions) {
if (typeof dimensions !== 'string') return null;
const match = /^(\d+(?:\.\d+)?)\s*[×x]\s*(\d+(?:\.\d+)?)\s*mm$/i.exec(dimensions.trim());
if (!match) return null;
return { widthMm: parseFloat(match[1]), heightMm: parseFloat(match[2]) };
}
/**
* Read `styles.xml` out of an ODT (zip) file as text.
*
* @param {string} odtPath Path to the .odt file
* @param {Function} PizZipUtil Injected PizZip constructor (keeps the module
* lazy-load friendly and lets tests pass the real one)
* @returns {{zip: object, stylesXml: string}}
*/
function openOdtStyles(odtPath, PizZipUtil) {
const zip = new PizZipUtil(fs.readFileSync(odtPath));
const stylesFile = zip.file('styles.xml');
if (!stylesFile) {
throw new Error('styles.xml not found in ODT package');
}
return { zip, stylesXml: stylesFile.asText() };
}
/**
* Set the page size/orientation on every `<style:page-layout-properties>`
* element in styles.xml (an ODT normally has exactly one). Width/height are
* written as `fo:page-width`/`fo:page-height` in mm, orientation as
* `style:print-orientation`, and landscape swaps width/height (ODF expects
* portrait-orientation dimensions plus the print-orientation flag, matching
* how LibreOffice itself saves landscape documents).
*
* @param {string} odtPath Path to the .odt to patch (modified in place)
* @param {{size?: string, customWidth?: string|number, customHeight?: string|number,
* orientation?: string, pageSizes?: object}} pageSettings App page
* settings; `pageSizes` injects the app's PAGE_SIZES map (main.js owns it).
* @returns {Promise<void>} Resolves when written; errors are logged, never thrown
*/
async function setOdtPageSize(odtPath, pageSettings = {}) {
try {
const PizZipUtil = require('pizzip');
const { zip, stylesXml } = openOdtStyles(odtPath, PizZipUtil);
// Resolve width/height in mm, mirroring setDocxPageSize's priority:
// named size → custom → A4 default.
const sizes = pageSettings.pageSizes || {};
let widthMm;
let heightMm;
const named = sizes[pageSettings.size];
const parsed = named ? parseDimensionsMm(named.dimensions) : null;
if (parsed) {
widthMm = parsed.widthMm;
heightMm = parsed.heightMm;
} else if (pageSettings.customWidth && pageSettings.customHeight) {
// Custom sizes are entered in mm in the export dialog
widthMm = parseFloat(pageSettings.customWidth) || 210;
heightMm = parseFloat(pageSettings.customHeight) || 297;
} else {
widthMm = 210;
heightMm = 297;
}
const orientation = pageSettings.orientation === 'landscape' ? 'landscape' : 'portrait';
if (orientation === 'landscape') {
[widthMm, heightMm] = [heightMm, widthMm];
}
let updated = stylesXml;
let patched = false;
// Replace attributes on every existing page-layout-properties element
updated = updated.replace(/<style:page-layout-properties\b[^>]*>/g, (tag) => {
patched = true;
let next = tag;
// Drop any prior size/orientation attributes so re-exports stay correct
next = next.replace(
/\s(?:fo:page-width|fo:page-height|style:print-orientation)="[^"]*"/g,
''
);
return next.replace(
/<style:page-layout-properties\b/,
`<style:page-layout-properties fo:page-width="${widthMm}mm" fo:page-height="${heightMm}mm"` +
` style:print-orientation="${orientation}"`
);
});
// Pandoc always emits a page layout, but if one is missing (self-closing
// <style:page-layout .../> with no properties), inject the properties in
if (!patched) {
updated = updated.replace(
/<style:page-layout\b([^>]*)\/>/g,
(_m, attrs) =>
`<style:page-layout${attrs}>` +
`<style:page-layout-properties fo:page-width="${widthMm}mm" fo:page-height="${heightMm}mm"` +
` style:print-orientation="${orientation}"` +
`/></style:page-layout>`
);
}
zip.file('styles.xml', updated);
fs.writeFileSync(odtPath, zip.generate({ type: 'nodebuffer' }));
} catch (error) {
// Best-effort: a failed style patch must never lose the user's export
console.error('Failed to set ODT page size:', error);
}
}
/**
* Build the XML for one header/footer region triple. $PAGE$/$TOTAL$ become ODF
* fields (`<text:page-number>` / `<text:page-count>`), matching the DOCX
* exporter's PAGE/NUMPAGES field handling.
*
* @param {{left?: string, center?: string, right?: string}} regions
* @param {'header'|'footer'} kind Only used for the element name
* @returns {string} `<style:header>…</style:header>` XML, or '' when all regions empty
*/
function buildHeaderFooterXml(regions, kind) {
const regionNames = ['left', 'center', 'right'];
const used = regionNames.some((name) => regions[name] && regions[name].trim());
if (!used) return '';
const fields = {
$PAGE$: '<text:page-number>1</text:page-number>',
$TOTAL$: '<text:page-count>1</text:page-count>',
};
const regionXml = regionNames
.map((name) => {
const raw = regions[name] || '';
if (!raw.trim()) return '';
// Split on the field markers and emit literal runs / field elements
const parts = raw.split(/(\$PAGE\$|\$TOTAL\$)/);
const inner = parts
.map((part) => (fields[part] ? fields[part] : escapeOdtText(part)))
.join('');
return `<style:region-${name}><text:p text:style-name="HeaderAndFooter">${inner}</text:p></style:region-${name}>`;
})
.join('');
return `<style:${kind}>${regionXml}</style:${kind}>`;
}
/**
* Add headers/footers to an ODT by injecting `<style:header>`/`<style:footer>`
* into the `Standard` master page in styles.xml. Existing header/footer
* elements on that master page are removed first so re-exports don't nest.
* The `settings` shape mirrors `headerFooterSettings` in main.js; dynamic
* fields ($DATE$, $TITLE$, …) are expected to be resolved by the caller via
* processDynamicFields before this runs (mirroring addHeaderFooterToDocx).
*
* @param {string} odtPath Path to the .odt to patch (modified in place)
* @param {{enabled?: boolean, header?: object, footer?: object}} settings
* @returns {Promise<void>} Resolves when written; errors are logged, never thrown
*/
async function addHeaderFooterToOdt(odtPath, settings = {}) {
if (!settings.enabled) return;
try {
const PizZipUtil = require('pizzip');
const { zip, stylesXml } = openOdtStyles(odtPath, PizZipUtil);
const headerXml = buildHeaderFooterXml(settings.header || {}, 'header');
const footerXml = buildHeaderFooterXml(settings.footer || {}, 'footer');
if (!headerXml && !footerXml) return;
let updated = stylesXml;
if (/<style:master-page\b[^>]*style:name="Standard"/.test(updated)) {
// Paired <style:master-page ...>…</style:master-page>: strip any existing
// header/footer from the inner content, then prepend the new ones.
// Self-closing <style:master-page ... /> (Pandoc's usual output) has no
// inner content, so it is expanded into a paired tag instead.
const paired =
/(<style:master-page\b[^>]*style:name="Standard"[^>]*>)([\s\S]*?)(<\/style:master-page>)/.exec(
updated
);
if (paired) {
updated = updated.replace(paired[0], (_m, openTag, inner) => {
const cleaned = inner
.replace(/<style:header\b[\s\S]*?<\/style:header>/g, '')
.replace(/<style:footer\b[\s\S]*?<\/style:footer>/g, '')
.replace(/<style:header\b[^>]*\/>/g, '')
.replace(/<style:footer\b[^>]*\/>/g, '');
return `${openTag}${headerXml}${footerXml}${cleaned}</style:master-page>`;
});
} else {
updated = updated.replace(
/<style:master-page\b([^>]*?)\s*\/>/,
(_m, attrs) => `<style:master-page${attrs}>${headerXml}${footerXml}</style:master-page>`
);
}
} else if (/<office:master-styles\b/.test(updated)) {
// Master-styles section exists but no Standard page: append one that
// uses the document's first page layout (Pandoc's "pm1" convention).
const layoutName = /<style:page-layout\b[^>]*style:name="([^"]+)"/.exec(updated);
const ref = layoutName ? ` style:page-layout-name="${layoutName[1]}"` : '';
updated = updated.replace(
/<office:master-styles\b[^>]*>/,
(openTag) =>
`${openTag}<style:master-page style:name="Standard"${ref}>${headerXml}${footerXml}</style:master-page>`
);
} else {
// No master-styles at all: master-styles is the last child element of
// office:document-styles, so inserting before its close tag is valid ODF
updated = updated.replace(
/<\/office:document-styles>/,
`<office:master-styles><style:master-page style:name="Standard">${headerXml}${footerXml}</style:master-page></office:master-styles></office:document-styles>`
);
}
zip.file('styles.xml', updated);
fs.writeFileSync(odtPath, zip.generate({ type: 'nodebuffer' }));
} catch (error) {
console.error('Failed to add headers/footers to ODT:', error);
}
}
module.exports = {
setOdtPageSize,
addHeaderFooterToOdt,
// Exported for unit tests
parseDimensionsMm,
escapeOdtText,
};
+3 -4
View File
@@ -22,10 +22,9 @@
* cannot supply (merge takes many inputs in one op; reorder needs each
* file's full page order; fillForm's field values differ per file).
* - formFields — a read-only query returning data, not a transform.
* - encrypt / decrypt / permissions — pdf-lib 1.17.1 (bundled) lacks
* encryption support, so since Task 27 these ops fail honestly with an
* "unavailable" result instead of silently writing unprotected files;
* a batch run would deterministically fail every file.
* - encrypt / decrypt / permissions — these need per-file secrets (passwords)
* that the folder-batch flow cannot supply meaningfully, so they stay
* single-file operations in the PDF Editor dialog.
*
* @module PDFBatchOperations
*/
+26 -16
View File
@@ -1,20 +1,20 @@
const fs = require('fs');
const path = require('path');
const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
// @cantoo/pdf-lib is a drop-in pdf-lib fork that adds real encryption support
// (doc.encrypt() + password-protected loading), which upstream pdf-lib 1.17.1
// lacks. Rather than trusting a pinned version string, probe the installed
// library once at module load: encrypt a tiny in-memory document and check the
// raw bytes for an /Encrypt dictionary (which an unencrypted document never
// contains). A library that supports encryption passes the probe and the
// password ops re-enable automatically. Probe errors fail closed (treated as
// unsupported).
const { PDFDocument, rgb, degrees, StandardFonts } = require('@cantoo/pdf-lib');
// pdf-lib 1.17.1 cannot encrypt: SaveOptions has no userPassword/ownerPassword/
// permissions fields, so save() silently ignores them and writes an unprotected
// file, and PDFDocument.load() cannot open password-protected input (verified
// empirically in Task 22's review). Rather than trusting a pinned version
// string, probe the installed library once at module load: save a tiny
// in-memory document with a userPassword and check the raw bytes for an
// /Encrypt dictionary (which an unencrypted document never contains). A library
// that supports encryption passes the probe and the password ops re-enable
// automatically. Probe errors fail closed (treated as unsupported).
const pdfEncryptionSupported = (async () => {
try {
const probeDoc = await PDFDocument.create();
const probeBytes = await probeDoc.save({ userPassword: 'encryption-capability-probe' });
probeDoc.encrypt({ userPassword: 'encryption-capability-probe' });
const probeBytes = await probeDoc.save();
return Buffer.from(probeBytes).includes('/Encrypt');
} catch {
return false;
@@ -335,7 +335,7 @@ async function pdfEncrypt(data) {
const pdfBytes = fs.readFileSync(data.inputPath);
const pdf = await PDFDocument.load(pdfBytes);
const encryptedPdfBytes = await pdf.save({
pdf.encrypt({
userPassword: data.userPassword,
ownerPassword: data.ownerPassword || data.userPassword,
permissions: {
@@ -349,6 +349,7 @@ async function pdfEncrypt(data) {
},
});
const encryptedPdfBytes = await pdf.save();
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
return { success: true, message: 'Successfully added password protection to PDF' };
@@ -357,7 +358,7 @@ async function pdfEncrypt(data) {
return {
success: false,
error:
'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.',
'PDF encryption requires @cantoo/pdf-lib with encryption support. This feature may not be available in the current version.',
};
}
return { success: false, error: error.message };
@@ -372,7 +373,14 @@ async function pdfDecrypt(data) {
const pdfBytes = fs.readFileSync(data.inputPath);
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
const decryptedPdfBytes = await pdf.save();
// The loaded context retains its /Encrypt security object, so re-saving it
// would keep the document encrypted. Copy the pages into a fresh document
// instead, which produces a clean, unencrypted file.
const decrypted = await PDFDocument.create();
const copiedPages = await decrypted.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach((page) => decrypted.addPage(page));
const decryptedPdfBytes = await decrypted.save();
fs.writeFileSync(data.outputPath, decryptedPdfBytes);
return { success: true, message: 'Successfully removed password protection from PDF' };
@@ -393,7 +401,7 @@ async function pdfSetPermissions(data) {
const loadOptions = data.currentPassword ? { password: data.currentPassword } : {};
const pdf = await PDFDocument.load(pdfBytes, loadOptions);
const newPdfBytes = await pdf.save({
pdf.encrypt({
ownerPassword: data.ownerPassword,
permissions: {
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
@@ -406,6 +414,8 @@ async function pdfSetPermissions(data) {
},
});
const newPdfBytes = await pdf.save();
fs.writeFileSync(data.outputPath, newPdfBytes);
return { success: true, message: 'Successfully updated PDF permissions' };
@@ -414,7 +424,7 @@ async function pdfSetPermissions(data) {
return {
success: false,
error:
'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.',
'PDF permissions require @cantoo/pdf-lib with encryption support. This feature may not be available in the current version.',
};
}
return { success: false, error: error.message };
+160
View File
@@ -0,0 +1,160 @@
/**
* Local document version history ("timemachine lite").
*
* Every time a file is saved, the previous on-disk content is snapshotted to
* <userData>/versions/<hash-of-path>/<timestamp>.md plus a meta.json index.
* Users browse/restore/diff versions from the History sidebar panel —
* recovery from a bad edit no longer depends on Git.
*
* Design notes:
* - Versions store the content that is about to be REPLACED (pre-save), so
* the newest version is always the last state before the current one.
* - `maxKeep` prunes oldest entries per document (default 20).
* - Version ids are `<ms-timestamp>-<suffix>`; strict format validation on
* read prevents path traversal via crafted ids.
* - All fs/path access is injectable for unit tests.
*
* @module VersionHistory
*/
const DEFAULT_MAX_KEEP = 20;
/** Inject a crypto-like object (Node's crypto by default). */
function defaultCrypto() {
return require('crypto');
}
/**
* Stable per-document storage folder name: first 16 hex chars of the sha1 of
* the absolute path. Avoids OS path-length and illegal-character issues.
*/
function folderFor(docPath, pathUtil, crypto) {
const hash = crypto.createHash('sha1').update(String(docPath)).digest('hex').slice(0, 16);
return pathUtil.join('by-path', hash);
}
/** Build a fresh version id for "now" (sortable + filesystem-safe). */
function newVersionId(now = Date.now()) {
return `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/** Version ids must be strictly alphanumeric-dash to stay inside the folder. */
function isValidVersionId(id) {
return typeof id === 'string' && /^[a-z0-9-]{6,64}$/.test(id);
}
/**
* Snapshot one version of a document.
*
* @param {object} args
* @param {string} args.docPath Absolute path of the document
* @param {string} args.content The content to snapshot
* @param {string} [args.label='auto-save'] How it was captured
* @param {number} [args.maxKeep] Prune to this many versions per document
* @param {object} args.io { rootDir, fs, pathUtil, crypto } — rootDir is the
* versions root (main passes <userData>/versions)
* @returns {{id, createdAt, label, wordCount}} the stored version meta
*/
function saveVersion({ docPath, content, label = 'auto-save', maxKeep = DEFAULT_MAX_KEEP, io }) {
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
const dir = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto));
fs.mkdirSync(dir, { recursive: true });
const id = newVersionId();
const createdAt = Date.now();
const wordCount = String(content || '')
.split(/\s+/)
.filter(Boolean).length;
// meta.json is the index; the .md blob is the content
const metaPath = pathUtil.join(dir, 'meta.json');
let meta = { doc: docPath, versions: [] };
try {
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
} catch {
/* first version for this document */
}
if (!Array.isArray(meta.versions)) meta.versions = [];
const entry = { id, createdAt, label, wordCount };
meta.versions.unshift(entry);
// Prune oldest beyond maxKeep (entries are newest-first)
if (meta.versions.length > maxKeep) {
for (const removed of meta.versions.slice(maxKeep)) {
try {
fs.unlinkSync(pathUtil.join(dir, `${removed.id}.md`));
} catch {
/* already gone */
}
}
meta.versions = meta.versions.slice(0, maxKeep);
}
fs.writeFileSync(pathUtil.join(dir, `${id}.md`), String(content ?? ''), 'utf-8');
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
return entry;
}
/**
* List versions for a document, newest first.
* @returns {Array<{id, createdAt, label, wordCount}>}
*/
function listVersions({ docPath, io }) {
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
const metaPath = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto), 'meta.json');
try {
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
return Array.isArray(meta.versions) ? meta.versions : [];
} catch {
return [];
}
}
/**
* Read one version's content.
* @throws when the id is malformed or the version is missing
*/
function readVersion({ docPath, id, io }) {
if (!isValidVersionId(id)) throw new Error('Invalid version id');
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
const file = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto), `${id}.md`);
return fs.readFileSync(file, 'utf-8');
}
/**
* Delete one version (also repairs the index if a blob is already gone).
* @returns {boolean} whether the version was found and removed
*/
function deleteVersion({ docPath, id, io }) {
if (!isValidVersionId(id)) return false;
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
const dir = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto));
const metaPath = pathUtil.join(dir, 'meta.json');
let meta;
try {
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
} catch {
return false;
}
const before = meta.versions?.length || 0;
meta.versions = (meta.versions || []).filter((v) => v.id !== id);
if (meta.versions.length === before) return false;
try {
fs.unlinkSync(pathUtil.join(dir, `${id}.md`));
} catch {
/* blob already missing — index repair is the important part */
}
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
return true;
}
module.exports = {
DEFAULT_MAX_KEEP,
saveVersion,
listVersions,
readVersion,
deleteVersion,
newVersionId,
isValidVersionId,
folderFor,
};
+170
View File
@@ -0,0 +1,170 @@
/**
* Minimal XLSX (Office Open XML spreadsheet) writer for markdown table export.
*
* Builds a valid multi-sheet workbook entirely in-memory with JSZip — no
* spreadsheet library needed. Each markdown table becomes one sheet ("Table 1",
* "Table 2", …) using inlineStr cells (no sharedStrings part), and cells that
* look numeric are emitted as real numbers so Excel can compute on them.
*
* Structure of the produced package:
* [Content_Types].xml
* _rels/.rels → points at xl/workbook.xml
* xl/workbook.xml → sheet list
* xl/_rels/workbook.xml.rels → sheet relationship ids
* xl/styles.xml → minimal valid styles part
* xl/worksheets/sheet<N>.xml → one per table
*
* @module XlsxExporter
*/
const JSZip = require('jszip');
/** Excel column letter for a 0-based column index (0→A, 25→Z, 26→AA…). */
function columnLetter(index) {
let letters = '';
let n = index;
while (n >= 0) {
letters = String.fromCharCode((n % 26) + 65) + letters;
n = Math.floor(n / 26) - 1;
}
return letters;
}
/** Escape text for XML content/attributes. */
function escapeXml(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** Plain integers/decimals become numeric cells (preserves leading zeros as text). */
function looksNumeric(value) {
return /^-?\d+(\.\d+)?$/.test(value) && !/^0\d/.test(value);
}
/**
* Build one sheet's XML from a table (array of rows of strings).
* The first row is emitted as a regular row — headers stay text so sorting
* and filters behave predictably.
*/
function buildSheetXml(table) {
const rows = table
.map((row, rowIndex) => {
const cells = row
.map((cell, colIndex) => {
const ref = `${columnLetter(colIndex)}${rowIndex + 1}`;
if (looksNumeric(cell)) {
return `<c r="${ref}" t="n"><v>${cell}</v></c>`;
}
// xml:space="preserve" keeps leading/trailing spaces users wrote
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(cell)}</t></is></c>`;
})
.join('');
return `<row r="${rowIndex + 1}">${cells}</row>`;
})
.join('');
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
`<sheetData>${rows}</sheetData>` +
'</worksheet>'
);
}
/**
* Assemble an XLSX workbook buffer from extracted markdown tables.
*
* @param {Array<string[][]>} tables Tables as rows of cell strings
* @returns {Promise<Buffer>} .xlsx file contents
*/
async function buildXlsx(tables) {
if (!Array.isArray(tables) || tables.length === 0) {
throw new Error('No tables to export');
}
const zip = new JSZip();
// Sheet names: "Table 1"… — Excel caps names at 31 chars (ours are short)
const sheetNames = tables.map((_, i) => `Table ${i + 1}`);
zip.file(
'[Content_Types].xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
tables
.map(
(_, i) =>
`<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ` +
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
)
.join('') +
'</Types>'
);
zip.file(
'_rels/.rels',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
'</Relationships>'
);
zip.file(
'xl/workbook.xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
'<sheets>' +
sheetNames
.map(
(name, i) => `<sheet name="${escapeXml(name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`
)
.join('') +
'</sheets></workbook>'
);
// Relationship ids rId1..rIdN map to sheets; styles get the next free id
zip.file(
'xl/_rels/workbook.xml.rels',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
tables
.map(
(_, i) =>
`<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`
)
.join('') +
`<Relationship Id="rId${tables.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>` +
'</Relationships>'
);
// Minimal styles part: Excel accepts workbooks without styling, but some
// viewers are picky, so ship the empty default.
zip.file(
'xl/styles.xml',
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>' +
'<fills count="1"><fill><patternFill patternType="none"/></fill></fills>' +
'<borders count="1"><border/></borders>' +
'<cellStyleXfs count="1"><xf/></cellStyleXfs>' +
'<cellXfs count="1"><xf xfId="0"/></cellXfs>' +
'</styleSheet>'
);
tables.forEach((table, i) => {
zip.file(`xl/worksheets/sheet${i + 1}.xml`, buildSheetXml(table));
});
// JSZip 3.x: generateAsync is the API (generate() was removed)
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
}
module.exports = { buildXlsx, buildSheetXml, columnLetter, looksNumeric };