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,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) {
|
||||
|
||||
Reference in New Issue
Block a user