feat: v4.6.0 — AI assistant, collaboration, knowledge base, and 15 more features

- AI Assistant plugin: multi-provider chat (OpenAI/Anthropic/Ollama/LM Studio),
  summarize/improve/translate commands, proofread via ai:analyze; calls
  proxied through main so API keys stay out of the renderer
- Collaboration plugin: anchor-based comments in .comments/ sidecars with
  drift detection and F8 navigation
- Local knowledge base: [[wiki-links]] with click-to-create + Backlinks panel
- Crash recovery: debounced session snapshots with restore prompt on launch
- Version history: pre-save snapshots, History panel with restore/diff/delete
- Real PDF encryption: swap pdf-lib for @cantoo/pdf-lib (probe-driven UI)
- XLSX export (native workbooks via JSZip), ODT headers/footers + page size
- Offline KaTeX (bundled CSS+fonts), local-first PlantUML rendering
- Editor: vim mode toggle, snippet Tab-expansion, zen word-goal setter,
  writing heatmap, writing-studio panels wired with rail icons
- Quick Note global scratchpad (Ctrl+Alt+Q), markdownconverter:// deep links,
  REPL first-run confirmation
- Fix: Ctrl+Shift+P collision, pandoc converter availability check, CLI
  dangling --css/--reference-doc flags, dead converter button

8 new test suites; 613 tests green; lint clean
This commit is contained in:
2026-09-05 20:48:39 +05:30
parent b83ba91731
commit c4dcbd8caf
105 changed files with 5695 additions and 195 deletions
+135
View File
@@ -0,0 +1,135 @@
/**
* Backlink scanning for the local knowledge-base (wiki-link) feature.
*
* "Which documents link to the one I'm editing?" — answered with a bounded
* breadth-first walk of the current folder collecting markdown files, then a
* content scan for `[[docName]]` references. Bounds (depth, file count,
* file size) keep a pathological folder tree from stalling the renderer.
*
* All IO is injected (listDir + readFile), mirroring the adapter style of the
* rest of src/utils, so tests run against fakes.
*
* @module Backlinks
*/
const MAX_DEPTH = 3;
const MAX_FILES = 500;
const MAX_FILE_BYTES = 1024 * 1024; // skip files larger than 1MB in scans
/**
* Collect markdown file paths under rootDir (bounded BFS).
*
* @param {string} rootDir Folder to scan (usually the current document's dir)
* @param {Function} listDir async (dir) => { entries: [{name, isDirectory, path}] } | null
* @param {{maxDepth?: number, maxFiles?: number}} [limits]
* @returns {Promise<string[]>} markdown file paths (not including hidden dirs)
*/
async function collectMarkdownFiles(rootDir, listDir, limits = {}) {
const maxDepth = limits.maxDepth ?? MAX_DEPTH;
const maxFiles = limits.maxFiles ?? MAX_FILES;
const results = [];
const queue = [{ dir: rootDir, depth: 0 }];
while (queue.length > 0 && results.length < maxFiles) {
const { dir, depth } = queue.shift();
if (depth > maxDepth) continue;
let listing;
try {
listing = await listDir(dir);
} catch {
continue; // unreadable folder: skip, don't fail the whole scan
}
if (!listing || !Array.isArray(listing.entries)) continue;
for (const entry of listing.entries) {
if (results.length >= maxFiles) break;
if (entry.isDirectory) {
// Never descend into hidden/vendor folders (.git, node_modules, …)
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
queue.push({ dir: entry.path, depth: depth + 1 });
}
} else if (/\.(md|markdown)$/i.test(entry.name)) {
results.push(entry.path);
}
}
}
return results;
}
/**
* Build the set of wiki targets a document's raw markdown references.
* @returns {string[]} trimmed targets ([[target|alias]] → target)
*/
function extractOutgoingLinks(markdown) {
const regex = /\[\[([^\]#|]+)(?:#[^\]|]*)?(?:\|[^\]]+)?\]\]/g;
const targets = new Set();
let match;
while ((match = regex.exec(String(markdown || ''))) !== null) {
const target = match[1].trim();
if (target) targets.add(target);
}
return [...targets];
}
/**
* Find documents that link to `docName`.
*
* @param {object} args
* @param {string} args.docName Current document name without extension
* @param {string} [args.docPath] Full path of the current doc (excluded from results)
* @param {string[]} args.files Candidate markdown paths
* @param {Function} args.readFile async (path) => string|null
* @returns {Promise<Array<{path:string, line:number, context:string}>>}
*/
async function findBacklinks({ docName, docPath, files, readFile }) {
if (!docName) return [];
const wanted = docName.toLowerCase();
const backlinks = [];
for (const file of files) {
if (docPath && file === docPath) continue;
let content;
try {
content = await readFile(file);
} catch {
continue;
}
if (typeof content !== 'string' || content.length > MAX_FILE_BYTES) continue;
// A backlink mentions [[docName]] or [[docName#heading]] or [[docName.md]]
const regex = new RegExp(
`\\[\\[\\s*${escapeRegex(wanted)}(?:\\.md)?(?:#[^\\]|]*)?\\s*(?:\\|[^\\]]+)?\\]\\]`,
'i'
);
if (!regex.test(content)) continue;
// Collect matching lines (capped) for context display
const lines = content.split('\n');
let hits = 0;
for (let i = 0; i < lines.length && hits < 3; i++) {
if (regex.test(lines[i])) {
backlinks.push({
path: file,
line: i + 1,
context: lines[i].trim().slice(0, 120),
});
hits++;
}
}
}
return backlinks;
}
/** Escape a literal string for embedding in a RegExp. */
function escapeRegex(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
module.exports = {
collectMarkdownFiles,
extractOutgoingLinks,
findBacklinks,
MAX_DEPTH,
MAX_FILES,
MAX_FILE_BYTES,
};
+149
View File
@@ -0,0 +1,149 @@
/**
* Session persistence for crash recovery / session restore.
*
* Keeps a snapshot of the open tabs (file paths, dirty state, and the content
* of unsaved buffers) in localStorage so that after a crash, force-quit, or
* accidental close the user can reopen exactly where they left off. Content is
* only stored for buffers that would otherwise be lost:
*
* - dirty untitled tabs → full content (the only copy in existence)
* - dirty file tabs → full content (differs from what is on disk)
* - clean file tabs → path only (re-read from disk on restore)
* - PDF tabs → path only (reopened in the PDF viewer)
*
* The module is pure logic over an injected Storage-like object so it can be
* unit-tested without jsdom's localStorage quirks.
*
* @module SessionStore
*/
const STORAGE_KEY = 'editor-session';
// localStorage budgets are typically 5-10MB; stay well under with a 2MB cap on
// captured content so a pathological 40-tab session cannot blow the quota and
// take the whole snapshot down with a QuotaExceededError.
const MAX_CAPTURED_CONTENT_CHARS = 2 * 1024 * 1024;
/**
* Build a serializable snapshot from the live tab map.
*
* @param {Map<number, object>} tabs TabManager's tab map
* @param {number} activeTabId Currently selected tab id
* @param {Function} getContentFn (tabId) => string — live editor content
* @returns {{version: number, savedAt: number, activeTabId: number,
* tabs: Array<{id:number,type:string,title:string,filePath:string|null,
* isDirty:boolean,content:(string|undefined)}>}}
*/
function captureSession(tabs, activeTabId, getContentFn) {
const sessionTabs = [];
let budget = MAX_CAPTURED_CONTENT_CHARS;
for (const tab of tabs.values()) {
const entry = {
id: tab.id,
type: tab.type || 'markdown',
title: tab.title || 'Untitled',
filePath: tab.filePath || null,
isDirty: Boolean(tab.isDirty),
};
// Capture unsaved content, newest-budget-first (tabs iterate in id order;
// later tabs are newer). Skip capture once the budget is exhausted rather
// than silently truncating a buffer mid-file.
if (entry.isDirty && budget > 0) {
const content = getContentFn ? getContentFn(tab.id) : tab.content || '';
if (typeof content === 'string' && content.length <= budget) {
entry.content = content;
budget -= content.length;
} else if (typeof content === 'string') {
// This single buffer exceeds the remaining budget — still try to keep
// it if it fits the overall cap (other tabs consumed nothing yet).
entry.content = content.length <= MAX_CAPTURED_CONTENT_CHARS ? content : undefined;
}
}
// Drop empty untitled tabs — they carry nothing worth restoring
if (!entry.filePath && !entry.isDirty && !(entry.content && entry.content.length > 0)) {
continue;
}
if (!entry.filePath && entry.content === undefined) {
continue;
}
sessionTabs.push(entry);
}
return {
version: 1,
savedAt: Date.now(),
activeTabId,
tabs: sessionTabs,
};
}
/**
* Persist a snapshot. Quota failures are swallowed after clearing the key: a
* failed save must never crash the editor, and a stale snapshot is worse than
* none (it would offer to restore outdated buffers).
*
* @param {Storage} storage localStorage-like object
* @param {object} session Snapshot from captureSession()
*/
function saveSession(storage, session) {
try {
storage.setItem(STORAGE_KEY, JSON.stringify(session));
} catch {
try {
storage.removeItem(STORAGE_KEY);
} catch {
/* nothing more to do */
}
}
}
/**
* Load a snapshot. Returns null when nothing is stored, the JSON is corrupt,
* or the shape is unrecognizable — callers treat null as "nothing to restore".
*
* @param {Storage} storage localStorage-like object
* @returns {object|null}
*/
function loadSession(storage) {
try {
const raw = storage.getItem(STORAGE_KEY);
if (!raw) return null;
const session = JSON.parse(raw);
if (
!session ||
session.version !== 1 ||
!Array.isArray(session.tabs) ||
session.tabs.length === 0
) {
return null;
}
return session;
} catch {
return null;
}
}
/**
* Remove the snapshot. Called on clean shutdown so a fresh launch doesn't
* offer to "restore" a session the user deliberately closed.
*
* @param {Storage} storage localStorage-like object
*/
function clearSession(storage) {
try {
storage.removeItem(STORAGE_KEY);
} catch {
/* best-effort */
}
}
module.exports = {
STORAGE_KEY,
captureSession,
saveSession,
loadSession,
clearSession,
};
+93
View File
@@ -0,0 +1,93 @@
/**
* Wiki-link ([[target]] / [[target|alias]] / [[target#heading]]) support.
*
* Markdown has no spec for wiki-links; marked renders the bracket text
* literally, so this module post-processes rendered HTML and turns wiki-link
* text into anchor elements. Code blocks and inline code are skipped so
* documentation *about* wiki-links doesn't become live links.
*
* Pure string processing — no DOM, no IO — so it is directly testable.
*
* @module WikiLinks
*/
/**
* Matches [[target]], [[target|alias]], [[target#section]] and
* [[target#section|alias]]. Group 1 = target, group 2 = optional alias.
* Brackets are not allowed inside (that would be nested links).
*/
const WIKI_LINK_REGEX = /\[\[([^\]#|]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g;
/**
* Replace wiki-link text with anchors in a rendered-HTML string, skipping
* <pre>/<code> segments.
*
* @param {string} html Rendered markdown HTML
* @returns {string} HTML with <a class="wiki-link" data-wiki-target="...">…
*/
function renderWikiLinksInHtml(html) {
if (typeof html !== 'string' || html.indexOf('[[') === -1) return html;
// Split into code/non-code segments so replacements only happen in prose
const segments = html.split(/(<pre\b[\s\S]*?<\/pre>|<code\b[\s\S]*?<\/code>)/i);
return segments
.map((segment, index) => {
// Odd indices are the captured code blocks (never transformed)
if (index % 2 === 1) return segment;
return segment.replace(WIKI_LINK_REGEX, (_match, target, alias) => {
const clean = String(target).trim();
if (!clean) return _match;
const label = alias ? String(alias).trim() : clean;
return `<a href="#" class="wiki-link" data-wiki-target="${escapeAttr(clean)}">${escapeHtml(label)}</a>`;
});
})
.join('');
}
/**
* Resolve a wiki target to a markdown file path next to the current document.
* "Note" → <dir>/Note.md; "Note.md" stays as-is; "./sub/Note" resolves
* relative to dir. Returns null for empty/unsafe targets (absolute paths
* and traversal outside the vault would be surprising at best).
*
* @param {string} target Wiki target (already trimmed)
* @param {string} currentDir Directory of the current document
* @param {Function} pathUtil injected path module
* @returns {string|null}
*/
function resolveTargetPath(target, currentDir, pathUtil) {
if (!target || !currentDir) return null;
const clean = String(target).trim();
if (!clean || clean.startsWith('/') || /^[a-zA-Z]:/.test(clean)) return null;
if (clean.includes('..')) return null;
const withExt = /\.(md|markdown)$/i.test(clean) ? clean : `${clean}.md`;
return pathUtil.join(currentDir, withExt);
}
/** Escape text for HTML content. */
function escapeHtml(text) {
return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Escape text for a double-quoted attribute value. */
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;');
}
/**
* The document-name key used for backlink matching: basename without
* extension, e.g. "/docs/My Note.md" → "My Note".
*/
function docNameFor(docPath, pathUtil) {
if (!docPath) return null;
const base = pathUtil.basename(docPath);
return base.replace(/\.(md|markdown)$/i, '');
}
module.exports = {
WIKI_LINK_REGEX,
renderWikiLinksInHtml,
resolveTargetPath,
docNameFor,
};