mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
Compare commits
2
Commits
b83ba91731
...
efca458495
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efca458495 | ||
|
|
c4dcbd8caf |
@@ -51,6 +51,16 @@ A powerful cross-platform Markdown editor and document converter powered by Pand
|
||||
- **ASCII Art Generator** - Create text banners and diagrams
|
||||
- **Word templates** - Use custom Word templates for enhanced exports
|
||||
- **Import documents** - Import from 30+ formats (DOCX, PDF, HTML, etc.)
|
||||
- **Excel export** - Markdown tables to native .xlsx workbooks (one sheet per table)
|
||||
- **AI Assistant** - Multi-provider AI help (OpenAI/Anthropic/Ollama/LM Studio): chat panel, summarize/improve/translate commands, grammar proofreading
|
||||
- **Inline comments** - Anchor-based document comments in `.comments/` sidecars with F8 navigation
|
||||
- **Wiki-links & Backlinks** - `[[Note]]` links with click-to-create and a "what links here?" panel (local knowledge base)
|
||||
- **Crash recovery** - Session restore of open tabs and unsaved buffers after a crash
|
||||
- **Version history** - Automatic pre-save snapshots with restore/diff/delete from the History panel
|
||||
- **Vim mode & snippet expansion** - Vim keybindings toggle; Tab expands saved snippets
|
||||
- **Quick Note** - Global scratchpad (Ctrl+Alt+Q) that appends to `notes/quick-notes.md`
|
||||
- **Real PDF encryption** - Password protection, removal, and permissions actually work
|
||||
- **Offline math & diagrams** - KaTeX bundled locally; PlantUML renders locally when the CLI is installed
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -96,9 +106,18 @@ npm run build:linux
|
||||
| Redo | Ctrl+Shift+Z |
|
||||
| New Tab | Ctrl+T |
|
||||
| Close Tab | Ctrl+W |
|
||||
| Toggle Preview | Ctrl+Shift+P |
|
||||
| Toggle Preview | Ctrl+Shift+V |
|
||||
| Zoom In | Ctrl+Shift++ |
|
||||
| Zoom Out | Ctrl+Shift+- |
|
||||
| Command Palette | Ctrl+Shift+P |
|
||||
| Zen Mode | F11 |
|
||||
| Writing Analytics | Ctrl+Shift+A |
|
||||
| Quick Note | Ctrl+Alt+Q |
|
||||
| Universal Converter | Ctrl+Shift+C |
|
||||
| Table Generator | Ctrl+Shift+T |
|
||||
| ASCII Art Generator | Ctrl+Shift+A |
|
||||
| Next Comment | F8 |
|
||||
| Add Comment at Cursor | Ctrl+Alt+M |
|
||||
|
||||
## Themes
|
||||
|
||||
@@ -162,4 +181,4 @@ Amit Haridas (amit.wh@gmail.com)
|
||||
|
||||
## Version
|
||||
|
||||
v4.5.0
|
||||
v4.6.0
|
||||
|
||||
+68
@@ -1,5 +1,73 @@
|
||||
# PanConverter - Updates & Changelog
|
||||
|
||||
## Version 4.6.0 (2026-09-05)
|
||||
|
||||
### New Features
|
||||
|
||||
#### AI Assistant Plugin (multi-provider)
|
||||
- Chat sidebar panel with rolling conversation history and insert-reply-into-document
|
||||
- Providers: OpenAI, Anthropic, Ollama, LM Studio, and any OpenAI-compatible endpoint
|
||||
- All provider traffic proxied through the main process — API keys never enter the renderer and the CSP stays closed to AI endpoints
|
||||
- Commands: AI Summarize / Improve / Explain / Translate selection
|
||||
- Answers the writing-studio `ai:analyze` contract, finally enabling the Proofread panel
|
||||
|
||||
#### Collaboration Plugin (inline comments)
|
||||
- Anchor-based comments stored in `.comments/` sidecar files (never exported, never committed)
|
||||
- Comments sidebar panel: add at cursor, list, resolve, delete, jump to anchor
|
||||
- Drift detection flags moved/edited anchors; F8 navigates to the next open comment
|
||||
|
||||
#### Local Knowledge Base (wiki-links + backlinks)
|
||||
- `[[Note]]`, `[[Note|alias]]`, `[[Note#section]]` render as links in the preview (code blocks excluded)
|
||||
- Clicking a wiki-link opens the note or offers to create it
|
||||
- Backlinks sidebar panel scans the folder (bounded BFS) for documents linking to the current one
|
||||
|
||||
#### Crash Recovery / Session Restore
|
||||
- Open tabs (paths + unsaved buffer content) snapshotted to localStorage, debounced on edits, on tab changes, on unload, and once a minute
|
||||
- Restore prompt on launch with per-tab restore, clean-fresh option, and 2MB content budget
|
||||
|
||||
#### Document Version History
|
||||
- Every save snapshots the previous on-disk content to `<userData>/versions/`
|
||||
- History sidebar panel: list, restore (with safety snapshot), unified diff vs. current, delete, manual "save version now"
|
||||
- Per-document pruning (20 versions), path-hash storage, id-validated reads
|
||||
|
||||
#### Editor
|
||||
- Vim keybindings (View → Vim Mode, persisted, live toggle via CodeMirror Compartment)
|
||||
- Snippet Tab-expansion: type a snippet name and press Tab to insert it
|
||||
- Zen Mode word-goal setter (the HUD progress bar finally has UI)
|
||||
|
||||
#### Export / Conversion
|
||||
- **Real PDF encryption**: pdf-lib swapped for @cantoo/pdf-lib — encrypt/decrypt/permissions now actually work (UI auto-enables via the capability probe)
|
||||
- **XLSX export**: markdown tables → native Excel workbook, one sheet per table (no Pandoc needed)
|
||||
- **ODT headers/footers + page size**: real ODF styles.xml patching replaces the empty stub
|
||||
- **Local PlantUML rendering**: diagrams render on-machine via the `plantuml` CLI when installed; plantuml.com stays as fallback
|
||||
- **KaTeX bundled locally** (CSS + fonts): math renders offline, no CDN calls
|
||||
- Writing heatmap (GitHub-style 30-day grid) in the writing-studio Goals panel
|
||||
|
||||
#### Platform
|
||||
- Quick Note global scratchpad (Ctrl+Alt+Q, works when unfocused; appends to `notes/quick-notes.md`)
|
||||
- Deep link protocol `markdownconverter://open?path=…`
|
||||
- REPL confirmation dialog before first code execution per language per session (unsandboxed-execution guard rail)
|
||||
- Writing-studio's four sidebar panels (Manuscript/Goals/Snapshots/Proofread) are now actually wired with rail icons
|
||||
- Plugin sidebar panels get automatic rail icons via `registerPanel({icon})`
|
||||
|
||||
### Bug Fixes
|
||||
- `Ctrl+Shift+P` collision: PDF (Enhanced) export now `Ctrl+Alt+Shift+P`; Command Palette keeps `Ctrl+Shift+P`
|
||||
- Universal Converter's Pandoc tool no longer always reports "not installed" (`checkConverterAvailable` gained a pandoc case with bundled-binary check)
|
||||
- CLI headless export: removed dangling `--css` / `--reference-doc` flags that made pandoc exit with an error; `--self-contained` replaced with `--standalone` (Pandoc 3.x)
|
||||
- Removed dead "Open Export Options Dialog…" button from the converter dialog
|
||||
- Removed duplicate `styles-zen.css` include
|
||||
|
||||
### Security
|
||||
- AI provider requests carry size caps (200KB prompt), timeouts (120s), and user-safe error surfaces
|
||||
- Version-history reads validate ids against traversal; history listing requires a valid document path
|
||||
- PlantUML local rendering removes the diagram-text exfiltration path when a local CLI exists (CVE-MC-007 follow-up)
|
||||
|
||||
### Tests
|
||||
- New suites: OdtStyling, AiProviders, ai-assistant prompts, collaboration comment-store, wiki-links/backlinks, session-store, XlsxExporter, VersionHistory
|
||||
- PDFOperations encryption tests rewritten for the real-encryption reality
|
||||
|
||||
---
|
||||
|
||||
## Version 4.0.0 (2026-03-04)
|
||||
|
||||
### Major Changes
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
File diff suppressed because one or more lines are too long
Generated
+1099
-2163
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "markdown-converter",
|
||||
"version": "4.5.0",
|
||||
"version": "4.6.0",
|
||||
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||
"main": "src/main.js",
|
||||
"scripts": {
|
||||
@@ -55,6 +55,7 @@
|
||||
"prettier": "^3.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.9.1",
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -69,25 +70,25 @@
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.16",
|
||||
"@replit/codemirror-vim": "^6.4.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"core-util-is": "^1.0.3",
|
||||
"docx": "^9.6.0",
|
||||
"docx4js": "^2.0.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"electron-store": "^10.1.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.18.5",
|
||||
"marked": "^17.0.3",
|
||||
"marked-footnote": "^1.4.0",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"mermaid": "^11.12.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.5.207",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pizzip": "^3.2.0",
|
||||
"sharp": "^0.34.3",
|
||||
"sharp": "^0.35.4",
|
||||
"simple-git": "^3.32.3",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
+431
-15
@@ -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>
|
||||
|
||||
@@ -2142,7 +2186,7 @@ function processDynamicFields(text, metadata = {}) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Add headers/footers to DOCX file using PizZip and docx4js
|
||||
// Add headers/footers to DOCX file using PizZip
|
||||
// Function to set page size in DOCX files
|
||||
async function setDocxPageSize(docxPath) {
|
||||
try {
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* AI Assistant prompt builder + proofread parser tests (pure module, jsdom
|
||||
* not required but harmless under the default environment).
|
||||
*/
|
||||
const {
|
||||
buildTaskPrompt,
|
||||
buildProofreadPrompt,
|
||||
parseProofreadIssues,
|
||||
} = require('../src/plugins/built-in/ai-assistant/prompts');
|
||||
|
||||
describe('AI Assistant prompts', () => {
|
||||
describe('buildTaskPrompt', () => {
|
||||
it('builds a summarize prompt embedding the text', () => {
|
||||
const { system, user } = buildTaskPrompt('summarize', 'the text here');
|
||||
expect(system).toMatch(/summar/i);
|
||||
expect(user).toContain('the text here');
|
||||
});
|
||||
|
||||
it('passes the target language through for translate', () => {
|
||||
const { user } = buildTaskPrompt('translate', 'bonjour', 'English');
|
||||
expect(user).toContain('English');
|
||||
expect(user).toContain('bonjour');
|
||||
});
|
||||
|
||||
it('throws on unknown actions', () => {
|
||||
expect(() => buildTaskPrompt('nope', 'x')).toThrow(/Unknown AI task/);
|
||||
});
|
||||
|
||||
it('tolerates missing text', () => {
|
||||
expect(buildTaskPrompt('explain', undefined).user).toBe('Explain the following:\n\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProofreadPrompt', () => {
|
||||
it('asks for a strict JSON array', () => {
|
||||
const { system, user } = buildProofreadPrompt('Some text.');
|
||||
expect(system).toMatch(/JSON array/);
|
||||
expect(user).toContain('Some text.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseProofreadIssues', () => {
|
||||
it('parses a clean JSON array', () => {
|
||||
const issues = parseProofreadIssues(
|
||||
'[{"type":"Spelling","message":"teh","suggestion":"the"}]'
|
||||
);
|
||||
expect(issues).toEqual([{ type: 'spelling', message: 'teh', suggestion: 'the' }]);
|
||||
});
|
||||
|
||||
it('strips code fences and surrounding prose', () => {
|
||||
const issues = parseProofreadIssues(
|
||||
'Here you go:\n```json\n[{"type":"grammar","message":"run on","suggestion":"split"}]\n```\nDone!'
|
||||
);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].suggestion).toBe('split');
|
||||
});
|
||||
|
||||
it('tolerates trailing commas', () => {
|
||||
const issues = parseProofreadIssues(
|
||||
'[{"type":"style","message":"wordy","suggestion":"cut",},]'
|
||||
);
|
||||
expect(issues).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns [] for empty/garbage input instead of throwing', () => {
|
||||
expect(parseProofreadIssues('')).toEqual([]);
|
||||
expect(parseProofreadIssues('no json at all')).toEqual([]);
|
||||
expect(parseProofreadIssues('{"not":"an array"}')).toEqual([]);
|
||||
expect(parseProofreadIssues(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops malformed entries and caps the list length', () => {
|
||||
const many = Array.from({ length: 300 }, (_, i) => ({
|
||||
type: 'grammar',
|
||||
message: `m${i}`,
|
||||
suggestion: 's',
|
||||
}));
|
||||
expect(parseProofreadIssues(JSON.stringify(many))).toHaveLength(100);
|
||||
const mixed = parseProofreadIssues('[{"message":"ok","suggestion":"x"},null,42,{"foo":1}]');
|
||||
// null/number entries dropped; {foo:1} dropped for lacking message+ suggestion
|
||||
expect(mixed).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Collaboration comment-store tests — pure logic with in-memory IO fakes,
|
||||
* covering persistence, navigation ordering, and anchor drift detection.
|
||||
*/
|
||||
const path = require('path');
|
||||
const store = require('../src/plugins/built-in/collaboration/comment-store');
|
||||
|
||||
/** In-memory IO fake shaped like the plugin's IPC adapters. */
|
||||
function makeIo(initial = {}) {
|
||||
const files = new Map(Object.entries(initial));
|
||||
return {
|
||||
files,
|
||||
readFile: (p) => (files.has(p) ? files.get(p) : null),
|
||||
writeFile: (p, c) => {
|
||||
files.set(p, c);
|
||||
},
|
||||
fileExists: (p) => files.has(p),
|
||||
ensureDirectory: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('comment-store', () => {
|
||||
const doc = path.join('/docs', 'notes.md');
|
||||
const sidecar = path.join('/docs', '.comments', 'notes.md.json');
|
||||
|
||||
describe('commentsFilePathFor', () => {
|
||||
it('places the sidecar in .comments/ next to the document', () => {
|
||||
expect(store.commentsFilePathFor(doc, path)).toBe(sidecar);
|
||||
expect(store.commentsFilePathFor('/a/b/c.md', path)).toBe(
|
||||
path.join('/a/b/.comments', 'c.md.json')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load/save round-trip', () => {
|
||||
it('returns [] for documents with no sidecar yet', () => {
|
||||
expect(store.loadComments(doc, makeIo(), path)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for corrupt JSON instead of throwing', () => {
|
||||
const io = makeIo({ [sidecar]: '{oops' });
|
||||
expect(store.loadComments(doc, io, path)).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists and reloads comments sorted by line', () => {
|
||||
const io = makeIo();
|
||||
const comments = [];
|
||||
store.addComment(comments, { line: 12, anchorText: 'para two', text: 'expand this' });
|
||||
store.addComment(comments, { line: 3, anchorText: 'intro', text: 'strong opener' });
|
||||
store.saveComments(doc, comments, io, path);
|
||||
|
||||
const loaded = store.loadComments(doc, io, path);
|
||||
expect(loaded.map((c) => c.line)).toEqual([3, 12]);
|
||||
expect(loaded[0].text).toBe('strong opener');
|
||||
expect(loaded[0].resolved).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes malformed entries on load', () => {
|
||||
const io = makeIo({
|
||||
[sidecar]: JSON.stringify({
|
||||
comments: [
|
||||
{ line: '7', text: 42 },
|
||||
{ line: 2, text: 'ok', author: 'ana' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const loaded = store.loadComments(doc, io, path);
|
||||
// '7' (string line) is dropped; the valid one survives normalization
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]).toMatchObject({ line: 2, author: 'ana' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutators', () => {
|
||||
it('addComment clamps lines and caps field lengths', () => {
|
||||
const comments = [];
|
||||
const c = store.addComment(comments, {
|
||||
line: -5,
|
||||
anchorText: 'x'.repeat(500),
|
||||
text: 'y'.repeat(5000),
|
||||
});
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(c.line).toBe(1);
|
||||
expect(c.anchorText.length).toBeLessThanOrEqual(200);
|
||||
expect(c.text.length).toBeLessThanOrEqual(4000);
|
||||
expect(c.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggleResolved flips state and returns null for unknown ids', () => {
|
||||
const comments = [];
|
||||
const c = store.addComment(comments, { line: 1, text: 'hi' });
|
||||
expect(store.toggleResolved(comments, c.id)).toBe(true);
|
||||
expect(store.toggleResolved(comments, c.id)).toBe(false);
|
||||
expect(store.toggleResolved(comments, 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteComment reports whether anything was removed', () => {
|
||||
const comments = [];
|
||||
const c = store.addComment(comments, { line: 1, text: 'hi' });
|
||||
expect(store.deleteComment(comments, c.id)).toBe(true);
|
||||
expect(comments).toHaveLength(0);
|
||||
expect(store.deleteComment(comments, c.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextUnresolved (F8 navigation)', () => {
|
||||
it('returns the first open comment below the cursor line', () => {
|
||||
const comments = [
|
||||
{ id: 'a', line: 5, resolved: false },
|
||||
{ id: 'b', line: 20, resolved: false },
|
||||
];
|
||||
expect(store.nextUnresolved(comments, 7).id).toBe('b');
|
||||
});
|
||||
|
||||
it('wraps to the top when no open comments are below', () => {
|
||||
const comments = [
|
||||
{ id: 'a', line: 5, resolved: false },
|
||||
{ id: 'b', line: 20, resolved: true },
|
||||
];
|
||||
expect(store.nextUnresolved(comments, 10).id).toBe('a');
|
||||
});
|
||||
|
||||
it('returns null when everything is resolved or empty', () => {
|
||||
expect(store.nextUnresolved([{ id: 'a', line: 1, resolved: true }], 1)).toBeNull();
|
||||
expect(store.nextUnresolved([], 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchorStatus (drift detection)', () => {
|
||||
const base = { line: 2, anchorText: 'original text' };
|
||||
|
||||
it('reports ok when the line still matches', () => {
|
||||
expect(store.anchorStatus(base, ['first', 'original text', 'third'])).toBe('ok');
|
||||
});
|
||||
|
||||
it('reports changed when the line text differs', () => {
|
||||
expect(store.anchorStatus(base, ['first', 'edited text', 'third'])).toBe('changed');
|
||||
});
|
||||
|
||||
it('reports missing when the line is beyond the document', () => {
|
||||
expect(store.anchorStatus({ ...base, line: 99 }, ['only'])).toBe('missing');
|
||||
expect(store.anchorStatus(base, [])).toBe('missing');
|
||||
});
|
||||
|
||||
it('reports moved for comments without an anchor snippet', () => {
|
||||
expect(store.anchorStatus({ line: 1, anchorText: '' }, ['whatever'])).toBe('moved');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*
|
||||
* AiProviders tests with a stubbed fetch — no network access. Verifies the
|
||||
* provider dispatch, auth handling, timeouts, and the user-safe error paths.
|
||||
*/
|
||||
const {
|
||||
complete,
|
||||
resolveSettings,
|
||||
AiProviderError,
|
||||
PROVIDER_DEFAULTS,
|
||||
MAX_PROMPT_CHARS,
|
||||
} = require('../../src/main/AiProviders');
|
||||
|
||||
/** Build a fetch stub returning the given JSON with status 200. */
|
||||
function okFetch(body) {
|
||||
return jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
});
|
||||
}
|
||||
|
||||
describe('AiProviders', () => {
|
||||
describe('resolveSettings', () => {
|
||||
it('applies provider defaults for base URL and model', () => {
|
||||
const s = resolveSettings({ provider: 'ollama' });
|
||||
expect(s.baseUrl).toBe(PROVIDER_DEFAULTS.ollama.baseUrl);
|
||||
expect(s.model).toBe(PROVIDER_DEFAULTS.ollama.defaultModel);
|
||||
expect(s.apiKey).toBe('');
|
||||
});
|
||||
|
||||
it('does not require an API key for local providers', () => {
|
||||
expect(() => resolveSettings({ provider: 'lmstudio' })).not.toThrow();
|
||||
expect(() => resolveSettings({ provider: 'ollama' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('requires an API key for remote providers', () => {
|
||||
expect(() => resolveSettings({ provider: 'openai' })).toThrow(AiProviderError);
|
||||
expect(() => resolveSettings({ provider: 'anthropic' })).toThrow(/API key/);
|
||||
});
|
||||
|
||||
it('rejects unknown providers and non-http(s) base URLs', () => {
|
||||
expect(() => resolveSettings({ provider: 'nope' })).toThrow(/Unknown AI provider/);
|
||||
expect(() =>
|
||||
resolveSettings({ provider: 'openai-compatible', apiKey: 'k', baseUrl: 'file:///etc' })
|
||||
).toThrow(/Invalid API base URL/);
|
||||
});
|
||||
|
||||
it('clamps temperature into [0, 2]', () => {
|
||||
expect(resolveSettings({ provider: 'ollama', temperature: 99 }).temperature).toBe(2);
|
||||
expect(resolveSettings({ provider: 'ollama', temperature: -5 }).temperature).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete', () => {
|
||||
const messages = [{ role: 'user', content: 'hello' }];
|
||||
|
||||
it('rejects requests with no messages', async () => {
|
||||
await expect(complete({ provider: 'ollama', messages: [] })).rejects.toThrow(/No messages/);
|
||||
});
|
||||
|
||||
it('rejects prompts over the size cap', async () => {
|
||||
await expect(
|
||||
complete({
|
||||
provider: 'ollama',
|
||||
messages: [{ role: 'user', content: 'x'.repeat(MAX_PROMPT_CHARS + 1) }],
|
||||
})
|
||||
).rejects.toThrow(/too large/i);
|
||||
});
|
||||
|
||||
it('sends the OpenAI chat-completions shape with a bearer key', async () => {
|
||||
const fetchImpl = okFetch({ choices: [{ message: { content: 'hi there' } }] });
|
||||
const result = await complete(
|
||||
{ provider: 'openai', apiKey: 'sk-test', messages },
|
||||
{ fetchImpl }
|
||||
);
|
||||
|
||||
expect(result.content).toBe('hi there');
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe('https://api.openai.com/v1/chat/completions');
|
||||
expect(init.headers.Authorization).toBe('Bearer sk-test');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.messages[0].role).toBe('user');
|
||||
});
|
||||
|
||||
it('prepends the system prompt as a system message for OpenAI-style providers', async () => {
|
||||
const fetchImpl = okFetch({ choices: [{ message: { content: 'ok' } }] });
|
||||
await complete({ provider: 'ollama', system: 'be brief', messages }, { fetchImpl });
|
||||
const body = JSON.parse(fetchImpl.mock.calls[0][1].body);
|
||||
expect(body.messages[0]).toEqual({ role: 'system', content: 'be brief' });
|
||||
});
|
||||
|
||||
it('sends the Anthropic messages shape with x-api-key header', async () => {
|
||||
const fetchImpl = okFetch({
|
||||
content: [{ type: 'text', text: 'claude says hi' }],
|
||||
});
|
||||
const result = await complete(
|
||||
{ provider: 'anthropic', apiKey: 'ak-test', system: 'be nice', messages },
|
||||
{ fetchImpl }
|
||||
);
|
||||
|
||||
expect(result.content).toBe('claude says hi');
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe('https://api.anthropic.com/v1/messages');
|
||||
expect(init.headers['x-api-key']).toBe('ak-test');
|
||||
expect(init.headers['anthropic-version']).toBe('2023-06-01');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.system).toBe('be nice');
|
||||
});
|
||||
|
||||
it('surfaces HTTP failures as user-safe errors without the body', async () => {
|
||||
const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 401 });
|
||||
await expect(
|
||||
complete({ provider: 'openai', apiKey: 'bad', messages }, { fetchImpl })
|
||||
).rejects.toThrow(/HTTP 401/);
|
||||
});
|
||||
|
||||
it('rejects unexpected response shapes', async () => {
|
||||
const fetchImpl = okFetch({ unexpected: true });
|
||||
await expect(
|
||||
complete({ provider: 'openai', apiKey: 'k', messages }, { fetchImpl })
|
||||
).rejects.toThrow(/unexpected response/i);
|
||||
});
|
||||
|
||||
it('surfaces aborts as timeouts', async () => {
|
||||
const fetchImpl = jest.fn(
|
||||
(_url, init) =>
|
||||
new Promise((_res, rej) => {
|
||||
init.signal.addEventListener('abort', () =>
|
||||
rej(new Error('The operation was aborted'))
|
||||
);
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
complete({ provider: 'ollama', messages }, { fetchImpl, timeoutMs: 30 })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*
|
||||
* OdtStyling.js tests: page-size patching and header/footer injection into a
|
||||
* Pandoc-style ODT package. Fixtures are minimal zips built with PizZip,
|
||||
* mirroring the structure Pandoc's ODT writer emits (styles.xml with a "pm1"
|
||||
* page layout and a "Standard" master page).
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const PizZip = require('pizzip');
|
||||
const {
|
||||
setOdtPageSize,
|
||||
addHeaderFooterToOdt,
|
||||
parseDimensionsMm,
|
||||
escapeOdtText,
|
||||
} = require('../../src/main/OdtStyling');
|
||||
|
||||
// Minimal Pandoc-like styles.xml: page layout pm1 + Standard master page
|
||||
const STYLES_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
|
||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
|
||||
office:version="1.2">
|
||||
<office:automatic-styles>
|
||||
<style:page-layout style:name="pm1">
|
||||
<style:page-layout-properties fo:margin-top="0.787in" fo:page-width="8.267in" fo:page-height="11.69in" style:print-orientation="portrait"/>
|
||||
</style:page-layout>
|
||||
</office:automatic-styles>
|
||||
<office:master-styles>
|
||||
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
|
||||
</office:master-styles>
|
||||
</office:document-styles>`;
|
||||
|
||||
/**
|
||||
* Write a minimal .odt fixture containing just styles.xml.
|
||||
* @param {string} filePath destination
|
||||
* @param {string} [stylesXml] styles.xml content (defaults to STYLES_XML)
|
||||
*/
|
||||
function writeOdtFixture(filePath, stylesXml = STYLES_XML) {
|
||||
const zip = new PizZip();
|
||||
zip.file('mimetype', 'application/vnd.oasis.opendocument.text');
|
||||
zip.file('styles.xml', stylesXml);
|
||||
zip.file('content.xml', '<office:document-content/>');
|
||||
fs.writeFileSync(filePath, zip.generate({ type: 'nodebuffer' }));
|
||||
}
|
||||
|
||||
/** Read styles.xml back out of a written fixture. */
|
||||
function readStylesXml(filePath) {
|
||||
return new PizZip(fs.readFileSync(filePath)).file('styles.xml').asText();
|
||||
}
|
||||
|
||||
describe('OdtStyling', () => {
|
||||
let tmpDir, odtPath;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odtstyle_'));
|
||||
odtPath = path.join(tmpDir, 'doc.odt');
|
||||
writeOdtFixture(odtPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('parseDimensionsMm', () => {
|
||||
it('parses "210×297mm" (U+00D7 separator) into numbers', () => {
|
||||
expect(parseDimensionsMm('210×297mm')).toEqual({ widthMm: 210, heightMm: 297 });
|
||||
});
|
||||
|
||||
it('returns null for unparseable input', () => {
|
||||
expect(parseDimensionsMm('8.5in')).toBeNull();
|
||||
expect(parseDimensionsMm(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeOdtText', () => {
|
||||
it('escapes XML-significant characters', () => {
|
||||
expect(escapeOdtText('a<b>&"c"')).toBe('a<b>&"c"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setOdtPageSize', () => {
|
||||
it('rewrites width/height/orientation on the existing page layout (A4 portrait)', async () => {
|
||||
await setOdtPageSize(odtPath, {
|
||||
size: 'a4',
|
||||
orientation: 'portrait',
|
||||
pageSizes: { a4: { dimensions: '210×297mm' } },
|
||||
});
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('fo:page-width="210mm"');
|
||||
expect(xml).toContain('fo:page-height="297mm"');
|
||||
expect(xml).toContain('style:print-orientation="portrait"');
|
||||
// Old inch-based attributes must be gone (no duplicate size attrs)
|
||||
expect(xml).not.toContain('fo:page-width="8.267in"');
|
||||
});
|
||||
|
||||
it('swaps dimensions for landscape', async () => {
|
||||
await setOdtPageSize(odtPath, {
|
||||
size: 'a4',
|
||||
orientation: 'landscape',
|
||||
pageSizes: { a4: { dimensions: '210×297mm' } },
|
||||
});
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('fo:page-width="297mm"');
|
||||
expect(xml).toContain('fo:page-height="210mm"');
|
||||
expect(xml).toContain('style:print-orientation="landscape"');
|
||||
});
|
||||
|
||||
it('falls back to custom mm dimensions and A4 default', async () => {
|
||||
await setOdtPageSize(odtPath, { customWidth: '148', customHeight: '210' });
|
||||
expect(readStylesXml(odtPath)).toContain('fo:page-width="148mm"');
|
||||
|
||||
writeOdtFixture(odtPath);
|
||||
await setOdtPageSize(odtPath, {});
|
||||
expect(readStylesXml(odtPath)).toContain('fo:page-width="210mm"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addHeaderFooterToOdt', () => {
|
||||
it('is a no-op when disabled', async () => {
|
||||
await addHeaderFooterToOdt(odtPath, { enabled: false, header: { left: 'X' } });
|
||||
expect(readStylesXml(odtPath)).not.toContain('<style:header');
|
||||
});
|
||||
|
||||
it('injects header and footer regions into the Standard master page', async () => {
|
||||
await addHeaderFooterToOdt(odtPath, {
|
||||
enabled: true,
|
||||
header: { left: 'My Doc', right: 'Draft <v2>' },
|
||||
footer: { center: 'Page $PAGE$ of $TOTAL$' },
|
||||
});
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('<style:header>');
|
||||
expect(xml).toContain('<style:region-left');
|
||||
expect(xml).toContain('My Doc');
|
||||
// User text must be XML-escaped
|
||||
expect(xml).toContain('Draft <v2>');
|
||||
expect(xml).toContain('<text:page-number>');
|
||||
expect(xml).toContain('<text:page-count>');
|
||||
// The self-closing master page must have been expanded to hold children
|
||||
expect(xml).toMatch(/<style:master-page[^>]*>[\s\S]*<\/style:master-page>/);
|
||||
});
|
||||
|
||||
it('does nothing when all regions are empty', async () => {
|
||||
await addHeaderFooterToOdt(odtPath, {
|
||||
enabled: true,
|
||||
header: { left: '', center: '', right: '' },
|
||||
footer: {},
|
||||
});
|
||||
expect(readStylesXml(odtPath)).toBe(STYLES_XML);
|
||||
});
|
||||
|
||||
it('creates a master page when the package has none', async () => {
|
||||
const bareXml = STYLES_XML.replace(
|
||||
/ <office:master-styles>[\s\S]*<\/office:master-styles>/,
|
||||
''
|
||||
);
|
||||
writeOdtFixture(odtPath, bareXml);
|
||||
|
||||
await addHeaderFooterToOdt(odtPath, {
|
||||
enabled: true,
|
||||
footer: { center: 'hi' },
|
||||
});
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('<office:master-styles>');
|
||||
expect(xml).toContain('style:name="Standard"');
|
||||
expect(xml).toContain('hi');
|
||||
});
|
||||
|
||||
it('replaces existing header/footer content on re-export', async () => {
|
||||
await addHeaderFooterToOdt(odtPath, { enabled: true, header: { left: 'first' } });
|
||||
await addHeaderFooterToOdt(odtPath, { enabled: true, header: { left: 'second' } });
|
||||
|
||||
const xml = readStylesXml(odtPath);
|
||||
expect(xml).toContain('second');
|
||||
expect(xml).not.toContain('first');
|
||||
// Exactly one header element, not nested duplicates
|
||||
expect(xml.match(/<style:header>/g)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const sharp = require('sharp');
|
||||
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
|
||||
const { PDFDocument, StandardFonts, rgb } = require('@cantoo/pdf-lib');
|
||||
const PDFOperations = require('../../src/main/PDFOperations');
|
||||
const {
|
||||
runPDFBatchOperation,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user