mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
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:
@@ -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,
|
||||
};
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
Reference in New Issue
Block a user