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
+64 -2
View File
@@ -8,7 +8,7 @@ const {
highlightActiveLine,
drawSelection,
} = require('@codemirror/view');
const { EditorState } = require('@codemirror/state');
const { EditorState, Compartment } = require('@codemirror/state');
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
// Language extensions loaded lazily on first use
let _javascript, _html, _css, _json, _python;
@@ -18,6 +18,30 @@ const { autocompletion, completionKeymap } = require('@codemirror/autocomplete')
const { bracketMatching, foldGutter, indentOnInput } = require('@codemirror/language');
const { oneDark } = require('@codemirror/theme-one-dark');
// Compartments allow toggling features (vim mode) on a live editor without
// recreating the view — CodeMirror 6's intended mechanism for reconfiguration.
const vimCompartment = new Compartment();
// Vim is loaded lazily: most users never enable it, and the module is heavy.
let _vimExtension = null;
function getVimExtension() {
if (!_vimExtension) {
_vimExtension = require('@replit/codemirror-vim').vim();
}
return _vimExtension;
}
/**
* Toggle vim keybindings on an editor created by createEditor.
* @param {EditorView} view
* @param {boolean} enabled
*/
function setVimMode(view, enabled) {
if (!view) return;
view.dispatch({
effects: vimCompartment.reconfigure(enabled ? getVimExtension() : []),
});
}
// Custom theme for JetBrains Mono font
const jetBrainsMonoTheme = EditorView.theme({
'&': {
@@ -31,6 +55,37 @@ const jetBrainsMonoTheme = EditorView.theme({
},
});
/**
* Build the Tab keymap entry that expands snippets before falling through to
* indent. `getExpansion(prefix)` is synchronous and returns the replacement
* text (or null to decline), letting the host keep its snippets in a Map.
*/
function snippetTabKeymap(getExpansion) {
return {
key: 'Tab',
run: (view) => {
if (typeof getExpansion !== 'function') return false;
const { head } = view.state.selection.main;
const line = view.state.doc.lineAt(head);
// Word-before-cursor: letters, digits, underscore and hyphen
const before = line.text.slice(0, head - line.from);
const match = /[\w-]+$/.exec(before);
if (!match) return false;
const expansion = getExpansion(match[0].toLowerCase());
if (typeof expansion !== 'string') return false;
view.dispatch({
changes: {
from: head - match[0].length,
to: head,
insert: expansion,
},
selection: { anchor: head - match[0].length + expansion.length },
});
return true;
},
};
}
/**
* Create a CodeMirror 6 editor instance.
*
@@ -41,6 +96,8 @@ const jetBrainsMonoTheme = EditorView.theme({
* @param {Function} options.onUpdate - called with the EditorView on every update (selection, doc change, etc.)
* @param {boolean} options.isDark - apply oneDark theme when true (default false)
* @param {boolean} options.showLineNumbers - show line-number gutter (default true)
* @param {boolean} options.vimMode - start with vim keybindings (default false; toggle later via setVimMode)
* @param {Function} options.getTabExpansion - (prefix)=>string|null; Tab expands a matching snippet (default null)
* @returns {EditorView} the created editor view
*/
function createEditor(parentElement, options = {}) {
@@ -62,6 +119,8 @@ function createEditor(parentElement, options = {}) {
onUpdate = null,
isDark = false,
showLineNumbers = true,
vimMode = false,
getTabExpansion = null,
} = options;
const extensions = [
@@ -75,13 +134,16 @@ function createEditor(parentElement, options = {}) {
autocompletion(),
foldGutter(),
jetBrainsMonoTheme,
// Snippet Tab expansion must precede indentWithTab so it wins when matched
keymap.of([
...(getTabExpansion ? [snippetTabKeymap(getTabExpansion)] : []),
...defaultKeymap,
...historyKeymap,
...searchKeymap,
...completionKeymap,
indentWithTab,
]),
vimCompartment.of(vimMode ? getVimExtension() : []),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
@@ -146,4 +208,4 @@ function getLanguageExtension(lang) {
return loader ? loader() : markdown({ base: markdownLanguage });
}
module.exports = { createEditor, getLanguageExtension };
module.exports = { createEditor, getLanguageExtension, setVimMode, snippetTabKeymap };
+36 -4
View File
@@ -22,6 +22,8 @@
<link rel="stylesheet" href="styles-welcome.css" />
<link rel="stylesheet" href="styles-zen.css" />
<link rel="stylesheet" href="../node_modules/highlight.js/styles/default.css" />
<!-- KaTeX math rendering — bundled locally (fonts included) for offline use -->
<link rel="stylesheet" href="../assets/katex/katex.min.css" />
</head>
<body>
<!-- Skip link for keyboard navigation -->
@@ -1256,11 +1258,9 @@
<div class="converter-options pandoc-options hidden">
<div class="export-section">
<small
>Pandoc conversions can use advanced export options from the Export menu.</small
>Pandoc conversions use the same engine as the Export menu. For advanced options
(metadata, TOC, templates, bibliography), use File → Export instead.</small
>
<button id="pandoc-show-export-options" type="button">
Open Export Options Dialog...
</button>
</div>
</div>
</div>
@@ -2506,6 +2506,38 @@
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</button>
<!-- Backlinks: local knowledge-base panel (wiki-link graph) -->
<button class="sidebar-icon" data-panel="backlinks" title="Backlinks">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</button>
<!-- History: local version history (snapshots before saves) -->
<button class="sidebar-icon" data-panel="history" title="Version History">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</button>
</div>
<div class="sidebar-panel" id="sidebar-panel">
<div class="sidebar-panel-header">
+430 -14
View File
@@ -347,10 +347,17 @@ ipcMain.handle('set-monospace-settings', (_event, partial) => {
store.set('monospaceLigatures', safe.monospaceLigatures === true);
}
return {
monospaceFont: readSettingsJsonCached().monospaceFont || 'jetbrains-mono',
monospaceLigatures: readSettingsJsonCached().monospaceLigatures === true,
monospaceFont: store.get('monospaceFont', 'jetbrains-mono'),
monospaceLigatures: store.get('monospaceLigatures', true),
};
});
// Vim keybinding mode — same read/toggle/persist flow as the monospace font
ipcMain.handle('get-vim-mode', () => store.get('vimMode', false) === true);
ipcMain.handle('set-vim-mode', (_event, enabled) => {
store.set('vimMode', enabled === true);
return enabled === true;
});
ipcMain.handle('get-app-version', () => app.getVersion());
// ============================================
@@ -509,6 +516,11 @@ if (!gotTheLock) {
mainWindow.focus();
}
// Deep link: markdownconverter://open?path=<encoded absolute path>
// (Windows/Linux deliver protocol URLs through the command line)
const deepLink = commandLine.find((arg) => arg.startsWith('markdownconverter://'));
if (deepLink && handleDeepLink(deepLink)) return;
// Check if a file was passed to the second instance
// commandLine is an array like: ['PanConverter.exe', 'file.md']
@@ -842,7 +854,8 @@ function createMenu() {
{
label: 'PDF (Enhanced)',
click: () => exportPDFViaWordTemplate(),
accelerator: 'Ctrl+Shift+P',
// Ctrl+Shift+P is the Command Palette; use Alt to avoid the collision
accelerator: 'Ctrl+Alt+Shift+P',
},
{
label: 'DOCX',
@@ -887,6 +900,10 @@ function createMenu() {
label: 'CSV (Tables)',
click: () => exportSpreadsheet('csv'),
},
{
label: 'Excel Spreadsheet (.xlsx)',
click: () => exportSpreadsheet('xlsx'),
},
{
type: 'separator',
},
@@ -1073,6 +1090,18 @@ function createMenu() {
label: 'Templates',
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates'),
},
{
label: 'Outline',
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'outline'),
},
{
label: 'Backlinks',
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'backlinks'),
},
{
label: 'Version History',
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'history'),
},
],
},
{
@@ -1264,6 +1293,15 @@ function createMenu() {
mainWindow.webContents.session.setSpellCheckerEnabled(menuItem.checked);
},
},
{
// Vim keybindings for the editor (toggled live via setVimMode)
label: 'Vim Mode',
type: 'checkbox',
checked: store.get('vimMode', false) === true,
click: (menuItem) => {
mainWindow.webContents.send('vim-setting-change', menuItem.checked);
},
},
{
type: 'separator',
},
@@ -1453,7 +1491,7 @@ function createMenu() {
title: 'About PDF Editor',
message: 'PDF Editor',
detail:
'Comprehensive PDF editing capabilities powered by pdf-lib and pdfjs-dist.\n\nFeatures:\n• Merge multiple PDF files\n• Split PDF into separate files\n• Compress PDF to reduce file size\n• Rotate pages (90°, 180°, 270°)\n• Delete unwanted pages\n• Reorder pages\n• Add text watermarks\n• Add page numbers\n• Crop pages\n• Extract text\n• Extract embedded images\n\nSecurity Features:\n• Password protection (encryption)\n• Remove passwords (decryption)\n• Set document permissions\n\n100% offline and open-source.',
'Comprehensive PDF editing capabilities powered by @cantoo/pdf-lib and pdfjs-dist.\n\nFeatures:\n• Merge multiple PDF files\n• Split PDF into separate files\n• Compress PDF to reduce file size\n• Rotate pages (90°, 180°, 270°)\n• Delete unwanted pages\n• Reorder pages\n• Add text watermarks\n• Add page numbers\n• Crop pages\n• Extract text\n• Extract embedded images\n\nSecurity Features:\n• Password protection (encryption)\n• Remove passwords (decryption)\n• Set document permissions\n\n100% offline and open-source.',
buttons: ['OK'],
});
},
@@ -1476,6 +1514,12 @@ function createMenu() {
{
type: 'separator',
},
{
// Global scratchpad (Ctrl+Alt+Q works even when the app is unfocused)
label: 'Quick Note',
accelerator: 'CmdOrCtrl+Alt+Q',
click: () => openQuickNoteWindow(),
},
{
label: 'Document Compare',
click: () => mainWindow.webContents.send('show-document-compare'),
@@ -1722,7 +1766,7 @@ function showDependenciesDialog() {
<h2>Bundled Libraries</h2>
<div class="dep-card">
<div class="dep-name">pdf-lib</div>
<div class="dep-name">@cantoo/pdf-lib</div>
<div class="dep-desc">PDF manipulation library for merge, split, watermark, and encryption features.</div>
</div>
@@ -2487,6 +2531,17 @@ function checkConverterAvailable(tool) {
resolve(fs.existsSync(ffmpegBin) || ffmpegBin === 'ffmpeg');
return;
}
case 'pandoc': {
// Pandoc is bundled (bin/<platform>/pandoc) with a system-PATH fallback
const pandocBin = getPandocPath();
if (pandocBin && pandocBin !== 'pandoc') {
resolve(fs.existsSync(pandocBin));
return;
}
// Bundled binary missing — fall through to a PATH lookup
toolName = 'pandoc';
break;
}
default:
resolve(false);
return;
@@ -3218,17 +3273,46 @@ function exportWithPandoc(pandocArgs, outputFile, format, onComplete) {
}
}
// Set page size for ODT
// Set page size for ODT (ODF styles.xml — the DOCX-oriented
// setDocxPageSize cannot patch an ODT package)
if (format === 'odt') {
try {
await setDocxPageSize(outputFile); // ODT has similar structure
const { setOdtPageSize } = require('./main/OdtStyling');
await setOdtPageSize(outputFile, { ...pageSettings, pageSizes: PAGE_SIZES });
} catch (pageSizeError) {
console.error('Error setting page size for ODT:', pageSizeError);
}
}
// Add headers/footers to ODT if enabled
// Add headers/footers to ODT if enabled (mirrors the DOCX path above)
if (format === 'odt' && headerFooterSettings.enabled) {
try {
const filename = currentFile
? path.basename(currentFile, path.extname(currentFile))
: 'document';
const metadata = {
filename: filename,
title: filename,
author: '',
};
const { addHeaderFooterToOdt } = require('./main/OdtStyling');
await addHeaderFooterToOdt(outputFile, {
enabled: headerFooterSettings.enabled,
header: {
left: processDynamicFields(headerFooterSettings.header.left, metadata),
center: processDynamicFields(headerFooterSettings.header.center, metadata),
right: processDynamicFields(headerFooterSettings.header.right, metadata),
},
footer: {
left: processDynamicFields(headerFooterSettings.footer.left, metadata),
center: processDynamicFields(headerFooterSettings.footer.center, metadata),
right: processDynamicFields(headerFooterSettings.footer.right, metadata),
},
});
} catch (hfError) {
console.error('Error adding headers/footers to ODT:', hfError);
// Continue with success message even if header/footer fails
}
}
showExportSuccess(outputFile);
}
@@ -3640,6 +3724,24 @@ function setTheme(theme) {
// IPC handlers
ipcMain.on('save-file', (event, { path, content }) => {
// Version history: snapshot the on-disk content before it is replaced so
// the user can roll back from the History panel. First save of a new file
// (or unchanged content) skips the snapshot to keep noise out of the list.
try {
if (fs.existsSync(path)) {
const previous = fs.readFileSync(path, 'utf-8');
if (previous !== content) {
VersionHistory.saveVersion({
docPath: path,
content: previous,
label: 'before save',
io: versionHistoryIo(),
});
}
}
} catch (historyError) {
console.error('Version history snapshot failed:', historyError);
}
fs.writeFileSync(path, content, 'utf-8');
currentFile = path;
});
@@ -3663,6 +3765,51 @@ ipcMain.on('set-current-file', (event, filePath) => {
currentFile = filePath;
});
// ============================================
// Version History (local rollback without Git)
// ============================================
const VersionHistory = require('./main/VersionHistory');
/** IO bundle for VersionHistory bound to <userData>/versions. */
function versionHistoryIo() {
return {
rootDir: path.join(app.getPath('userData'), 'versions'),
fs,
pathUtil: path,
};
}
ipcMain.handle('version-history:list', (_event, docPath) => {
if (typeof docPath !== 'string' || !docPath) return [];
// Never leak other documents' history via a guessed path
const validation = validatePath(docPath);
if (!validation.valid) return [];
return VersionHistory.listVersions({ docPath, io: versionHistoryIo() });
});
ipcMain.handle('version-history:read', (_event, { docPath, id } = {}) => {
const validation = typeof docPath === 'string' ? validatePath(docPath) : { valid: false };
if (!validation.valid) throw new Error('Invalid document path');
return VersionHistory.readVersion({ docPath, id, io: versionHistoryIo() });
});
ipcMain.handle('version-history:save', (_event, { docPath, content, label } = {}) => {
const validation = typeof docPath === 'string' ? validatePath(docPath) : { valid: false };
if (!validation.valid) throw new Error('Invalid document path');
return VersionHistory.saveVersion({
docPath,
content,
label: typeof label === 'string' ? label : 'manual',
io: versionHistoryIo(),
});
});
ipcMain.handle('version-history:delete', (_event, { docPath, id } = {}) => {
const validation = typeof docPath === 'string' ? validatePath(docPath) : { valid: false };
if (!validation.valid) return false;
return VersionHistory.deleteVersion({ docPath, id, io: versionHistoryIo() });
});
// Handle actual printing when renderer is ready
ipcMain.on('do-print', (event, { withStyles }) => {
if (mainWindow) {
@@ -3800,6 +3947,11 @@ ipcMain.on('export-spreadsheet', (event, { content, format }) => {
});
});
fs.writeFileSync(outputFile, csvContent, 'utf-8');
} else if (format === 'xlsx') {
// Native Excel workbook: one sheet per markdown table (no Pandoc needed)
const { buildXlsx } = require('./main/XlsxExporter');
const buffer = buildXlsx(tables);
fs.writeFileSync(outputFile, buffer);
}
dialog.showMessageBox(mainWindow, {
type: 'info',
@@ -4441,13 +4593,13 @@ function buildCLIConversionArgs(content, format, outputPath) {
}
break;
case 'html':
args.push('--self-contained', '--css');
// Standalone HTML; a bare --css (or --self-contained, removed in Pandoc 3.x)
// without a value is invalid argv and makes pandoc exit with an error.
args.push('--standalone');
break;
case 'docx':
args.push('--reference-doc');
// For DOCX, header/footer are handled via reference document or separate processing
// We'll add a note that DOCX headers/footers require reference doc or post-processing
// DOCX headers/footers are applied via addHeaderFooterToDocx() post-processing
// after export; a bare --reference-doc without a value would be invalid argv.
break;
case 'odt':
// ODT headers/footers are handled via reference document
@@ -4608,6 +4760,58 @@ app.on('open-file', (event, filePath) => {
}
});
// ============================================
// Deep link protocol: markdownconverter://open?path=<encoded abs path>
// ============================================
// Lets external tools (browsers, launchers, note-taking scripts) hand a
// document straight to a running instance. Only the 'open' action exists;
// the path must be absolute, exist, and pass the same size guard as normal
// opens. Returns true when the link was consumed.
function handleDeepLink(urlString) {
let url;
try {
url = new URL(urlString);
} catch {
return false;
}
if (url.protocol !== 'markdownconverter:') return false;
const action = url.hostname || url.pathname.replace(/^\/+/, '');
if (action !== 'open') {
dialog.showErrorBox('Deep Link', `Unknown action "${action}". Only "open" is supported.`);
return true;
}
const target = url.searchParams.get('path');
if (!target || !path.isAbsolute(target)) {
dialog.showErrorBox('Deep Link', 'The link must include an absolute ?path=… parameter.');
return true;
}
const validation = validatePath(target);
if (!validation.valid || !fs.existsSync(validation.resolved)) {
dialog.showErrorBox('Deep Link', 'The linked file does not exist or is not accessible.');
return true;
}
if (rendererReady) {
openFileFromPath(validation.resolved);
} else {
app.pendingFile = validation.resolved;
}
return true;
}
// macOS delivers protocol clicks as open-url events
app.on('open-url', (event, urlString) => {
event.preventDefault();
handleDeepLink(urlString);
});
// Register the custom protocol once the app is ready (idempotent)
app.whenReady().then(() => {
app.setAsDefaultProtocolClient('markdownconverter');
});
// Handle file opening from command line or file association
function openFileFromPath(filePath) {
if (fs.existsSync(filePath)) {
@@ -4654,12 +4858,77 @@ ipcMain.on('process-pdf-operation', async (event, data) => {
});
}
});
// Reports pdf-lib's encryption capability (Task 27) so the renderer can
// Reports the PDF library's encryption capability so the renderer can
// disable the password-protection controls instead of letting the user
// fill the form only to see the operation fail.
ipcMain.handle('get-pdf-capabilities', async () => ({
passwordProtection: await PDFOperations.pdfEncryptionSupported,
}));
// ================================
// AI Assistant plugin (main-process side)
// ================================
// All AI provider traffic flows through the main process: the renderer CSP
// stays closed to AI endpoints and API keys never cross the IPC boundary.
// Provider settings live in settings.json under plugins.ai-assistant.*
// (written by the plugin settings store), so the handlers below read them
// with the same store the rest of the app uses.
const AiProviders = require('./main/AiProviders');
/** Read the AI Assistant plugin's provider settings from the settings store. */
function getAiAssistantSettings() {
return {
provider: store.get('plugins.ai-assistant.provider', ''),
model: store.get('plugins.ai-assistant.model', ''),
baseUrl: store.get('plugins.ai-assistant.baseUrl', ''),
apiKey: store.get('plugins.ai-assistant.apiKey', ''),
temperature: store.get('plugins.ai-assistant.temperature', 0.7),
};
}
/**
* Report provider/model and whether a usable configuration exists — without
* ever returning the key material to the renderer.
*/
ipcMain.handle('ai-assistant:status', async () => {
const settings = getAiAssistantSettings();
let configured = false;
try {
// resolveSettings throws when the provider is unknown or a required key
// is missing — exactly the "not configured" definition we want.
AiProviders.resolveSettings(settings);
configured = true;
} catch {
configured = false;
}
return {
configured,
provider: settings.provider,
model: settings.model || AiProviders.PROVIDER_DEFAULTS[settings.provider]?.defaultModel || '',
};
});
/**
* Run a chat completion. The payload is {system?, messages:[{role,content}]}
* with provider settings taken from the store, never from the caller — a
* compromised renderer cannot redirect requests to an attacker endpoint.
*/
ipcMain.handle('ai-assistant:complete', async (_event, request) => {
const settings = getAiAssistantSettings();
try {
return await AiProviders.complete({ ...request, ...settings });
} catch (error) {
// AiProviderError messages are already user-safe; wrap anything else
return {
error:
error.code === undefined
? 'AI request failed. Check the provider settings and your connection.'
: error.message,
code: error.code || 'failed',
};
}
});
ipcMain.on('get-pdf-page-count', async (event, filePath) => {
try {
const count = await PDFOperations.getPageCount(filePath);
@@ -5073,6 +5342,108 @@ ipcMain.on('open-table-generator', () => {
openTableGenerator();
});
// ============================================
// Quick Note — global scratchpad window (Ctrl+Alt+Q)
// ============================================
// A small always-quick-to-open window for capturing thoughts without
// disturbing the main workspace. Notes append to <userData>/notes/quick-notes.md
// with a timestamp header, so nothing is ever lost and the file is plain
// markdown the user can open in the main editor later.
let quickNoteWindow = null;
const quickNotesPath = () => path.join(app.getPath('userData'), 'notes', 'quick-notes.md');
function openQuickNoteWindow() {
if (quickNoteWindow) {
quickNoteWindow.show();
quickNoteWindow.focus();
return;
}
quickNoteWindow = new BrowserWindow({
width: 420,
height: 320,
alwaysOnTop: true,
skipTaskbar: false,
title: 'Quick Note',
icon: path.join(__dirname, '../assets/icon.png'),
webPreferences: {
// Small self-contained page; nodeIntegration lets it save via IPC
// without shipping a dedicated preload for one textarea.
nodeIntegration: true,
contextIsolation: false,
},
});
quickNoteWindow.setMenuBarVisibility(false);
quickNoteWindow.loadURL(
'data:text/html;charset=utf-8,' +
encodeURIComponent(`<!doctype html>
<html><head><meta charset="utf-8"><title>Quick Note</title>
<style>
body { margin:0; font: 13px system-ui, sans-serif; display:flex; flex-direction:column; height:100vh; }
textarea { flex:1; border:0; resize:none; padding:12px; font:inherit; box-sizing:border-box; outline:none; }
footer { display:flex; justify-content:space-between; align-items:center; padding:6px 12px;
border-top:1px solid #ddd; color:#666; font-size:11px; }
button { padding:4px 12px; cursor:pointer; }
</style></head>
<body>
<textarea id="note" placeholder="Type a note… (Ctrl+Enter saves & clears, Esc hides)"></textarea>
<footer>
<span id="status">Appends to notes/quick-notes.md</span>
<button id="save" type="button">Save note</button>
</footer>
<script>
const { ipcRenderer } = require('electron');
const note = document.getElementById('note');
const status = document.getElementById('status');
function save() {
const text = note.value.trim();
if (!text) return;
ipcRenderer.invoke('quick-note:save', text).then(() => {
note.value = '';
status.textContent = 'Saved ' + new Date().toLocaleTimeString();
}).catch((e) => { status.textContent = 'Save failed: ' + e.message; });
}
document.getElementById('save').addEventListener('click', save);
note.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); }
if (e.key === 'Escape') ipcRenderer.send('quick-note:hide');
});
note.focus();
</script>
</body></html>`)
);
quickNoteWindow.on('closed', () => {
quickNoteWindow = null;
});
}
ipcMain.handle('quick-note:save', async (_event, text) => {
// Append with a timestamp header; folder is created on first save
const file = quickNotesPath();
fs.mkdirSync(path.dirname(file), { recursive: true });
const stamp = new Date().toISOString().replace('T', ' ').slice(0, 16);
fs.appendFileSync(file, `\n\n## ${stamp}\n\n${String(text)}\n`, 'utf-8');
return { path: file };
});
// Esc in the note window hides instead of closing (keeps it one keystroke away)
ipcMain.on('quick-note:hide', () => {
if (quickNoteWindow) quickNoteWindow.hide();
});
// Global shortcut works even when the app is not focused — the point of a
// scratchpad. Registered on ready, unregistered on quit.
app.whenReady().then(() => {
const { globalShortcut } = require('electron');
const registered = globalShortcut.register('CommandOrControl+Alt+Q', () => {
openQuickNoteWindow();
});
if (!registered) console.warn('Quick Note shortcut Ctrl+Alt+Q could not be registered');
});
app.on('will-quit', () => {
const { globalShortcut } = require('electron');
globalShortcut.unregister('CommandOrControl+Alt+Q');
});
// IPC Handler for loading document templates
ipcMain.handle('load-template', async (event, filename) => {
try {
@@ -5084,6 +5455,51 @@ ipcMain.handle('load-template', async (event, filename) => {
}
});
// ============================================
// PlantUML local rendering (optional CLI)
// ============================================
// When a local `plantuml` executable is installed, diagrams render on the
// user's machine via `-pipe` mode (diagram source on stdin, PNG on stdout)
// instead of POSTing them to plantuml.com (the CVE-MC-007 data-exposure
// concern). Availability is probed once and cached.
let plantumlAvailableCache = null;
ipcMain.handle('plantuml:available', async () => {
if (plantumlAvailableCache !== null) return plantumlAvailableCache;
plantumlAvailableCache = await new Promise((resolve) => {
const isWin = process.platform === 'win32';
execFile(isWin ? 'where' : 'which', [isWin ? 'plantuml.exe' : 'plantuml'], (error) => {
resolve(!error);
});
});
return plantumlAvailableCache;
});
ipcMain.handle('plantuml:render', async (_event, { text } = {}) => {
if (typeof text !== 'string' || text.length > 1024 * 1024) {
throw new Error('Invalid PlantUML input');
}
return new Promise((resolve, reject) => {
// -pipe reads the diagram from stdin and writes PNG bytes to stdout
const child = execFile(
'plantuml',
['-tpng', '-pipe', '-charset', 'UTF-8'],
{ timeout: 30000, maxBuffer: 20 * 1024 * 1024, encoding: 'buffer' },
(error, stdout) => {
if (error) {
reject(new Error('Local PlantUML rendering failed'));
return;
}
resolve({ dataUrl: 'data:image/png;base64,' + stdout.toString('base64') });
}
);
if (child.stdin) {
child.stdin.on('error', () => {}); // EPIPE if plantuml dies early
child.stdin.write(text, 'utf-8');
child.stdin.end();
}
});
});
// IPC Handler for saving pasted/dropped images
ipcMain.handle('save-pasted-image', async (event, { base64, ext }) => {
try {
+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 };
@@ -0,0 +1,164 @@
/**
* AI Assistant chat sidebar panel.
*
* Renders a compact chat interface (message list, composer, status badge) and
* wires it to the plugin's IPC-backed completion helper. All network work
* happens in the main process; this file only manages conversation state
* (history is kept in memory for the session, not persisted).
*
* @module AiChatPanel
*/
/** Simple HTML escape for all user/model text before insertion. */
function esc(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Render the chat panel into its sidebar container.
*
* @param {HTMLElement} container Sidebar panel content element
* @param {object} deps
* @param {Function} deps.complete async ({system, messages}) => {content}
* @param {Function} deps.insertAtCursor (text) => void — insert reply in doc
* @param {Function} deps.getSelection () => string — for "use selection"
* @param {{provider:string, model:string, configured:boolean}} deps.statusInfo
* @param {Function} deps.onOpenSettings () => void — jump to provider config
*/
function renderChatPanel(container, deps) {
container.replaceChildren();
const panel = document.createElement('div');
panel.className = 'ws-panel ai-chat-panel';
panel.innerHTML = `
<div class="ws-section">
<div class="ai-chat-status">
<span class="ai-chat-badge" data-role="badge"></span>
<button class="ws-btn ws-btn-sm" data-role="settings" type="button">Setup…</button>
</div>
<div class="ai-chat-messages" data-role="messages" aria-live="polite"></div>
<div class="ai-chat-composer">
<textarea data-role="input" rows="3" placeholder="Ask about your document…
(Shift+Enter for newline)"></textarea>
<div class="ai-chat-actions">
<button class="ws-btn" data-role="use-selection" type="button"
title="Insert the current editor selection into the composer">Use selection</button>
<button class="ws-btn ws-btn-primary" data-role="send" type="button">Send</button>
</div>
<div class="ai-chat-footer">
<button class="ws-btn ws-btn-sm" data-role="insert-last" type="button"
title="Insert the last assistant reply at the cursor">Insert last reply</button>
<button class="ws-btn ws-btn-sm" data-role="clear" type="button">Clear chat</button>
</div>
</div>
</div>`;
container.appendChild(panel);
// --- Element handles -----------------------------------------------------
const badge = panel.querySelector('[data-role="badge"]');
const messagesEl = panel.querySelector('[data-role="messages"]');
const inputEl = panel.querySelector('[data-role="input"]');
const sendBtn = panel.querySelector('[data-role="send"]');
const insertLastBtn = panel.querySelector('[data-role="insert-last"]');
const useSelectionBtn = panel.querySelector('[data-role="use-selection"]');
const clearBtn = panel.querySelector('[data-role="clear"]');
const settingsBtn = panel.querySelector('[data-role="settings"]');
// --- Conversation state (session-scoped) ---------------------------------
const history = []; // {role: 'user'|'assistant', content}
let lastAssistantReply = '';
let busy = false;
function renderBadge() {
const { provider, model, configured } = deps.statusInfo;
badge.textContent = configured ? `${provider} · ${model}` : 'Not configured';
badge.classList.toggle('ai-chat-badge-ok', Boolean(configured));
badge.classList.toggle('ai-chat-badge-off', !configured);
sendBtn.disabled = !configured || busy;
}
function appendMessage(role, text) {
const item = document.createElement('div');
item.className = `ai-chat-msg ai-chat-msg-${role}`;
item.innerHTML = `<div class="ai-chat-msg-role">${role === 'user' ? 'You' : 'AI'}</div>
<div class="ai-chat-msg-body">${esc(text)}</div>`;
messagesEl.appendChild(item);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function appendBusy() {
const item = document.createElement('div');
item.className = 'ai-chat-msg ai-chat-msg-assistant ai-chat-msg-busy';
item.dataset.role = 'busy';
item.textContent = 'Thinking…';
messagesEl.appendChild(item);
messagesEl.scrollTop = messagesEl.scrollHeight;
return item;
}
async function send() {
const text = inputEl.value.trim();
if (!text || busy || !deps.statusInfo.configured) return;
busy = true;
renderBadge();
inputEl.value = '';
appendMessage('user', text);
history.push({ role: 'user', content: text });
const busyEl = appendBusy();
try {
// Keep a rolling window of the last 12 turns so long chats don't grow
// request payloads unboundedly.
const window = history.slice(-12);
const { content } = await deps.complete({ messages: window });
lastAssistantReply = content;
history.push({ role: 'assistant', content });
appendMessage('assistant', content);
} catch (error) {
appendMessage('assistant', `Error: ${error.message || 'request failed'}`);
} finally {
busyEl.remove();
busy = false;
renderBadge();
inputEl.focus();
}
}
// --- Wire events ----------------------------------------------------------
sendBtn.addEventListener('click', send);
inputEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
send();
}
});
useSelectionBtn.addEventListener('click', () => {
const sel = deps.getSelection();
if (sel) inputEl.value += (inputEl.value ? '\n\n' : '') + sel;
inputEl.focus();
});
insertLastBtn.addEventListener('click', () => {
if (lastAssistantReply) deps.insertAtCursor(lastAssistantReply);
});
clearBtn.addEventListener('click', () => {
history.length = 0;
lastAssistantReply = '';
messagesEl.replaceChildren();
});
settingsBtn.addEventListener('click', () => deps.onOpenSettings());
renderBadge();
if (!deps.statusInfo.configured) {
appendMessage(
'assistant',
'Set up a provider first (OpenAI, Anthropic, or a local Ollama/LM Studio server) via the Setup button.'
);
}
}
module.exports = { renderChatPanel };
+293
View File
@@ -0,0 +1,293 @@
/**
* AI Assistant built-in plugin.
*
* Brings LLM assistance into the editor with multi-provider support:
* - OpenAI / Anthropic (cloud, API key required)
* - Ollama / LM Studio / any OpenAI-compatible endpoint (local, no key)
*
* All provider traffic is proxied through the main process
* (ai-assistant:complete / ai-assistant:status IPC) so API keys never enter
* the renderer and the renderer CSP stays closed to AI endpoints.
*
* Extension points:
* - Sidebar "AI Chat" panel (session-scoped conversation, insert replies)
* - Commands: Summarize / Improve / Explain / Translate selection
* - EventBus `ai:analyze` handler — powers the writing-studio Proofread
* panel, which was built waiting for exactly this plugin
*
* @module ai-assistant
*/
const { PluginAPI } = require('../../../plugins/plugin-api');
const { renderChatPanel } = require('./chat-panel');
const { buildTaskPrompt, buildProofreadPrompt, parseProofreadIssues } = require('./prompts');
class AiAssistantPlugin extends PluginAPI {
init(context) {
this.context = context;
// Cached provider status; refreshed from main whenever settings change
this._statusInfo = { provider: '', model: '', configured: false };
this._settingsModalOpen = false;
this._refreshStatus();
this._registerSidebar(context);
this._registerCommands(context);
this._registerAnalyzeHandler(context);
}
/** Fetch provider/model/configured from the main process (no key material). */
async _refreshStatus() {
try {
const status = await this.context.ipc.invoke('ai-assistant:status', null);
this._statusInfo = {
provider: status.provider || '',
model: status.model || '',
configured: Boolean(status.configured),
};
} catch {
this._statusInfo = { provider: '', model: '', configured: false };
}
}
_registerSidebar(context) {
// Panel body + rail icon: PluginContext namespaces the panel id to
// "ai-assistant:chat"; SidebarManager creates the rail icon from the
// inline SVG so the plugin needs no app-shell edits.
context.sidebar.registerPanel('chat', {
title: 'AI Chat',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"></path>
<path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9z"></path>
</svg>`,
render: (container) =>
renderChatPanel(container, {
complete: (request) => this._complete(request),
insertAtCursor: (text) => context.editor.insertAtCursor(text),
getSelection: () => context.editor.getSelection() || '',
statusInfo: this._statusInfo,
onOpenSettings: () => this._openSettingsModal(),
}),
});
}
_registerCommands(context) {
const runOnSelection = async (action, extra) => {
const selection = context.editor.getSelection();
const text = selection && selection.trim() ? selection : context.editor.getContent() || '';
if (!text.trim()) return;
try {
const { system, user } = buildTaskPrompt(action, text, extra);
const { content } = await this._complete({
system,
messages: [{ role: 'user', content: user }],
});
if (action === 'improve' && selection && selection.trim()) {
// Improving an explicit selection replaces it at the cursor
context.editor.insertAtCursor(content);
} else {
context.editor.insertAtCursor(`\n\n${content}`);
}
} catch (error) {
console.warn('[ai-assistant] task failed:', error.message);
}
};
context.commands.register('summarize-selection', 'AI: Summarize Selection', () =>
runOnSelection('summarize')
);
context.commands.register('improve-selection', 'AI: Improve Writing', () =>
runOnSelection('improve')
);
context.commands.register('explain-selection', 'AI: Explain Selection', () =>
runOnSelection('explain')
);
context.commands.register('translate-selection', 'AI: Translate Selection', () => {
// window.prompt() is not supported in Electron renderers — use a small
// inline dialog instead. Cancelling leaves everything untouched.
this._inputBox('Translate to which language?', 'e.g. French, Japanese').then((target) => {
if (target) runOnSelection('translate', target);
});
});
context.commands.register('open-chat', 'AI: Open Chat', () => {
this._openSettingsModalRefresh();
// Ask the host to expand our sidebar panel (wired via the event bus)
this.context.events.emit('sidebar:open-panel', { panel: 'ai-assistant:chat' });
});
}
/**
* Minimal promise-based text input dialog (Electron has no window.prompt).
* Resolves with the text, or null when cancelled.
* @returns {Promise<string|null>}
*/
_inputBox(label, placeholder) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'ai-settings-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML = `
<div class="ai-settings-dialog">
<h3>${label}</h3>
<input type="text" data-role="input" placeholder="${placeholder || ''}" />
<div class="ai-settings-actions">
<button data-role="ok" type="button" class="ws-btn ws-btn-primary">OK</button>
<button data-role="cancel" type="button" class="ws-btn">Cancel</button>
</div>
</div>`;
const input = overlay.querySelector('[data-role="input"]');
const done = (value) => {
overlay.remove();
resolve(value);
};
overlay
.querySelector('[data-role="ok"]')
.addEventListener('click', () => done(input.value.trim() || null));
overlay.querySelector('[data-role="cancel"]').addEventListener('click', () => done(null));
overlay.addEventListener('click', (event) => {
if (event.target === overlay) done(null);
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') done(input.value.trim() || null);
if (event.key === 'Escape') done(null);
});
document.body.appendChild(overlay);
input.focus();
});
}
/**
* Fulfill the writing-studio proofread contract: listen for `ai:analyze`
* events ({text, type, callback}) and answer with {issues: [...]} parsed
* from the model's JSON reply.
*/
_registerAnalyzeHandler(context) {
context.events.on('ai:analyze', async (payload) => {
if (!payload || typeof payload.callback !== 'function') return;
if (!this._statusInfo.configured) {
payload.callback({
issues: [
{
type: 'setup',
message: 'AI Assistant is not configured. Set a provider via AI: Open Chat → Setup.',
suggestion: '',
},
],
});
return;
}
try {
const { system, user } = buildProofreadPrompt(String(payload.text || ''));
const { content } = await this._complete({
system,
messages: [{ role: 'user', content: user }],
});
payload.callback({ issues: parseProofreadIssues(content) });
} catch (error) {
payload.callback({ issues: [{ type: 'error', message: error.message, suggestion: '' }] });
}
});
}
/**
* Shared completion helper: main process owns provider settings, so the
* plugin only sends the prompt payload.
*/
async _complete(request) {
await this._refreshStatus();
if (!this._statusInfo.configured) {
throw new Error('AI Assistant is not configured (see AI Chat → Setup)');
}
return this.context.ipc.invoke('ai-assistant:complete', request);
}
/**
* Tiny settings modal rendered by the plugin itself (plugin settings have
* no host-provided UI yet). Persists through the plugin settings store,
* which lands in settings.json main-side.
*/
_openSettingsModalRefresh() {
this._refreshStatus();
}
_openSettingsModal() {
if (this._settingsModalOpen) return;
this._settingsModalOpen = true;
const overlay = document.createElement('div');
overlay.className = 'ai-settings-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML = `
<div class="ai-settings-dialog">
<h3>AI Assistant Settings</h3>
<label>Provider
<select data-role="provider">
<option value="ollama">Ollama (local)</option>
<option value="lmstudio">LM Studio (local)</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="openai-compatible">OpenAI-compatible…</option>
</select>
</label>
<label>Model <input data-role="model" type="text" placeholder="e.g. llama3.1, gpt-4o-mini" /></label>
<label>Base URL <input data-role="baseUrl" type="text" placeholder="http://localhost:11434/v1" /></label>
<label>API Key <input data-role="apiKey" type="password" placeholder="(not needed for local)" /></label>
<label>Temperature <input data-role="temperature" type="number" min="0" max="2" step="0.1" /></label>
<p class="ai-settings-note">Settings are stored locally in settings.json. API keys never leave this machine.</p>
<div class="ai-settings-actions">
<button data-role="save" type="button">Save</button>
<button data-role="cancel" type="button">Cancel</button>
</div>
</div>`;
const close = () => {
overlay.remove();
this._settingsModalOpen = false;
};
overlay.addEventListener('click', (event) => {
if (event.target === overlay) close();
});
const $ = (role) => overlay.querySelector(`[data-role="${role}"]`);
// Pre-fill from current settings
Promise.all([
this.context.settings.get('provider'),
this.context.settings.get('model'),
this.context.settings.get('baseUrl'),
this.context.settings.get('apiKey'),
this.context.settings.get('temperature'),
]).then(([provider, model, baseUrl, apiKey, temperature]) => {
if (provider) $('provider').value = provider;
if (model) $('model').value = model;
if (baseUrl) $('baseUrl').value = baseUrl;
if (apiKey) $('apiKey').value = apiKey;
if (temperature !== undefined && temperature !== null) {
$('temperature').value = temperature;
}
});
$('cancel').addEventListener('click', close);
$('save').addEventListener('click', async () => {
await this.context.settings.set('provider', $('provider').value);
await this.context.settings.set('model', $('model').value.trim());
await this.context.settings.set('baseUrl', $('baseUrl').value.trim());
await this.context.settings.set('apiKey', $('apiKey').value.trim());
await this.context.settings.set('temperature', parseFloat($('temperature').value) || 0.7);
await this._refreshStatus();
close();
});
document.body.appendChild(overlay);
}
deactivate() {
/* no long-lived timers to clean up */
}
}
module.exports = { Plugin: AiAssistantPlugin };
@@ -0,0 +1,44 @@
{
"id": "ai-assistant",
"name": "AI Assistant",
"version": "1.0.0",
"description": "Multi-provider AI help: chat panel, summarize/improve/explain/translate commands, and grammar proofreading via the writing-studio Proofread panel",
"icon": "sparkles",
"extensionPoints": {
"sidebar": [{ "id": "chat", "title": "AI Chat", "order": 40 }],
"commands": [
{ "id": "summarize-selection", "label": "AI: Summarize Selection", "shortcut": "" },
{ "id": "improve-selection", "label": "AI: Improve Writing", "shortcut": "" },
{ "id": "explain-selection", "label": "AI: Explain Selection", "shortcut": "" },
{ "id": "translate-selection", "label": "AI: Translate Selection", "shortcut": "" },
{ "id": "open-chat", "label": "AI: Open Chat", "shortcut": "" }
]
},
"settings": [
{
"key": "provider",
"type": "string",
"default": "",
"label": "Provider (openai|anthropic|ollama|lmstudio|openai-compatible)"
},
{ "key": "model", "type": "string", "default": "", "label": "Model name" },
{
"key": "baseUrl",
"type": "string",
"default": "",
"label": "API base URL (optional override)"
},
{
"key": "apiKey",
"type": "string",
"default": "",
"label": "API key (not required for local providers)"
},
{
"key": "temperature",
"type": "number",
"default": 0.7,
"label": "Sampling temperature (0-2)"
}
]
}
@@ -0,0 +1,142 @@
/**
* Prompt builders and output parsers for the AI Assistant plugin.
*
* Kept as a pure module (no DOM, no IPC) so prompt construction and the
* proofread-issue parser can be unit-tested directly.
*
* @module AiPrompts
*/
/**
* Task prompt templates. Each entry maps an assistant action to a system
* prompt (behavior contract) and a user-prompt builder over the document
* text. Markdown output is requested for editor-facing actions so results
* can be inserted straight into the document.
*/
const TASKS = {
summarize: {
system:
'You are a concise writing assistant. Summarize the user text in clear markdown. ' +
'Use a short paragraph followed by 3-5 bullet points with the key ideas.',
user: (text, extra) => `Summarize the following${extra ? ` (${extra})` : ''}:\n\n${text}`,
},
improve: {
system:
'You are a professional editor. Improve the user text for clarity, flow, and correctness. ' +
'Return ONLY the rewritten markdown — no preamble, no explanations, no code fences ' +
'around the whole answer.',
user: (text) => `Rewrite and improve this text:\n\n${text}`,
},
explain: {
system:
'You are a patient technical explainer. Explain the user text in plain language, ' +
'using short markdown sections and examples where helpful.',
user: (text) => `Explain the following:\n\n${text}`,
},
translate: {
system:
'You are a careful translator. Translate the user text, preserving markdown formatting, ' +
'tone, and technical terminology. Return ONLY the translation.',
user: (text, targetLanguage) =>
`Translate the following to ${targetLanguage || 'English'}:\n\n${text}`,
},
chat: {
// Free-form conversation; the panel supplies its own message history
system:
'You are a helpful writing and markdown assistant inside a desktop editor. ' +
'Answer in markdown. Be concise unless asked for detail.',
user: (text) => text,
},
};
/**
* Build a {system, user} prompt pair for a known task action.
*
* @param {string} action Task id (summarize|improve|explain|translate|chat)
* @param {string} text Document text or selection
* @param {string} [extra] e.g. target language for translate
* @returns {{system: string, user: string}}
* @throws {Error} on unknown action
*/
function buildTaskPrompt(action, text, extra) {
const task = TASKS[action];
if (!task) throw new Error(`Unknown AI task "${action}"`);
return { system: task.system, user: task.user(String(text || ''), extra) };
}
/**
* System+user prompts for grammar proofreading. The writing-studio proofread
* panel expects a callback with `{issues: [{type, message, suggestion}]}`,
* so the model is asked for strict JSON.
*/
const PROOFREAD_SYSTEM =
'You are a strict proofreader. Find grammar, spelling, and punctuation issues in the text. ' +
'Respond with ONLY a JSON array — no prose, no code fences. Each element must be an object: ' +
'{"type": "grammar"|"spelling"|"punctuation"|"style", "message": string, "suggestion": string}. ' +
'The message should quote or describe the problematic fragment; the suggestion is the fix. ' +
'If there are no issues, respond with [].';
/** @returns {{system: string, user: string}} */
function buildProofreadPrompt(text) {
return {
system: PROOFREAD_SYSTEM,
user: `Proofread the following text and list its issues as the JSON array described:\n\n${text}`,
};
}
/**
* Parse a model's proofread answer into an issues array. Tolerates the usual
* LLM quirks: code fences around the JSON, leading prose, trailing commas,
* and single-quoted keys. Returns [] when nothing parseable is found rather
* than throwing — a chatty model must not break the panel.
*
* @param {string} modelOutput Raw assistant text
* @returns {Array<{type: string, message: string, suggestion: string}>}
*/
function parseProofreadIssues(modelOutput) {
const raw = String(modelOutput || '').trim();
if (!raw) return [];
// Strip markdown code fences the model may have added despite instructions
const unfenced = raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/i, '')
.trim();
// Grab the outermost [...] block; ignores any leading/trailing prose
const start = unfenced.indexOf('[');
const end = unfenced.lastIndexOf(']');
if (start === -1 || end === -1 || end <= start) return [];
let jsonSlice = unfenced.slice(start, end + 1);
let parsed;
try {
parsed = JSON.parse(jsonSlice);
} catch {
try {
// Retry after trimming trailing commas (a common LLM artifact)
jsonSlice = jsonSlice.replace(/,\s*([\]}])/g, '$1');
parsed = JSON.parse(jsonSlice);
} catch {
return [];
}
}
if (!Array.isArray(parsed)) return [];
// Normalize/whitelist fields so the panel always gets a stable shape
return parsed
.filter((item) => item && typeof item === 'object' && (item.message || item.suggestion))
.slice(0, 100)
.map((item) => ({
type: typeof item.type === 'string' ? item.type.toLowerCase() : 'grammar',
message: String(item.message || item.suggestion || ''),
suggestion: item.suggestion === undefined ? '' : String(item.suggestion),
}));
}
module.exports = {
buildTaskPrompt,
buildProofreadPrompt,
parseProofreadIssues,
TASKS,
};
@@ -0,0 +1,176 @@
/**
* Comment store for the Collaboration plugin.
*
* Inline document comments live in a sidecar folder (`.comments/`) next to the
* document they annotate, one JSON file per document (`<name>.json`), matching
* the v5 collaboration design. Keeping comments out of the document itself
* means they never ship in exports or leak into Git commits of the prose.
*
* A comment anchors to a line number plus the anchor line's text snippet; the
* snippet lets the UI detect drift ("line moved / text changed") after edits
* without storing full document positions.
*
* All IO is injected, so the module is unit-testable and works from both the
* renderer (via IPC file helpers) and tests (via in-memory fakes).
*
* @module CommentStore
*/
const COMMENT_FOLDER = '.comments';
/**
* Path of the sidecar comments file for a document path.
* Pure string surgery — no IO, easy to test.
*
* @param {string} docPath Absolute path to the markdown document
* @param {Function} pathUtil path module (or equivalent) — injected for tests
* @returns {string} e.g. /docs/.comments/notes.md.json
*/
function commentsFilePathFor(docPath, pathUtil) {
const p = pathUtil;
return p.join(p.dirname(docPath), COMMENT_FOLDER, p.basename(docPath) + '.json');
}
/**
* Generate a reasonably unique comment id without external deps.
*/
function newCommentId(now = Date.now()) {
return 'c' + now.toString(36) + Math.random().toString(36).slice(2, 8);
}
/**
* Load all comments for a document. Missing file/corrupt JSON → [] (a fresh
* document simply has no comments yet; a corrupt store must not crash the app).
*
* @param {string} docPath Document path
* @param {object} io - { readFile: (path) => string|null, fileExists: (path) => bool }
* @param {Function} pathUtil injected path module
* @returns {Array<object>} comments sorted by line then createdAt
*/
function loadComments(docPath, io, pathUtil) {
if (!docPath) return [];
const file = commentsFilePathFor(docPath, pathUtil);
try {
if (!io.fileExists(file)) return [];
const raw = io.readFile(file);
const data = JSON.parse(raw);
if (!Array.isArray(data.comments)) return [];
return normalizeComments(data.comments);
} catch {
return [];
}
}
/**
* Persist comments for a document (creates the sidecar structure on demand).
*
* @param {string} docPath Document path
* @param {Array<object>} comments
* @param {object} io - { writeFile, ensureDirectory }
* @param {Function} pathUtil injected path module
*/
function saveComments(docPath, comments, io, pathUtil) {
const file = commentsFilePathFor(docPath, pathUtil);
io.ensureDirectory(pathUtil.dirname(file));
io.writeFile(
file,
JSON.stringify({ version: 1, doc: pathUtil.basename(docPath), comments }, null, 2)
);
}
/** Coerce/validate raw entries into the canonical comment shape. */
function normalizeComments(raw) {
return raw
.filter((c) => c && typeof c === 'object' && typeof c.line === 'number')
.map((c) => ({
id: typeof c.id === 'string' ? c.id : newCommentId(),
line: Math.max(1, Math.floor(c.line)),
anchorText: typeof c.anchorText === 'string' ? c.anchorText : '',
author: typeof c.author === 'string' ? c.author : 'anonymous',
text: typeof c.text === 'string' ? c.text : '',
createdAt: typeof c.createdAt === 'number' ? c.createdAt : Date.now(),
resolved: c.resolved === true,
}))
.sort((a, b) => a.line - b.line || a.createdAt - b.createdAt);
}
/**
* Add a comment anchored to a document line.
*
* @param {Array<object>} comments Existing list (mutated copy returned)
* @param {{line:number, anchorText?:string, author?:string, text:string}} input
* @returns {object} the created comment
*/
function addComment(comments, input) {
const comment = {
id: newCommentId(),
line: Math.max(1, Math.floor(input.line || 1)),
anchorText: String(input.anchorText || '').slice(0, 200),
author: String(input.author || 'me'),
text: String(input.text || '').slice(0, 4000),
createdAt: Date.now(),
resolved: false,
};
comments.push(comment);
return comment;
}
/**
* Toggle a comment's resolved flag by id.
* @returns {boolean} new resolved state, or null when the id is unknown
*/
function toggleResolved(comments, id) {
const comment = comments.find((c) => c.id === id);
if (!comment) return null;
comment.resolved = !comment.resolved;
return comment.resolved;
}
/**
* Remove a comment by id. @returns {boolean} whether anything was removed.
*/
function deleteComment(comments, id) {
const index = comments.findIndex((c) => c.id === id);
if (index === -1) return false;
comments.splice(index, 1);
return true;
}
/**
* Find the next unresolved comment for F8-style navigation (wraps around).
* "Next" is relative to the given line: the first unresolved comment on a
* line > fromLine, wrapping to the top when none exist below.
*
* @returns {object|null} the comment to jump to, or null when none are open
*/
function nextUnresolved(comments, fromLine) {
const open = comments.filter((c) => !c.resolved);
if (open.length === 0) return null;
return open.find((c) => c.line > fromLine) || open[0];
}
/**
* Drift check for one comment against the live document lines.
* @returns {'ok'|'moved'|'changed'|'missing'} anchor status
*/
function anchorStatus(comment, lines) {
if (!Array.isArray(lines) || lines.length === 0) return 'missing';
if (comment.line > lines.length) return 'missing';
const currentText = lines[comment.line - 1].trim();
if (!comment.anchorText) return 'moved';
return currentText === comment.anchorText.trim() ? 'ok' : 'changed';
}
module.exports = {
COMMENT_FOLDER,
commentsFilePathFor,
loadComments,
saveComments,
addComment,
toggleResolved,
deleteComment,
nextUnresolved,
anchorStatus,
newCommentId,
normalizeComments,
};
@@ -0,0 +1,149 @@
/**
* Collaboration plugin — Comments sidebar panel.
*
* Lists the inline comments anchored to the current document, lets the user
* add a comment at the cursor line, jump to a comment's anchor, and
* resolve/delete entries. Comment persistence flows through the injected IO
* (IPC-backed file helpers in the app; fakes in tests).
*
* @module CommentsPanel
*/
const store = require('./comment-store');
/** Escape text before it enters any innerHTML template. */
function esc(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Render the comments panel.
*
* @param {HTMLElement} container Sidebar panel content element
* @param {object} deps
* @param {object} deps.editor Plugin editor API (getCurrentFilePath,
* getCurrentLine, jumpToLine, getContent)
* @param {object} deps.io { readFile, writeFile, fileExists, ensureDirectory }
* @param {Function} deps.pathUtil injected path module
* @param {string} [deps.author='me'] Display name for new comments
*/
function renderCommentsPanel(container, deps) {
container.replaceChildren();
const { editor, io, pathUtil } = deps;
const docPath = editor.getCurrentFilePath();
const panel = document.createElement('div');
panel.className = 'ws-panel';
// --- Header ---------------------------------------------------------------
const header = document.createElement('div');
header.className = 'ws-section';
const heading = document.createElement('h3');
heading.className = 'ws-heading';
heading.textContent = docPath ? 'Inline Comments' : 'No document open';
header.appendChild(heading);
if (docPath) {
const hint = document.createElement('p');
hint.className = 'ws-muted';
hint.textContent =
'Comments are stored in .comments/ next to the file and never exported. F8 jumps to the next open comment.';
header.appendChild(hint);
}
panel.appendChild(header);
if (!docPath) {
const note = document.createElement('p');
note.className = 'ws-muted';
note.textContent = 'Open a saved document to annotate it.';
panel.appendChild(note);
container.appendChild(panel);
return;
}
// --- Composer -------------------------------------------------------------
const composer = document.createElement('div');
composer.className = 'ws-section';
const textarea = document.createElement('textarea');
textarea.rows = 2;
textarea.placeholder = 'Comment on the current line…';
const addBtn = document.createElement('button');
addBtn.className = 'ws-btn ws-btn-primary';
addBtn.textContent = 'Comment on line ' + editor.getCurrentLine();
addBtn.addEventListener('click', () => {
const text = textarea.value.trim();
if (!text) return;
const line = editor.getCurrentLine();
const anchorText = (getLines(editor)[line - 1] || '').trim();
const comments = store.loadComments(docPath, io, pathUtil);
store.addComment(comments, { line, anchorText, author: deps.author || 'me', text });
store.saveComments(docPath, comments, io, pathUtil);
renderCommentsPanel(container, deps); // re-render with fresh list
});
composer.appendChild(textarea);
composer.appendChild(addBtn);
panel.appendChild(composer);
// --- List -----------------------------------------------------------------
const comments = store.loadComments(docPath, io, pathUtil);
const lines = getLines(editor);
const list = document.createElement('div');
list.className = 'ws-issues-list';
if (comments.length === 0) {
const empty = document.createElement('p');
empty.className = 'ws-muted';
empty.textContent = 'No comments yet.';
list.appendChild(empty);
}
for (const comment of comments) {
const status = store.anchorStatus(comment, lines);
const item = document.createElement('div');
item.className = 'ws-issue-item' + (comment.resolved ? ' comment-resolved' : '');
const date = new Date(comment.createdAt).toLocaleString();
item.innerHTML = `
<div class="ws-issue-type">
<span data-role="meta">L${comment.line} · ${esc(comment.author)} · ${esc(date)}
${status !== 'ok' ? '· ' + (status === 'changed' ? 'text changed' : status === 'moved' ? 'moved' : 'gone') : ''}
</span>
</div>
<div class="ws-issue-text">${esc(comment.text)}</div>
${comment.anchorText ? `<div class="ws-muted">> ${esc(comment.anchorText)}</div>` : ''}
<div class="ws-issue-actions">
<button class="ws-btn ws-btn-sm" data-action="jump">Go to line</button>
<button class="ws-btn ws-btn-sm" data-action="resolve">${comment.resolved ? 'Reopen' : 'Resolve'}</button>
<button class="ws-btn ws-btn-sm" data-action="delete">Delete</button>
</div>`;
item.querySelector('[data-action="jump"]').addEventListener('click', () => {
editor.jumpToLine(comment.line);
});
item.querySelector('[data-action="resolve"]').addEventListener('click', () => {
const updated = store.loadComments(docPath, io, pathUtil);
store.toggleResolved(updated, comment.id);
store.saveComments(docPath, updated, io, pathUtil);
renderCommentsPanel(container, deps);
});
item.querySelector('[data-action="delete"]').addEventListener('click', () => {
const updated = store.loadComments(docPath, io, pathUtil);
store.deleteComment(updated, comment.id);
store.saveComments(docPath, updated, io, pathUtil);
renderCommentsPanel(container, deps);
});
list.appendChild(item);
}
panel.appendChild(list);
container.appendChild(panel);
}
/** Split the live document content into lines (1-based indexing helper). */
function getLines(editor) {
return String(editor.getContent() || '').split('\n');
}
module.exports = { renderCommentsPanel };
+109
View File
@@ -0,0 +1,109 @@
/**
* Collaboration built-in plugin — anchor-based inline comments.
*
* Implements the comment half of the v5 collaboration design:
* - Comments sidebar panel (add / list / jump / resolve / delete)
* - F8 navigates to the next open comment (document-level key handler)
* - Ctrl+Alt+M adds a comment at the cursor line
*
* Comments persist to `<folder>/.comments/<file>.json` via the app's IPC file
* helpers; nothing is written into the document itself, so exports and Git
* commits of the prose stay clean.
*
* @module collaboration
*/
const { PluginAPI } = require('../../../plugins/plugin-api');
const { renderCommentsPanel } = require('./comments-panel');
const store = require('./comment-store');
class CollaborationPlugin extends PluginAPI {
init(context) {
this.context = context;
// IO adapters over the allowlisted IPC file helpers; comments-panel
// and comment-store stay renderer-agnostic.
this._io = {
readFile: (p) => context.ipc.invoke('read-file', p),
writeFile: (p, c) => context.ipc.invoke('write-file', { path: p, content: c }),
fileExists: (p) => context.ipc.invoke('path-exists', p),
ensureDirectory: (p) => context.ipc.invoke('ensure-directory', p),
};
this._registerSidebar(context);
this._registerCommands(context);
this._installF8Navigation();
}
_registerSidebar(context) {
context.sidebar.registerPanel('comments', {
title: 'Comments',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>`,
render: (container) => renderCommentsPanel(container, this._panelDeps()),
});
}
_panelDeps() {
return {
editor: this.context.editor,
io: this._io,
pathUtil: require('path'),
author: 'me',
};
}
_registerCommands(context) {
context.commands.register(
'add-comment',
'Comment: Add Comment at Cursor',
() => {
// Opens the Comments panel focused on the composer at the cursor line
this._openPanel();
},
'Ctrl+Alt+M'
);
context.commands.register('next-comment', 'Comment: Next Open Comment', () => {
this._jumpToNextComment();
});
}
/**
* Document-level F8 handler (uninstalled on deactivate). Inside textareas
* and inputs F8 must keep its default behavior.
*/
_installF8Navigation() {
this._f8Handler = (event) => {
if (event.key !== 'F8') return;
const target = event.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
event.preventDefault();
this._jumpToNextComment();
};
document.addEventListener('keydown', this._f8Handler);
}
_jumpToNextComment() {
const docPath = this.context.editor.getCurrentFilePath();
if (!docPath) return;
const comments = store.loadComments(docPath, this._io, require('path'));
const fromLine = this.context.editor.getCurrentLine();
const next = store.nextUnresolved(comments, fromLine);
if (next) this.context.editor.jumpToLine(next.line);
}
_openPanel() {
this.context.events.emit('sidebar:open-panel', { panel: 'collaboration:comments' });
}
deactivate() {
if (this._f8Handler) {
document.removeEventListener('keydown', this._f8Handler);
this._f8Handler = null;
}
}
}
module.exports = { Plugin: CollaborationPlugin };
@@ -0,0 +1,15 @@
{
"id": "collaboration",
"name": "Collaboration",
"version": "1.0.0",
"description": "Anchor-based inline comments stored in .comments/ sidecar files, with an F8 next-comment navigator",
"icon": "message-square",
"extensionPoints": {
"sidebar": [{ "id": "comments", "title": "Comments", "order": 34 }],
"commands": [
{ "id": "add-comment", "label": "Comment: Add Comment at Cursor", "shortcut": "Ctrl+Alt+M" },
{ "id": "next-comment", "label": "Comment: Next Open Comment", "shortcut": "F8" }
]
},
"settings": []
}
@@ -94,7 +94,44 @@ function renderGoalsPanel(container, { engines, settings }) {
section3.appendChild(chart);
panel.appendChild(section3);
// GitHub-style activity heatmap over the same 30-day window: intensity is
// relative to the daily goal (empty / <33% / <66% / <100% / goal met).
const section4 = document.createElement('div');
section4.className = 'ws-section';
const heading4 = document.createElement('h3');
heading4.className = 'ws-heading';
heading4.textContent = 'Writing Heatmap';
section4.appendChild(heading4);
const heatmap = document.createElement('div');
heatmap.className = 'ws-heatmap';
heatmap.setAttribute('role', 'img');
heatmap.setAttribute('aria-label', 'Daily writing activity over the last 30 days');
for (const day of last30) {
const cell = document.createElement('span');
cell.className = 'ws-heatmap-cell ' + heatLevel(day.words, dailyGoal);
cell.title = `${day.date}: ${day.words} words${day.words >= dailyGoal ? ' — goal met' : ''}`;
heatmap.appendChild(cell);
}
section4.appendChild(heatmap);
panel.appendChild(section4);
container.appendChild(panel);
}
/**
* Map a day's word count to a heatmap intensity class (l0 implicit…l4).
* @param {number} words words written that day
* @param {number} goal daily goal
* @returns {string} class suffix ('' for empty days)
*/
function heatLevel(words, goal) {
if (!words || words <= 0) return '';
const ratio = words / Math.max(1, goal);
if (ratio >= 1) return 'l4';
if (ratio >= 0.66) return 'l3';
if (ratio >= 0.33) return 'l2';
return 'l1';
}
module.exports = { renderGoalsPanel };
+9
View File
@@ -58,6 +58,15 @@ class PluginContext {
getSelection: () => editor.getSelection(),
insertAtCursor: (text) => editor.insertAtCursor(text),
onContentChanged: (cb) => editor.onContentChanged(cb),
// Current tab's file path (null for untitled tabs) — plugins that store
// sidecar data (comments, snapshots, …) key off this.
getCurrentFilePath: () => (editor.getCurrentFilePath ? editor.getCurrentFilePath() : null),
// Scroll the active editor to a 1-based line and place the cursor there
jumpToLine: (line) => {
if (editor.jumpToLine) editor.jumpToLine(line);
},
// 1-based line of the primary cursor
getCurrentLine: () => (editor.getCurrentLine ? editor.getCurrentLine() : 1),
};
this.events = {
+21
View File
@@ -40,6 +40,10 @@ const ALLOWED_SEND_CHANNELS = [
'plugin-export-formats-registered',
'plugin-export-format-result',
// AI Assistant plugin (completions proxied through main; keys stay there)
'ai-assistant:complete',
'ai-assistant:status',
// Batch conversion
'batch-convert',
'select-folder',
@@ -155,6 +159,23 @@ const ALLOWED_SEND_CHANNELS = [
// Monospace font settings
'get-monospace-settings',
'set-monospace-settings',
// Vim keybinding mode
'get-vim-mode',
'set-vim-mode',
// PlantUML local rendering (optional CLI)
'plantuml:available',
'plantuml:render',
// Quick Note scratchpad
'quick-note:save',
// Document version history
'version-history:list',
'version-history:read',
'version-history:save',
'version-history:delete',
];
const ALLOWED_RECEIVE_CHANNELS = [
+538 -51
View File
@@ -17,6 +17,46 @@ const { showDocumentCompareDialog } = require('./renderer/document-compare-dialo
const { initExportPresets, refreshExportPresets } = require('./renderer/export-presets');
const { csvToMarkdownTable } = require('./utils/csv-to-markdown-table');
const { getFilePath } = require('./utils/file-path');
const SessionStore = require('./utils/session-store');
const { renderWikiLinksInHtml, resolveTargetPath } = require('./utils/wiki-links');
const fs = require('fs');
// ================================
// Snippet Tab-expansion registry
// ================================
// A lowercase-name → content map rebuilt whenever snippets change; queried
// synchronously by every editor's Tab keymap (see snippetTabKeymap).
const snippetExpansions = new Map();
async function refreshSnippetExpansions() {
try {
const snippets = await ipcRenderer.invoke('get-snippets');
snippetExpansions.clear();
for (const snippet of Array.isArray(snippets) ? snippets : []) {
if (snippet?.name && typeof snippet.content === 'string') {
snippetExpansions.set(snippet.name.toLowerCase(), snippet.content);
}
}
} catch {
/* snippets unavailable — Tab keeps its default indent behavior */
}
}
refreshSnippetExpansions();
// ================================
// Vim keybinding mode
// ================================
// Mirrors the monospace-setting flow: main menu checkbox → persisted setting
// → renderer applies setVimMode to every live editor. window.__vimModeEnabled
// seeds newly created editors with the right mode.
function applyVimMode(enabled) {
window.__vimModeEnabled = enabled === true;
const { setVimMode } = require('./editor/codemirror-setup');
if (tabManager) {
for (const tab of tabManager.tabs.values()) {
if (tab.editorView) setVimMode(tab.editorView, window.__vimModeEnabled);
}
}
}
/**
* Toggle body classes that drive the monospace font + ligatures CSS tokens.
@@ -278,6 +318,10 @@ marked.use({
],
});
// Session-scoped set of languages the user has approved for REPL execution
// (populated by the confirm dialog on first Run click per language).
const replExecutionConfirmed = new Set();
// PlantUML hex encoding for server rendering
function plantumlEncode(text) {
const hex = Array.from(new TextEncoder().encode(text))
@@ -286,6 +330,67 @@ function plantumlEncode(text) {
return '~h' + hex;
}
// ================================
// PlantUML rendering (local-first, server fallback)
// ================================
// A local `plantuml` CLI (if installed) renders via the main process, so
// diagram text never leaves the machine; otherwise the public plantuml.com
// server is used exactly as before. The availability probe is cached per
// session so each preview render costs at most one invoke.
let plantumlLocalAvailability = undefined; // undefined = not probed yet
/**
* Replace PlantUML code blocks with rendered diagrams.
* @param {NodeList} blocks `pre code.language-plantuml` elements
*/
async function renderPlantUmlBlocks(blocks) {
if (plantumlLocalAvailability === undefined) {
try {
plantumlLocalAvailability = (await ipcRenderer.invoke('plantuml:available')) === true;
} catch {
plantumlLocalAvailability = false;
}
}
for (const block of blocks) {
const code = block.textContent;
const pre = block.parentElement;
if (plantumlLocalAvailability) {
try {
const result = await ipcRenderer.invoke('plantuml:render', { text: code });
if (result?.dataUrl) {
replacePreWithImage(pre, result.dataUrl);
continue;
}
} catch {
// fall through to server rendering
}
}
const img = document.createElement('img');
img.src = `https://www.plantuml.com/plantuml/svg/${plantumlEncode(code)}`;
img.alt = 'PlantUML diagram';
img.style.maxWidth = '100%';
img.onerror = () => {
img.replaceWith(pre); // Fallback to code block on error
};
if (pre.parentElement) pre.parentElement.replaceChild(img, pre);
}
}
/** Swap a <pre> for a rendered <img>, keeping a fallback on error. */
function replacePreWithImage(pre, dataUrl) {
const img = document.createElement('img');
img.src = dataUrl;
img.alt = 'PlantUML diagram';
img.style.maxWidth = '100%';
img.onerror = () => {
img.replaceWith(pre);
};
if (pre.parentElement) pre.parentElement.replaceChild(img, pre);
}
// Scopes user custom CSS by prefixing all selectors to protect application UI
function scopeCSS(cssText, scopeSelector) {
if (!cssText) return '';
@@ -618,6 +723,10 @@ class TabManager {
const isDark = document.body.className.includes('dark');
tab.editorView = createEditor(editorContainer, {
content: tab.content,
// Vim mode follows the persisted app setting for every new editor
vimMode: window.__vimModeEnabled === true,
// Tab expands a snippet when the word before the cursor matches one
getTabExpansion: (prefix) => snippetExpansions.get(prefix) || null,
onChange: (newContent) => {
tab.content = newContent;
tab.isDirty = true;
@@ -638,6 +747,7 @@ class TabManager {
this.updatePreview(tab.id);
this.updateWordCount();
this.updateTabBar();
this.scheduleSessionPersist(); // crash-recovery snapshot (debounced)
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
},
onUpdate: (view) => {
@@ -678,6 +788,7 @@ class TabManager {
// Refresh outline panel for new tab content
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
this.scheduleSessionPersist(); // crash-recovery snapshot
}
switchToNextTab() {
const tabIds = Array.from(this.tabs.keys());
@@ -729,6 +840,7 @@ class TabManager {
this.switchToTab(remainingTabs[0]);
}
this.updateTabBar();
this.scheduleSessionPersist(); // crash-recovery snapshot
}
updateTabBar() {
const tabBar = document.getElementById('tab-bar');
@@ -919,6 +1031,13 @@ class TabManager {
}
preview.innerHTML = sanitizedHtml;
// Turn [[wiki links]] into clickable links (knowledge-base mode).
// Applied after sanitization; renderWikiLinksInHtml itself escapes the
// target/label text, so no new HTML injection surface is opened.
if (sanitizedHtml.indexOf('[[') !== -1) {
preview.innerHTML = renderWikiLinksInHtml(sanitizedHtml);
}
// Render math expressions if KaTeX is available
if (window.katex && window.renderMathInElement) {
try {
@@ -989,23 +1108,13 @@ class TabManager {
}
}
// Render PlantUML diagrams
// Render PlantUML diagrams — locally when a plantuml CLI is installed
// (keeps diagram sources on the machine), falling back to the public
// server otherwise. Local availability is checked once per session.
const plantumlBlocks = preview.querySelectorAll('pre code.language-plantuml');
plantumlBlocks.forEach((block) => {
const code = block.textContent;
const pre = block.parentElement;
// Encode for PlantUML server using hex encoding
const encoded = plantumlEncode(code);
const img = document.createElement('img');
img.src = `https://www.plantuml.com/plantuml/svg/${encoded}`;
img.alt = 'PlantUML diagram';
img.style.maxWidth = '100%';
img.onerror = () => {
img.replaceWith(pre); // Fallback to code block on error
};
pre.parentElement.replaceChild(img, pre);
});
if (plantumlBlocks.length > 0) {
renderPlantUmlBlocks(plantumlBlocks);
}
// Add Run buttons to executable code blocks (REPL)
const codeBlocks = preview.querySelectorAll('pre code[class*="language-"]');
@@ -1017,6 +1126,18 @@ class TabManager {
runBtn.textContent = '\u25B6 Run';
runBtn.addEventListener('click', async () => {
if (replPanel) {
// Code execution runs UNSANDBOXED on the user's machine
// (node/python/shell child processes). Confirm once per session
// per language so a crafted document can't silently run code
// the first time its Run button is clicked.
if (!replExecutionConfirmed.has(lang)) {
const ok = confirm(
`Run ${lang} code blocks on this machine?\n\n` +
'Code from the document will execute as a child process with your user permissions (10s timeout).'
);
if (!ok) return;
replExecutionConfirmed.add(lang);
}
replPanel.show();
const result = await ipcRenderer.invoke('execute-code', {
code: block.textContent,
@@ -1156,6 +1277,115 @@ class TabManager {
}, 1500);
}
// ================================
// Session persistence (crash recovery)
// ================================
/**
* Write a session snapshot (open tabs + unsaved content) to localStorage so
* the next launch can offer to restore after a crash or force-quit.
*/
persistSession() {
const session = SessionStore.captureSession(this.tabs, this.activeTabId, (tabId) =>
this.getEditorContent(tabId)
);
SessionStore.saveSession(localStorage, session);
}
/**
* Debounced persistSession — called on every content keystroke, so writing
* to localStorage synchronously each time would be wasteful.
*/
scheduleSessionPersist() {
if (this._sessionPersistTimer) clearTimeout(this._sessionPersistTimer);
this._sessionPersistTimer = setTimeout(() => {
this._sessionPersistTimer = null;
this.persistSession();
}, 2000);
}
/**
* Restore tabs from a saved session snapshot. Returns the number of tabs
* actually restored (missing files and empty tabs are skipped).
*
* @param {object} session Snapshot from SessionStore.loadSession()
* @returns {number} restored tab count
*/
restoreSession(session) {
if (!session || !Array.isArray(session.tabs)) return 0;
let restored = 0;
for (const entry of session.tabs) {
try {
if (entry.type === 'pdf') {
// PDF tabs are view-only; reopen from the original path when it
// still exists, otherwise skip silently.
if (entry.filePath && fs.existsSync(entry.filePath)) {
this.createPdfTab(entry.filePath);
restored++;
}
continue;
}
if (entry.filePath && fs.existsSync(entry.filePath)) {
// File-backed tab: reload the file from disk, then reapply the
// unsaved buffer on top when the snapshot captured one.
const onDisk = fs.readFileSync(entry.filePath, 'utf-8');
this.openFile(entry.filePath, onDisk);
if (entry.isDirty && typeof entry.content === 'string') {
const tab = this.tabs.get(this.activeTabId);
if (tab) {
this.setEditorContent(tab.id, entry.content);
tab.isDirty = true;
this.updateTabBar();
}
}
restored++;
} else if (typeof entry.content === 'string' && entry.content.length > 0) {
// Untitled dirty buffer — the only copy is the snapshot itself.
this.createNewTab();
const tab = this.tabs.get(this.activeTabId);
if (tab) {
this.setEditorContent(tab.id, entry.content);
tab.title = entry.title || 'Untitled';
tab.isDirty = true;
this.updatePreview(tab.id);
this.updateTabBar();
}
restored++;
}
} catch (error) {
console.warn('Failed to restore tab from session:', error);
}
}
// End on the tab that was active when the session was saved, if possible
const lastEntry = [...session.tabs].reverse().find((t) => this._findTabIdForEntry(t) !== null);
const targetId = lastEntry ? this._findTabIdForEntry(lastEntry) : null;
if (targetId !== null && this.tabs.has(targetId)) {
this.switchToTab(targetId);
}
if (restored > 0) this.scheduleSessionPersist();
return restored;
}
/**
* Best-effort mapping of a session entry back to a live tab id after a
* restore pass (matches by filePath, falling back to title for untitled).
*/
_findTabIdForEntry(entry) {
if (!entry) return null;
for (const tab of this.tabs.values()) {
if (entry.filePath && tab.filePath === entry.filePath) return tab.id;
}
for (const tab of this.tabs.values()) {
if (!entry.filePath && !tab.filePath && tab.title === (entry.title || 'Untitled')) {
return tab.id;
}
}
return null;
}
// Recent files functionality
addToRecentFiles(filePath) {
if (!filePath) return;
@@ -1608,6 +1838,7 @@ class TabManager {
}
this.startAutoSave();
this.addToRecentFiles(filePath);
this.scheduleSessionPersist(); // crash-recovery snapshot
this.updateTabBar();
this.updateFilePath();
this.updateBreadcrumb();
@@ -1708,10 +1939,83 @@ async function loadTemplateIntoNewTab(file) {
}
}
/**
* Crash recovery: build and show a lightweight "restore session?" prompt.
* Rendered as a fixed overlay (not a ModalManager dialog) because modals are
* initialized inside DOMContentLoaded and this must appear above everything on
* the very first paint. Disappears on either choice; no opinion is recorded
* for future launches (the prompt appears whenever a snapshot exists).
*
* @param {object} session Snapshot from SessionStore.loadSession()
*/
function showRestoreSessionPrompt(session) {
const unsaved = session.tabs.filter((t) => t.isDirty && typeof t.content === 'string');
const summary =
`${session.tabs.length} tab${session.tabs.length === 1 ? '' : 's'}` +
(unsaved.length > 0 ? `, ${unsaved.length} with unsaved changes` : '');
const overlay = document.createElement('div');
overlay.id = 'session-restore-prompt';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'session-restore-title');
overlay.style.cssText =
'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);' +
'display:flex;align-items:center;justify-content:center;z-index:10000;';
overlay.innerHTML = `
<div style="background:var(--bg-primary,#fff);color:var(--text-primary,#222);border-radius:8px;
padding:24px;max-width:420px;box-shadow:0 8px 32px rgba(0,0,0,0.3);text-align:center;">
<h3 id="session-restore-title" style="margin:0 0 8px;">Restore previous session?</h3>
<p style="margin:0 0 20px;color:var(--text-secondary,#666);">
The app closed with ${summary}. Restore them now?
</p>
<div style="display:flex;gap:12px;justify-content:center;">
<button id="session-restore-yes" type="button"
style="padding:8px 20px;border-radius:4px;border:none;cursor:pointer;
background:#4a90d9;color:#fff;">Restore session</button>
<button id="session-restore-no" type="button"
style="padding:8px 20px;border-radius:4px;border:1px solid #ccc;cursor:pointer;
background:transparent;color:inherit;">Start fresh</button>
</div>
</div>`;
document.body.appendChild(overlay);
const dismiss = () => overlay.remove();
overlay.querySelector('#session-restore-yes').addEventListener('click', () => {
dismiss();
const restored = tabManager ? tabManager.restoreSession(session) : 0;
notifyUser(restored > 0 ? `Restored ${restored} tab(s)` : 'Nothing to restore', 'success');
});
overlay.querySelector('#session-restore-no').addEventListener('click', () => {
dismiss();
SessionStore.clearSession(localStorage);
});
}
document.addEventListener('DOMContentLoaded', async () => {
const ModalManager = _ModalManager;
tabManager = new TabManager();
// ================================
// Crash recovery: offer to restore the previous session
// ================================
// A snapshot is written on edits/tab changes (debounced), on unload, and
// once a minute. It is kept only when it contains something restorable;
// a clean single-empty-tab state clears it.
const previousSession = SessionStore.loadSession(localStorage);
if (previousSession) {
showRestoreSessionPrompt(previousSession);
}
// Persist synchronously on unload — the debounced timer may not have fired
window.addEventListener('beforeunload', () => {
if (tabManager) tabManager.persistSession();
});
// Periodic safety net: guarantees a snapshot at most 60s old even when the
// user steps away mid-edit (laptop sleep, SIGKILL, power loss, etc.)
setInterval(() => {
if (tabManager) tabManager.persistSession();
}, 60000);
// Load saved Custom Preview CSS if present
const savedCSSContent = localStorage.getItem('customPreviewCSSContent');
if (savedCSSContent) {
@@ -1805,8 +2109,17 @@ document.addEventListener('DOMContentLoaded', async () => {
render: (container) =>
getRenderSnippetsPanel()(container, {
getSnippets: () => ipcRenderer.invoke('get-snippets'),
saveSnippet: (s) => ipcRenderer.invoke('save-snippet', s),
deleteSnippet: (id) => ipcRenderer.invoke('delete-snippet', id),
// Wrap save/delete so the Tab-expansion map stays in sync
saveSnippet: async (s) => {
const result = await ipcRenderer.invoke('save-snippet', s);
refreshSnippetExpansions();
return result;
},
deleteSnippet: async (id) => {
const result = await ipcRenderer.invoke('delete-snippet', id);
refreshSnippetExpansions();
return result;
},
onInsert: (code) => tabManager.insertAtCursor(code),
}),
});
@@ -1846,6 +2159,79 @@ document.addEventListener('DOMContentLoaded', async () => {
},
});
// Backlinks panel — "what links here?" for [[wiki links]] (knowledge-base mode)
const { renderBacklinksPanel } = require('./sidebar/backlinks-panel');
sidebarManager.registerPanel('backlinks', {
title: 'Backlinks',
render: (container) =>
renderBacklinksPanel(container, {
getCurrentFilePath: () => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
return tab?.filePath || null;
},
// Bounded folder scan through the same IPC helper the Explorer uses
listDir: (dir) => ipcRenderer.invoke('list-directory', dir),
readFile: (p) => ipcRenderer.invoke('read-file', p),
onFileOpen: (p) => ipcRenderer.send('open-file-path', p),
pathUtil: require('path'),
}),
});
// History panel — local version history (snapshots captured before saves)
const { renderHistoryPanel } = require('./sidebar/history-panel');
sidebarManager.registerPanel('history', {
title: 'History',
render: (container) =>
renderHistoryPanel(container, {
getCurrentFilePath: () => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
return tab?.filePath || null;
},
getCurrentContent: () => tabManager.getEditorContent(),
replaceContent: (content) => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (!tab) return;
tabManager.setEditorContent(tab.id, content);
tab.isDirty = true;
tabManager.updatePreview(tab.id);
tabManager.updateTabBar();
},
history: {
list: (docPath) => ipcRenderer.invoke('version-history:list', docPath),
read: (docPath, id) => ipcRenderer.invoke('version-history:read', { docPath, id }),
save: (docPath, content, label) =>
ipcRenderer.invoke('version-history:save', { docPath, content, label }),
delete: (docPath, id) => ipcRenderer.invoke('version-history:delete', { docPath, id }),
},
}),
});
// Wiki-link navigation: open the target note (creating it on demand after
// confirmation). Delegated on document so it works in every tab's preview.
document.addEventListener('click', (event) => {
const link = event.target.closest?.('.wiki-link');
if (!link) return;
event.preventDefault();
const target = link.getAttribute('data-wiki-target');
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (!target || !tab?.filePath) return;
const pathUtil = require('path');
const targetPath = resolveTargetPath(target, pathUtil.dirname(tab.filePath), pathUtil);
if (!targetPath) return;
if (fs.existsSync(targetPath)) {
ipcRenderer.send('open-file-path', targetPath);
} else if (confirm(`"${target}" doesn't exist yet. Create it?`)) {
// Create then open — the note materializes with a title heading
ipcRenderer
.invoke('write-file', { path: targetPath, content: `# ${target}\n\n` })
.then(() => {
ipcRenderer.send('open-file-path', targetPath);
});
}
});
// View menu: toggle a sidebar panel by id (explorer/git/snippets/templates/outline)
ipcRenderer.on('toggle-sidebar-panel', (event, panelId) => {
sidebarManager.togglePanel(panelId);
@@ -1899,6 +2285,28 @@ document.addEventListener('DOMContentLoaded', async () => {
getSelection: () => tabManager.getSelection(),
insertAtCursor: (text) => tabManager.insertAtCursor(text),
onContentChanged: (cb) => pluginEventBus.on('document:changed', cb),
// Current file path for plugins that store per-file sidecar data
getCurrentFilePath: () => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
return tab?.filePath || null;
},
// Jump the active editor to a 1-based line (used by comment navigation)
jumpToLine: (line) => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (!tab?.editorView || !Number.isFinite(line) || line < 1) return;
const maxLine = tab.editorView.state.doc.lines;
const target = Math.min(Math.floor(line), maxLine);
tab.editorView.dispatch({
selection: { anchor: tab.editorView.state.doc.line(target).from },
scrollIntoView: true,
});
tab.editorView.focus();
},
getCurrentLine: () => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (!tab?.editorView) return 1;
return tab.editorView.state.doc.lineAt(tab.editorView.state.selection.main.head).number;
},
},
ipc: {
invoke: (ch, data) => window.electronAPI.invoke(ch, data),
@@ -1911,6 +2319,66 @@ document.addEventListener('DOMContentLoaded', async () => {
const discovered = loader.discoverPlugins();
discovered.forEach((p) => pluginRegistry.register(p));
// Plugin panels request programmatic opening via the shared event bus
// (e.g. the AI Chat command). Expand the named sidebar panel in response.
pluginEventBus.on('sidebar:open-panel', ({ panel } = {}) => {
if (panel && typeof panel === 'string') sidebarManager.expand(panel);
});
// Wire the writing-studio plugin's sidebar panels (declared in its manifest
// but rendered by these panel modules): registering through the registry's
// context namespaces the ids to "writing-studio:<panel>" and adds rail icons.
const studioPlugin = pluginRegistry.getPlugin('writing-studio');
if (studioPlugin?.context) {
const ctx = studioPlugin.context;
const {
renderManuscriptPanel,
} = require('./plugins/built-in/writing-studio/panels/manuscript-panel');
const { renderGoalsPanel } = require('./plugins/built-in/writing-studio/panels/goals-panel');
const {
renderSnapshotsPanel,
} = require('./plugins/built-in/writing-studio/panels/snapshots-panel');
const {
renderProofreadPanel,
} = require('./plugins/built-in/writing-studio/panels/proofread-panel');
const studioIcon = (paths) =>
`<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
// Panels take {engines, settings, editor, events} — engines is the
// writing-studio plugin's engine map (sprint/goals/snapshots/projects).
const engines = studioPlugin.instance.getEngines ? studioPlugin.instance.getEngines() : {};
const panelDeps = { engines, settings: ctx.settings, editor: ctx.editor, events: ctx.events };
ctx.sidebar.registerPanel('manuscript', {
title: 'Manuscript',
icon: studioIcon(
'<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>'
),
render: (container) => renderManuscriptPanel(container, panelDeps),
});
ctx.sidebar.registerPanel('goals', {
title: 'Goals',
icon: studioIcon(
'<circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="6"></circle><circle cx="12" cy="12" r="2"></circle>'
),
render: (container) => renderGoalsPanel(container, panelDeps),
});
ctx.sidebar.registerPanel('snapshots', {
title: 'Snapshots',
icon: studioIcon(
'<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>'
),
render: (container) => renderSnapshotsPanel(container, panelDeps),
});
ctx.sidebar.registerPanel('proofread', {
title: 'Proofread',
icon: studioIcon(
'<path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>'
),
render: (container) => renderProofreadPanel(container, panelDeps),
});
}
// Tell the main process which export formats plugins registered, so the
// Export menu (built in main.js, a separate process from this one) can
// grow entries for them. Only serializable metadata crosses the IPC
@@ -1975,6 +2443,18 @@ document.addEventListener('DOMContentLoaded', async () => {
.catch(() => {});
});
// Vim mode: initial value from settings, then live toggles from the View menu
ipcRenderer
.invoke('get-vim-mode')
.then(applyVimMode)
.catch(() => {});
ipcRenderer.on('vim-setting-change', (event, enabled) => {
ipcRenderer
.invoke('set-vim-mode', enabled === true)
.then(() => applyVimMode(enabled === true))
.catch(() => {});
});
// Welcome tab on startup
const hasLaunched = localStorage.getItem('hasLaunchedBefore');
const showWelcome = localStorage.getItem('showWelcomeOnStartup') !== 'false';
@@ -3922,12 +4402,13 @@ function updateMergeFilesList() {
});
}
// Task 27: pdf-lib 1.17.1 cannot encrypt, so encrypt/decrypt/permissions fail
// honestly in the main process. Ask main once whether password protection is
// available and, when it is not, disable the three sections' controls with an
// explanatory hint instead of letting the user fill the form only to see the
// operation fail. If the capability query itself fails, leave the controls
// enabled — main still fails the operation honestly on submit.
// Password ops (encrypt/decrypt/permissions) are backed by @cantoo/pdf-lib,
// which supports real encryption. Main reports its capability probe once; if a
// build ever ships a library without encryption support, disable the three
// sections' controls with an explanatory hint instead of letting the user fill
// the form only to see the operation fail. If the capability query itself
// fails, leave the controls enabled — main still fails the operation honestly
// on submit.
async function applyPDFPasswordProtectionAvailability() {
let capabilities;
try {
@@ -3947,14 +4428,14 @@ async function applyPDFPasswordProtectionAvailability() {
const hint = document.createElement('p');
hint.className = 'warning-message pdf-unavailable-hint';
hint.textContent =
'Password protection is not available in this build (pdf-lib lacks encryption support).';
'Password protection is not available in this build (the PDF library lacks encryption support).';
section.prepend(hint);
}
}
// PDF Editor Event Listeners
document.addEventListener('DOMContentLoaded', () => {
// Disable password-protection controls when pdf-lib lacks encryption support
// Disable password-protection controls if the PDF library lacks encryption support
applyPDFPasswordProtectionAvailability();
// Close PDF Editor Dialog
@@ -4742,33 +5223,39 @@ ipcRenderer.on('pdf-operation-progress', (event, { message, progress }) => {
}
});
// Add math rendering support using KaTeX for enhanced preview
// Add math rendering support using KaTeX for enhanced preview.
// KaTeX is bundled locally (assets/katex CSS + the katex npm package) so math
// renders offline and nothing is fetched from a CDN at runtime (the old
// jsdelivr links broke math rendering whenever the machine was offline and
// leaked document content requests to a third party).
function initMathSupport() {
// Add KaTeX CSS
const katexCSS = document.createElement('link');
katexCSS.rel = 'stylesheet';
katexCSS.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css';
katexCSS.crossOrigin = 'anonymous';
document.head.appendChild(katexCSS);
// KaTeX CSS ships in index.html via <link href="assets/katex/katex.min.css">,
// including its font files. Only the JS needs wiring up here.
try {
// The renderer runs with nodeIntegration, so the locally installed npm
// package can be required directly — no script-tag loading needed.
// katex exports the katex object; the auto-render contrib exports the
// renderMathInElement function itself (webpack default interop), so
// resolve both shapes defensively.
const katex = require('katex');
const autoRender = require('katex/contrib/auto-render');
window.katex = katex;
const renderFn =
typeof autoRender === 'function'
? autoRender
: autoRender.renderMathInElement || autoRender.default;
if (typeof renderFn !== 'function') throw new Error('auto-render export not found');
window.renderMathInElement = renderFn;
} catch (error) {
console.warn('Local KaTeX unavailable, math rendering disabled:', error.message);
return;
}
// Add KaTeX JS
const katexJS = document.createElement('script');
katexJS.src = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.js';
katexJS.crossOrigin = 'anonymous';
katexJS.onload = () => {
// Add auto-render extension
const autoRenderJS = document.createElement('script');
autoRenderJS.src = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js';
autoRenderJS.crossOrigin = 'anonymous';
autoRenderJS.onload = () => {
// Re-render current preview to include math
if (tabManager) {
tabManager.updatePreview();
}
};
document.head.appendChild(autoRenderJS);
};
document.head.appendChild(katexJS);
// Math is now available synchronously; re-render the current preview so an
// already-rendered tab picks up math without waiting for the next edit.
if (tabManager) {
tabManager.updatePreview();
}
}
// Initialize math support on load
+103
View File
@@ -0,0 +1,103 @@
/**
* Backlinks sidebar panel — the "what links here?" view for the local
* knowledge-base. Lists documents in the current folder that wiki-link to the
* active document, with jump-to-source. Re-scans on every render (panel opens
* / refresh) because scans are bounded and folders are small in practice.
*
* @module BacklinksPanel
*/
const { collectMarkdownFiles, findBacklinks } = require('../../utils/backlinks');
const { docNameFor } = require('../../utils/wiki-links');
/**
* Render the backlinks panel.
*
* @param {HTMLElement} container Sidebar panel content element
* @param {object} deps
* @param {() => string|null} deps.getCurrentFilePath Active tab's file path
* @param {(dir: string) => Promise<{entries:Array}|null>} deps.listDir
* @param {(path: string) => Promise<string|null>} deps.readFile
* @param {(path: string) => void} deps.onFileOpen Open a file in a new tab
* @param {Function} deps.pathUtil injected path module
*/
async function renderBacklinksPanel(container, deps) {
container.replaceChildren();
const { getCurrentFilePath, listDir, readFile, onFileOpen, pathUtil } = deps;
const panel = document.createElement('div');
panel.className = 'ws-panel';
const heading = document.createElement('h3');
heading.className = 'ws-heading';
heading.textContent = 'Backlinks';
panel.appendChild(heading);
const docPath = getCurrentFilePath();
if (!docPath) {
appendNote(panel, 'Open a saved document to see what links to it.');
container.appendChild(panel);
return;
}
const docName = docNameFor(docPath, pathUtil);
const dir = pathUtil.dirname(docPath);
const status = document.createElement('p');
status.className = 'ws-muted';
status.textContent = `Scanning for links to "${docName}"…`;
panel.appendChild(status);
container.appendChild(panel);
try {
const files = await collectMarkdownFiles(dir, listDir);
const links = await findBacklinks({ docName, docPath, files, readFile });
status.textContent =
files.length === 0
? 'No markdown files found in this folder.'
: `${links.length} document${links.length === 1 ? '' : 's'} link here ` +
`(scanned ${files.length} files)`;
if (links.length > 0) {
const list = document.createElement('div');
list.className = 'ws-issues-list';
for (const link of links) {
const item = document.createElement('div');
item.className = 'ws-issue-item';
const name = pathUtil.basename(link.path);
item.innerHTML = `
<div class="ws-issue-type"><span>${escapeText(name)} : line ${link.line}</span></div>
<div class="ws-issue-text">${escapeText(link.context)}</div>`;
// Whole item is clickable — jump straight to the linking document
item.setAttribute('role', 'link');
item.setAttribute('tabindex', '0');
item.style.cursor = 'pointer';
item.addEventListener('click', () => onFileOpen(link.path));
item.addEventListener('keydown', (event) => {
if (event.key === 'Enter') onFileOpen(link.path);
});
list.appendChild(item);
}
panel.appendChild(list);
}
} catch (error) {
status.textContent = 'Scan failed: ' + (error.message || error);
}
}
function appendNote(parent, text) {
const note = document.createElement('p');
note.className = 'ws-muted';
note.textContent = text;
parent.appendChild(note);
}
function escapeText(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
module.exports = { renderBacklinksPanel };
+180
View File
@@ -0,0 +1,180 @@
/**
* History sidebar panel — local version history for the current document.
*
* Lists snapshots captured automatically before every save (plus manual ones),
* and offers Restore (replace editor content), Diff (side-by-side line diff
* against the current editor content, reusing utils/line-diff), and Delete.
*
* @module HistoryPanel
*/
const { computeLineDiff } = require('../utils/line-diff');
/** Escape text before it enters innerHTML templates. */
function esc(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** "3 min ago"-style relative time for version labels. */
function timeAgo(ts) {
const seconds = Math.max(1, Math.floor((Date.now() - ts) / 1000));
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
/**
* Render the history panel.
*
* @param {HTMLElement} container Sidebar panel content element
* @param {object} deps
* @param {() => string|null} deps.getCurrentFilePath Active tab's file path
* @param {() => string} deps.getCurrentContent Live editor content
* @param {(content: string) => void} deps.replaceContent Swap the editor's document
* @param {object} deps.history IPC-backed VersionHistory api:
* { list(docPath), read(docPath, id), save(docPath, content, label), delete(docPath, id) }
*/
async function renderHistoryPanel(container, deps) {
container.replaceChildren();
const { getCurrentFilePath, getCurrentContent, replaceContent, history } = deps;
const panel = document.createElement('div');
panel.className = 'ws-panel';
const heading = document.createElement('h3');
heading.className = 'ws-heading';
heading.textContent = 'Version History';
panel.appendChild(heading);
const docPath = getCurrentFilePath();
if (!docPath) {
const note = document.createElement('p');
note.className = 'ws-muted';
note.textContent =
'Open a saved document to see its local history. Versions are captured automatically before each save.';
panel.appendChild(note);
container.appendChild(panel);
return;
}
// Manual snapshot control: pin the current state before a risky edit
const snapshotBtn = document.createElement('button');
snapshotBtn.className = 'ws-btn ws-btn-primary';
snapshotBtn.textContent = 'Save version now';
snapshotBtn.addEventListener('click', async () => {
await history.save(docPath, getCurrentContent(), 'manual');
renderHistoryPanel(container, deps);
});
panel.appendChild(snapshotBtn);
let versions = [];
try {
versions = (await history.list(docPath)) || [];
} catch {
/* leave the list empty */
}
const list = document.createElement('div');
list.className = 'ws-issues-list';
if (versions.length === 0) {
const empty = document.createElement('p');
empty.className = 'ws-muted';
empty.textContent = 'No versions yet — one is captured each time you save.';
list.appendChild(empty);
}
for (const version of versions) {
const item = document.createElement('div');
item.className = 'ws-issue-item';
const date = new Date(version.createdAt).toLocaleString();
item.innerHTML = `
<div class="ws-issue-type">
<span>${esc(version.label || 'version')} · ${esc(timeAgo(version.createdAt))}</span>
</div>
<div class="ws-muted">${esc(date)} · ${version.wordCount ?? '?'} words</div>
<div class="ws-issue-actions">
<button class="ws-btn ws-btn-sm" data-action="restore">Restore</button>
<button class="ws-btn ws-btn-sm" data-action="diff">Diff</button>
<button class="ws-btn ws-btn-sm" data-action="delete">Delete</button>
</div>`;
item.querySelector('[data-action="restore"]').addEventListener('click', async () => {
if (
!confirm(
'Replace the editor content with this version? (Your current text is snapped first.)'
)
) {
return;
}
// Guard rail: snapshot the live content before overwriting it
await history.save(docPath, getCurrentContent(), 'before restore');
const content = await history.read(docPath, version.id);
replaceContent(content);
});
item.querySelector('[data-action="diff"]').addEventListener('click', async () => {
const content = await history.read(docPath, version.id);
showDiffModal(content, getCurrentContent(), version);
});
item.querySelector('[data-action="delete"]').addEventListener('click', async () => {
await history.delete(docPath, version.id);
renderHistoryPanel(container, deps);
});
list.appendChild(item);
}
panel.appendChild(list);
container.appendChild(panel);
}
/**
* Overlay modal showing a unified line diff (version → current).
* Read-only; closed with its button, Esc, or backdrop click.
*/
function showDiffModal(oldText, newText, version) {
const overlay = document.createElement('div');
overlay.className = 'ai-settings-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
const body = document.createElement('div');
body.className = 'history-diff-body';
// computeLineDiff takes raw texts and returns {type: 'added'|'removed'|'unchanged', text}
const diffLines = computeLineDiff(String(oldText || ''), String(newText || ''));
for (const line of diffLines) {
const div = document.createElement('div');
div.className = `history-diff-line history-diff-${line.type}`;
div.textContent =
(line.type === 'added' ? '+ ' : line.type === 'removed' ? '- ' : ' ') + (line.text ?? '');
body.appendChild(div);
}
const dialog = document.createElement('div');
dialog.className = 'ai-settings-dialog history-diff-dialog';
dialog.innerHTML = `<h3>Version diff — ${esc(new Date(version.createdAt).toLocaleString())}</h3>
<p class="ai-settings-note">Red: version · Green: current editor content</p>`;
dialog.appendChild(body);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.textContent = 'Close';
closeBtn.className = 'ws-btn';
dialog.appendChild(closeBtn);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
const close = () => overlay.remove();
closeBtn.addEventListener('click', close);
overlay.addEventListener('click', (event) => {
if (event.target === overlay) close();
});
}
module.exports = { renderHistoryPanel };
+30 -1
View File
@@ -17,8 +17,37 @@ class SidebarManager {
?.addEventListener('click', () => this.collapse());
}
registerPanel(name, { title, render }) {
/**
* Register a sidebar panel. Core panels (explorer/git/…) get their icons
* from index.html; plugins pass an `icon` (inline SVG string) and a rail
* button is created for them automatically so their panel is reachable
* without editing the app shell.
*
* @param {string} name Panel id
* @param {object} opts { title, render, icon? }
*/
registerPanel(name, { title, render, icon }) {
this.panels.set(name, { title, render });
if (icon && !document.querySelector(`.sidebar-icon[data-panel="${name}"]`)) {
this.addIcon(name, { title, icon });
}
}
/**
* Append an icon button to the sidebar rail for a registered panel.
* Idempotent — re-registering a plugin won't duplicate its icon.
*/
addIcon(name, { title, icon }) {
const btn = document.createElement('button');
btn.className = 'sidebar-icon';
btn.dataset.panel = name;
btn.title = title || name;
btn.setAttribute('aria-label', title || name);
btn.innerHTML = icon;
btn.addEventListener('click', () => this.togglePanel(name));
this.sidebar?.querySelector('.sidebar-icons')?.appendChild(btn) ||
this.sidebar?.appendChild(btn);
}
togglePanel(name) {
+401
View File
@@ -622,3 +622,404 @@ body[class*='dark'] .outline-footer {
border-color: #333;
color: #6b7280;
}
/* ================================
* Plugin panels (writing-studio + AI Assistant)
* ================================ */
/* Generic plugin panel scaffold */
.ws-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
font-size: 13px;
}
.ws-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.ws-heading {
margin: 0;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted, #6b7280);
}
.ws-btn {
padding: 5px 10px;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 4px;
background: var(--bg-primary, #fff);
color: inherit;
cursor: pointer;
font-size: 12px;
}
.ws-btn:hover {
background: var(--bg-secondary, #f3f4f6);
}
.ws-btn-primary {
background: #4a90d9;
border-color: #4a90d9;
color: #fff;
}
.ws-btn-primary:hover {
background: #3a7bc8;
}
.ws-btn-sm {
padding: 3px 8px;
font-size: 11px;
}
.ws-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ws-muted {
color: var(--text-muted, #9ca3af);
font-size: 12px;
}
.ws-stat-row {
display: flex;
justify-content: space-between;
font-size: 12px;
}
.ws-pct {
font-weight: 600;
color: #4a90d9;
}
.ws-progress-bar {
height: 8px;
border-radius: 4px;
background: var(--bg-secondary, #e5e7eb);
overflow: hidden;
}
.ws-progress-fill {
height: 100%;
background: linear-gradient(90deg, #4a90d9, #6aa8e8);
transition: width 0.3s ease;
}
.ws-snapshot-list,
.ws-issues-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.ws-snapshot-item,
.ws-issue-item {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
padding: 8px;
}
.ws-snapshot-header,
.ws-issue-type {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-muted, #6b7280);
}
.ws-issue-type {
font-weight: 700;
color: #c06040;
}
.ws-issue-text {
margin-top: 4px;
font-size: 12px;
}
.ws-issue-suggestion {
margin-top: 2px;
font-size: 12px;
color: #2e7d32;
}
.ws-issue-actions {
display: flex;
gap: 6px;
margin-top: 6px;
}
/* Writing heatmap (Goals panel) */
.ws-heatmap {
display: grid;
grid-template-rows: repeat(7, 10px);
grid-auto-flow: column;
grid-auto-columns: 10px;
gap: 2px;
}
.ws-heatmap-cell {
width: 10px;
height: 10px;
border-radius: 2px;
background: var(--bg-secondary, #ebedf0);
}
.ws-heatmap-cell.l1 {
background: #9be9a8;
}
.ws-heatmap-cell.l2 {
background: #40c463;
}
.ws-heatmap-cell.l3 {
background: #30a14c;
}
.ws-heatmap-cell.l4 {
background: #216e39;
}
/* AI Chat panel */
.ai-chat-panel {
height: 100%;
}
.ai-chat-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ai-chat-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-chat-badge-ok {
background: rgba(46, 125, 50, 0.12);
color: #2e7d32;
}
.ai-chat-badge-off {
background: rgba(192, 96, 64, 0.12);
color: #c06040;
}
.ai-chat-messages {
flex: 1;
min-height: 200px;
max-height: 45vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0;
}
.ai-chat-msg {
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.ai-chat-msg-user {
background: rgba(74, 144, 217, 0.12);
align-self: flex-end;
max-width: 90%;
}
.ai-chat-msg-assistant {
background: var(--bg-secondary, #f3f4f6);
align-self: flex-start;
max-width: 95%;
}
.ai-chat-msg-busy {
color: var(--text-muted, #9ca3af);
font-style: italic;
}
.ai-chat-msg-role {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted, #9ca3af);
margin-bottom: 2px;
}
.ai-chat-composer {
display: flex;
flex-direction: column;
gap: 8px;
}
.ai-chat-composer textarea {
width: 100%;
resize: vertical;
min-height: 56px;
padding: 8px;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 6px;
font: inherit;
background: var(--bg-primary, #fff);
color: inherit;
box-sizing: border-box;
}
.ai-chat-actions,
.ai-chat-footer {
display: flex;
justify-content: space-between;
gap: 8px;
}
.ai-chat-footer {
justify-content: flex-start;
}
/* AI settings/input modal overlays */
.ai-settings-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.ai-settings-dialog {
background: var(--bg-primary, #fff);
color: var(--text-primary, #222);
border-radius: 8px;
padding: 20px;
width: 380px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.ai-settings-dialog h3 {
margin: 0 0 4px;
}
.ai-settings-dialog label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.ai-settings-dialog input,
.ai-settings-dialog select {
padding: 6px 8px;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 4px;
font: inherit;
background: var(--bg-primary, #fff);
color: inherit;
}
.ai-settings-note {
font-size: 11px;
color: var(--text-muted, #9ca3af);
margin: 0;
}
.ai-settings-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Dark mode adjustments for plugin panels */
body[class*='dark'] .ws-btn {
background: #374151;
border-color: #4b5563;
color: #e5e7eb;
}
body[class*='dark'] .ws-btn:hover {
background: #4b5563;
}
body[class*='dark'] .ai-chat-msg-assistant {
background: #374151;
}
body[class*='dark'] .ai-chat-composer textarea,
body[class*='dark'] .ai-settings-dialog input,
body[class*='dark'] .ai-settings-dialog select {
background: #1f2937;
border-color: #4b5563;
color: #e5e7eb;
}
body[class*='dark'] .ai-settings-dialog {
background: #1f2937;
color: #e5e7eb;
}
/* Wiki links in the preview (knowledge-base mode) */
.preview-content a.wiki-link {
color: #8b5cf6;
text-decoration: none;
border-bottom: 1px dashed #8b5cf6;
}
.preview-content a.wiki-link:hover {
background: rgba(139, 92, 246, 0.1);
}
body[class*='dark'] .preview-content a.wiki-link {
color: #a78bfa;
border-bottom-color: #a78bfa;
}
/* Resolved comments fade but stay visible */
.ws-issue-item.comment-resolved {
opacity: 0.55;
}
/* Goals panel: stat cards + 30-day bar chart (writing-studio) */
.ws-stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.ws-stat-card {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
padding: 10px 6px;
}
.ws-stat-value {
font-size: 18px;
font-weight: 700;
}
.ws-stat-label {
font-size: 11px;
color: var(--text-muted, #9ca3af);
}
.ws-chart {
display: flex;
align-items: flex-end;
gap: 2px;
height: 64px;
}
.ws-bar {
flex: 1;
min-width: 3px;
background: #9cbfe8;
border-radius: 2px 2px 0 0;
}
.ws-bar-met {
background: #2e7d32;
}
/* History panel diff modal */
.history-diff-dialog {
width: 640px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.history-diff-body {
overflow: auto;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
padding: 8px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
flex: 1;
min-height: 200px;
}
.history-diff-line {
padding: 0 4px;
border-radius: 2px;
}
.history-diff-added {
background: rgba(46, 125, 50, 0.12);
color: #2e7d32;
}
.history-diff-removed {
background: rgba(198, 40, 40, 0.1);
color: #c62828;
}
.history-diff-unchanged {
color: var(--text-secondary, inherit);
opacity: 0.75;
}
+55
View File
@@ -100,3 +100,58 @@ body.zen-mode .editor-wrapper {
body[class*='dark'].zen-mode .zen-hud {
background: rgba(0, 0, 0, 0.7);
}
/* Word-goal setter (HUD button + inline dialog) */
.zen-goal-btn {
background: none;
border: none;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0;
opacity: 0.7;
}
.zen-goal-btn:hover {
opacity: 1;
}
.zen-goal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 11000;
}
.zen-goal-dialog {
background: var(--bg-primary, #fff);
color: var(--text-primary, #222);
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.zen-goal-dialog h3 {
margin: 0;
}
.zen-goal-dialog input {
padding: 8px;
border: 1px solid var(--border-color, #ccc);
border-radius: 4px;
font: inherit;
}
.zen-goal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.zen-goal-actions button {
padding: 6px 16px;
border-radius: 4px;
border: 1px solid var(--border-color, #ccc);
background: transparent;
color: inherit;
cursor: pointer;
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Backlink scanning for the local knowledge-base (wiki-link) feature.
*
* "Which documents link to the one I'm editing?" — answered with a bounded
* breadth-first walk of the current folder collecting markdown files, then a
* content scan for `[[docName]]` references. Bounds (depth, file count,
* file size) keep a pathological folder tree from stalling the renderer.
*
* All IO is injected (listDir + readFile), mirroring the adapter style of the
* rest of src/utils, so tests run against fakes.
*
* @module Backlinks
*/
const MAX_DEPTH = 3;
const MAX_FILES = 500;
const MAX_FILE_BYTES = 1024 * 1024; // skip files larger than 1MB in scans
/**
* Collect markdown file paths under rootDir (bounded BFS).
*
* @param {string} rootDir Folder to scan (usually the current document's dir)
* @param {Function} listDir async (dir) => { entries: [{name, isDirectory, path}] } | null
* @param {{maxDepth?: number, maxFiles?: number}} [limits]
* @returns {Promise<string[]>} markdown file paths (not including hidden dirs)
*/
async function collectMarkdownFiles(rootDir, listDir, limits = {}) {
const maxDepth = limits.maxDepth ?? MAX_DEPTH;
const maxFiles = limits.maxFiles ?? MAX_FILES;
const results = [];
const queue = [{ dir: rootDir, depth: 0 }];
while (queue.length > 0 && results.length < maxFiles) {
const { dir, depth } = queue.shift();
if (depth > maxDepth) continue;
let listing;
try {
listing = await listDir(dir);
} catch {
continue; // unreadable folder: skip, don't fail the whole scan
}
if (!listing || !Array.isArray(listing.entries)) continue;
for (const entry of listing.entries) {
if (results.length >= maxFiles) break;
if (entry.isDirectory) {
// Never descend into hidden/vendor folders (.git, node_modules, …)
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
queue.push({ dir: entry.path, depth: depth + 1 });
}
} else if (/\.(md|markdown)$/i.test(entry.name)) {
results.push(entry.path);
}
}
}
return results;
}
/**
* Build the set of wiki targets a document's raw markdown references.
* @returns {string[]} trimmed targets ([[target|alias]] → target)
*/
function extractOutgoingLinks(markdown) {
const regex = /\[\[([^\]#|]+)(?:#[^\]|]*)?(?:\|[^\]]+)?\]\]/g;
const targets = new Set();
let match;
while ((match = regex.exec(String(markdown || ''))) !== null) {
const target = match[1].trim();
if (target) targets.add(target);
}
return [...targets];
}
/**
* Find documents that link to `docName`.
*
* @param {object} args
* @param {string} args.docName Current document name without extension
* @param {string} [args.docPath] Full path of the current doc (excluded from results)
* @param {string[]} args.files Candidate markdown paths
* @param {Function} args.readFile async (path) => string|null
* @returns {Promise<Array<{path:string, line:number, context:string}>>}
*/
async function findBacklinks({ docName, docPath, files, readFile }) {
if (!docName) return [];
const wanted = docName.toLowerCase();
const backlinks = [];
for (const file of files) {
if (docPath && file === docPath) continue;
let content;
try {
content = await readFile(file);
} catch {
continue;
}
if (typeof content !== 'string' || content.length > MAX_FILE_BYTES) continue;
// A backlink mentions [[docName]] or [[docName#heading]] or [[docName.md]]
const regex = new RegExp(
`\\[\\[\\s*${escapeRegex(wanted)}(?:\\.md)?(?:#[^\\]|]*)?\\s*(?:\\|[^\\]]+)?\\]\\]`,
'i'
);
if (!regex.test(content)) continue;
// Collect matching lines (capped) for context display
const lines = content.split('\n');
let hits = 0;
for (let i = 0; i < lines.length && hits < 3; i++) {
if (regex.test(lines[i])) {
backlinks.push({
path: file,
line: i + 1,
context: lines[i].trim().slice(0, 120),
});
hits++;
}
}
}
return backlinks;
}
/** Escape a literal string for embedding in a RegExp. */
function escapeRegex(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
module.exports = {
collectMarkdownFiles,
extractOutgoingLinks,
findBacklinks,
MAX_DEPTH,
MAX_FILES,
MAX_FILE_BYTES,
};
+149
View File
@@ -0,0 +1,149 @@
/**
* Session persistence for crash recovery / session restore.
*
* Keeps a snapshot of the open tabs (file paths, dirty state, and the content
* of unsaved buffers) in localStorage so that after a crash, force-quit, or
* accidental close the user can reopen exactly where they left off. Content is
* only stored for buffers that would otherwise be lost:
*
* - dirty untitled tabs → full content (the only copy in existence)
* - dirty file tabs → full content (differs from what is on disk)
* - clean file tabs → path only (re-read from disk on restore)
* - PDF tabs → path only (reopened in the PDF viewer)
*
* The module is pure logic over an injected Storage-like object so it can be
* unit-tested without jsdom's localStorage quirks.
*
* @module SessionStore
*/
const STORAGE_KEY = 'editor-session';
// localStorage budgets are typically 5-10MB; stay well under with a 2MB cap on
// captured content so a pathological 40-tab session cannot blow the quota and
// take the whole snapshot down with a QuotaExceededError.
const MAX_CAPTURED_CONTENT_CHARS = 2 * 1024 * 1024;
/**
* Build a serializable snapshot from the live tab map.
*
* @param {Map<number, object>} tabs TabManager's tab map
* @param {number} activeTabId Currently selected tab id
* @param {Function} getContentFn (tabId) => string — live editor content
* @returns {{version: number, savedAt: number, activeTabId: number,
* tabs: Array<{id:number,type:string,title:string,filePath:string|null,
* isDirty:boolean,content:(string|undefined)}>}}
*/
function captureSession(tabs, activeTabId, getContentFn) {
const sessionTabs = [];
let budget = MAX_CAPTURED_CONTENT_CHARS;
for (const tab of tabs.values()) {
const entry = {
id: tab.id,
type: tab.type || 'markdown',
title: tab.title || 'Untitled',
filePath: tab.filePath || null,
isDirty: Boolean(tab.isDirty),
};
// Capture unsaved content, newest-budget-first (tabs iterate in id order;
// later tabs are newer). Skip capture once the budget is exhausted rather
// than silently truncating a buffer mid-file.
if (entry.isDirty && budget > 0) {
const content = getContentFn ? getContentFn(tab.id) : tab.content || '';
if (typeof content === 'string' && content.length <= budget) {
entry.content = content;
budget -= content.length;
} else if (typeof content === 'string') {
// This single buffer exceeds the remaining budget — still try to keep
// it if it fits the overall cap (other tabs consumed nothing yet).
entry.content = content.length <= MAX_CAPTURED_CONTENT_CHARS ? content : undefined;
}
}
// Drop empty untitled tabs — they carry nothing worth restoring
if (!entry.filePath && !entry.isDirty && !(entry.content && entry.content.length > 0)) {
continue;
}
if (!entry.filePath && entry.content === undefined) {
continue;
}
sessionTabs.push(entry);
}
return {
version: 1,
savedAt: Date.now(),
activeTabId,
tabs: sessionTabs,
};
}
/**
* Persist a snapshot. Quota failures are swallowed after clearing the key: a
* failed save must never crash the editor, and a stale snapshot is worse than
* none (it would offer to restore outdated buffers).
*
* @param {Storage} storage localStorage-like object
* @param {object} session Snapshot from captureSession()
*/
function saveSession(storage, session) {
try {
storage.setItem(STORAGE_KEY, JSON.stringify(session));
} catch {
try {
storage.removeItem(STORAGE_KEY);
} catch {
/* nothing more to do */
}
}
}
/**
* Load a snapshot. Returns null when nothing is stored, the JSON is corrupt,
* or the shape is unrecognizable — callers treat null as "nothing to restore".
*
* @param {Storage} storage localStorage-like object
* @returns {object|null}
*/
function loadSession(storage) {
try {
const raw = storage.getItem(STORAGE_KEY);
if (!raw) return null;
const session = JSON.parse(raw);
if (
!session ||
session.version !== 1 ||
!Array.isArray(session.tabs) ||
session.tabs.length === 0
) {
return null;
}
return session;
} catch {
return null;
}
}
/**
* Remove the snapshot. Called on clean shutdown so a fresh launch doesn't
* offer to "restore" a session the user deliberately closed.
*
* @param {Storage} storage localStorage-like object
*/
function clearSession(storage) {
try {
storage.removeItem(STORAGE_KEY);
} catch {
/* best-effort */
}
}
module.exports = {
STORAGE_KEY,
captureSession,
saveSession,
loadSession,
clearSession,
};
+93
View File
@@ -0,0 +1,93 @@
/**
* Wiki-link ([[target]] / [[target|alias]] / [[target#heading]]) support.
*
* Markdown has no spec for wiki-links; marked renders the bracket text
* literally, so this module post-processes rendered HTML and turns wiki-link
* text into anchor elements. Code blocks and inline code are skipped so
* documentation *about* wiki-links doesn't become live links.
*
* Pure string processing — no DOM, no IO — so it is directly testable.
*
* @module WikiLinks
*/
/**
* Matches [[target]], [[target|alias]], [[target#section]] and
* [[target#section|alias]]. Group 1 = target, group 2 = optional alias.
* Brackets are not allowed inside (that would be nested links).
*/
const WIKI_LINK_REGEX = /\[\[([^\]#|]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g;
/**
* Replace wiki-link text with anchors in a rendered-HTML string, skipping
* <pre>/<code> segments.
*
* @param {string} html Rendered markdown HTML
* @returns {string} HTML with <a class="wiki-link" data-wiki-target="...">…
*/
function renderWikiLinksInHtml(html) {
if (typeof html !== 'string' || html.indexOf('[[') === -1) return html;
// Split into code/non-code segments so replacements only happen in prose
const segments = html.split(/(<pre\b[\s\S]*?<\/pre>|<code\b[\s\S]*?<\/code>)/i);
return segments
.map((segment, index) => {
// Odd indices are the captured code blocks (never transformed)
if (index % 2 === 1) return segment;
return segment.replace(WIKI_LINK_REGEX, (_match, target, alias) => {
const clean = String(target).trim();
if (!clean) return _match;
const label = alias ? String(alias).trim() : clean;
return `<a href="#" class="wiki-link" data-wiki-target="${escapeAttr(clean)}">${escapeHtml(label)}</a>`;
});
})
.join('');
}
/**
* Resolve a wiki target to a markdown file path next to the current document.
* "Note" → <dir>/Note.md; "Note.md" stays as-is; "./sub/Note" resolves
* relative to dir. Returns null for empty/unsafe targets (absolute paths
* and traversal outside the vault would be surprising at best).
*
* @param {string} target Wiki target (already trimmed)
* @param {string} currentDir Directory of the current document
* @param {Function} pathUtil injected path module
* @returns {string|null}
*/
function resolveTargetPath(target, currentDir, pathUtil) {
if (!target || !currentDir) return null;
const clean = String(target).trim();
if (!clean || clean.startsWith('/') || /^[a-zA-Z]:/.test(clean)) return null;
if (clean.includes('..')) return null;
const withExt = /\.(md|markdown)$/i.test(clean) ? clean : `${clean}.md`;
return pathUtil.join(currentDir, withExt);
}
/** Escape text for HTML content. */
function escapeHtml(text) {
return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Escape text for a double-quoted attribute value. */
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;');
}
/**
* The document-name key used for backlink matching: basename without
* extension, e.g. "/docs/My Note.md" → "My Note".
*/
function docNameFor(docPath, pathUtil) {
if (!docPath) return null;
const base = pathUtil.basename(docPath);
return base.replace(/\.(md|markdown)$/i, '');
}
module.exports = {
WIKI_LINK_REGEX,
renderWikiLinksInHtml,
resolveTargetPath,
docNameFor,
};
+54
View File
@@ -157,15 +157,69 @@ class ZenMode {
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
<span class="zen-hud-sep">&middot;</span>
<span class="zen-hud-item" id="zen-timer">00:00</span>
<span class="zen-hud-sep">&middot;</span>
<button class="zen-hud-item zen-goal-btn" id="zen-goal-btn" type="button"
title="Set a session word goal">&#9878; goal</button>
<div class="zen-progress-container" id="zen-progress-container">
<div class="zen-progress-bar" id="zen-progress-bar"></div>
</div>
`;
document.body.appendChild(hud);
this._hud = hud;
this._wireGoalButton();
this._updateHUD();
}
/**
* The word-goal progress bar reads localStorage('zen-word-goal'), but until
* now nothing in the app ever set it. The HUD button opens a small inline
* input (window.prompt is unavailable in Electron) that sets or clears it.
*/
_wireGoalButton() {
const btn = this._hud?.querySelector('#zen-goal-btn');
if (!btn) return;
btn.addEventListener('click', () => {
const current = localStorage.getItem('zen-word-goal') || '';
const overlay = document.createElement('div');
overlay.className = 'zen-goal-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML = `
<div class="zen-goal-dialog">
<h3>Session word goal</h3>
<input type="number" min="0" step="50" value="${current}"
placeholder="e.g. 500 (0 clears the goal)" aria-label="Word goal" />
<div class="zen-goal-actions">
<button type="button" data-role="ok">Set</button>
<button type="button" data-role="cancel">Cancel</button>
</div>
</div>`;
const input = overlay.querySelector('input');
const done = () => overlay.remove();
overlay.querySelector('[data-role="ok"]').addEventListener('click', () => {
const value = parseInt(input.value, 10);
if (Number.isFinite(value) && value > 0) {
localStorage.setItem('zen-word-goal', String(value));
} else {
localStorage.removeItem('zen-word-goal');
}
done();
this._updateHUD();
});
overlay.querySelector('[data-role="cancel"]').addEventListener('click', done);
overlay.addEventListener('click', (event) => {
if (event.target === overlay) done();
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') overlay.querySelector('[data-role="ok"]').click();
if (event.key === 'Escape') done();
});
document.body.appendChild(overlay);
input.focus();
input.select();
});
}
_updateHUD() {
if (!this.active || !this._hud) return;