mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
feat: v4.6.0 — AI assistant, collaboration, knowledge base, and 15 more features
- AI Assistant plugin: multi-provider chat (OpenAI/Anthropic/Ollama/LM Studio), summarize/improve/translate commands, proofread via ai:analyze; calls proxied through main so API keys stay out of the renderer - Collaboration plugin: anchor-based comments in .comments/ sidecars with drift detection and F8 navigation - Local knowledge base: [[wiki-links]] with click-to-create + Backlinks panel - Crash recovery: debounced session snapshots with restore prompt on launch - Version history: pre-save snapshots, History panel with restore/diff/delete - Real PDF encryption: swap pdf-lib for @cantoo/pdf-lib (probe-driven UI) - XLSX export (native workbooks via JSZip), ODT headers/footers + page size - Offline KaTeX (bundled CSS+fonts), local-first PlantUML rendering - Editor: vim mode toggle, snippet Tab-expansion, zen word-goal setter, writing heatmap, writing-studio panels wired with rail icons - Quick Note global scratchpad (Ctrl+Alt+Q), markdownconverter:// deep links, REPL first-run confirmation - Fix: Ctrl+Shift+P collision, pandoc converter availability check, CLI dangling --css/--reference-doc flags, dead converter button 8 new test suites; 613 tests green; lint clean
This commit is contained in:
@@ -0,0 +1,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,
|
||||
};
|
||||
Reference in New Issue
Block a user