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