chore(release): bump v4.7.1 → v4.8.0; Prettier pass on overnight-session files

12 commits on master since v4.7.1 baseline, taking tests 524 → 914 across
27 new suites. This commit:
- Bumps package.json + README version to v4.8.0 (additive features → semver minor)
- Applies Prettier formatting to all files touched during the overnight
  session so release build is reproducible from a clean repo

New in v4.8.0:
- PDF encryption close-out (D1)
- KaTeX rendering test coverage
- Autosave buffer + crash recovery banner
- Daily notes + workspace search + doc-aware Q&A
- Search sidebar panel + Ask mode
- Status bar: word count, reading time, Flesch-Kincaid grade
- Footnote hover preview
- Smart paste (URL → link, CSV/TSV → table)
- Daily notes menu item + sidebar panel + Q&A deep-link
- Daily templates gallery
- Pluggable DocQA engine (TF-idF default + lazy neural embeddings)

Amit Haridas
This commit is contained in:
2026-09-14 14:56:05 +05:30
parent c3fe72bcae
commit b1b72f9a60
29 changed files with 186 additions and 136 deletions
+1 -1
View File
@@ -225,4 +225,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version ## Version
v4.7.1 v4.8.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "markdown-converter", "name": "markdown-converter",
"version": "4.7.1", "version": "4.8.0",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting", "description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js", "main": "src/main.js",
"scripts": { "scripts": {
+17 -3
View File
@@ -2559,7 +2559,11 @@
</svg> </svg>
</button> </button>
<!-- Search + Q&A: workspace content search / doc-aware Q&A --> <!-- Search + Q&A: workspace content search / doc-aware Q&A -->
<button class="sidebar-icon" data-panel="search" title="Search &amp; Ask (Ctrl+Shift+F)"> <button
class="sidebar-icon"
data-panel="search"
title="Search &amp; Ask (Ctrl+Shift+F)"
>
<svg <svg
width="20" width="20"
height="20" height="20"
@@ -2865,9 +2869,19 @@
<span class="status-separator">|</span> <span class="status-separator">|</span>
<span class="status-item" id="char-count">Chars: 0</span> <span class="status-item" id="char-count">Chars: 0</span>
<span class="status-separator">|</span> <span class="status-separator">|</span>
<span class="status-item" id="reading-time" title="Estimated reading time at 220 words/minute">~0 min read</span> <span
class="status-item"
id="reading-time"
title="Estimated reading time at 220 words/minute"
>~0 min read</span
>
<span class="status-separator">|</span> <span class="status-separator">|</span>
<span class="status-item" id="grade-level" title="Flesch-Kincaid grade level (lower = easier)">Grade —</span> <span
class="status-item"
id="grade-level"
title="Flesch-Kincaid grade level (lower = easier)"
>Grade —</span
>
<span class="status-separator">|</span> <span class="status-separator">|</span>
<span class="status-item" id="line-col">Ln 1, Col 1</span> <span class="status-item" id="line-col">Ln 1, Col 1</span>
<span class="status-separator">|</span> <span class="status-separator">|</span>
+30 -27
View File
@@ -5902,35 +5902,38 @@ ipcMain.handle('workspace-search:query', async (_event, { query, dir, limit = 50
// strips grammar noise (what/how/why/...) and re-ranks at the chunk level // strips grammar noise (what/how/why/...) and re-ranks at the chunk level
// so the renderer can show multiple passages from the same file. No neural // so the renderer can show multiple passages from the same file. No neural
// model — same ranking algorithm — so results stay explainable and offline. // model — same ranking algorithm — so results stay explainable and offline.
ipcMain.handle('doc-qa:ask', async (_event, { question, dir, topK = 5, engine: engineName } = {}) => { ipcMain.handle(
if (typeof question !== 'string' || !question.trim()) { 'doc-qa:ask',
return { question: '', chunks: [] }; async (_event, { question, dir, topK = 5, engine: engineName } = {}) => {
} if (typeof question !== 'string' || !question.trim()) {
const validation = typeof dir === 'string' ? validatePath(dir) : { valid: false }; return { question: '', chunks: [] };
if (!validation.valid) return { question: String(question), chunks: [] };
const files = collectMarkdownFiles(dir, WORKSPACE_SEARCH_MAX_FILES);
const corpus = [];
for (const filePath of files) {
try {
const stat = fs.statSync(filePath);
if (stat.size > WORKSPACE_SEARCH_MAX_BYTES) continue;
corpus.push({
path: filePath,
content: fs.readFileSync(filePath, 'utf-8'),
mtimeMs: stat.mtimeMs,
});
} catch {
/* unreadable — skip */
} }
} const validation = typeof dir === 'string' ? validatePath(dir) : { valid: false };
if (!validation.valid) return { question: String(question), chunks: [] };
// Resolve the engine up-front. If the user asked for neural and the dep const files = collectMarkdownFiles(dir, WORKSPACE_SEARCH_MAX_FILES);
// is missing, SemanticEngine.getEngine() falls back to tf-idf with a const corpus = [];
// single console warning — callers always get a usable engine. for (const filePath of files) {
const engine = await SemanticEngine.getEngine(engineName || 'tf-idf'); try {
return DocQA.ask({ question, files: corpus, topK, engine }); const stat = fs.statSync(filePath);
}); if (stat.size > WORKSPACE_SEARCH_MAX_BYTES) continue;
corpus.push({
path: filePath,
content: fs.readFileSync(filePath, 'utf-8'),
mtimeMs: stat.mtimeMs,
});
} catch {
/* unreadable — skip */
}
}
// Resolve the engine up-front. If the user asked for neural and the dep
// is missing, SemanticEngine.getEngine() falls back to tf-idf with a
// single console warning — callers always get a usable engine.
const engine = await SemanticEngine.getEngine(engineName || 'tf-idf');
return DocQA.ask({ question, files: corpus, topK, engine });
}
);
// ================================ // ================================
// Smart-paste: URL → page title // Smart-paste: URL → page title
+4 -3
View File
@@ -84,9 +84,10 @@ function openOrCreate({ date, dir, templateDir, fs, pathUtil, now = new Date(),
// Use the explicit seedContent when provided (e.g. a non-default // Use the explicit seedContent when provided (e.g. a non-default
// template chosen via the gallery); otherwise fall back to the // template chosen via the gallery); otherwise fall back to the
// template-loader (which honors templateDir + the built-in default). // template-loader (which honors templateDir + the built-in default).
content = typeof seedContent === 'string' && seedContent.length > 0 content =
? seedContent typeof seedContent === 'string' && seedContent.length > 0
: loadTemplate({ date, templateDir, fs, pathUtil, now }); ? seedContent
: loadTemplate({ date, templateDir, fs, pathUtil, now });
fs.writeFileSync(notePath, content, 'utf-8'); fs.writeFileSync(notePath, content, 'utf-8');
created = true; created = true;
} }
+2 -9
View File
@@ -178,13 +178,7 @@ function chunkDocument(content, maxChunkChars = 800) {
* swap in semantic embeddings. * swap in semantic embeddings.
* @returns {{question:string, chunks:Array<{filePath:string, snippet:string, score:number, mtimeMs:number}>}} * @returns {{question:string, chunks:Array<{filePath:string, snippet:string, score:number, mtimeMs:number}>}}
*/ */
async function ask({ async function ask({ question, files, topK = 5, nowMs = Date.now(), engine = null }) {
question,
files,
topK = 5,
nowMs = Date.now(),
engine = null,
}) {
const cleaned = cleanQuestion(question); const cleaned = cleanQuestion(question);
if (!cleaned || !Array.isArray(files) || files.length === 0) { if (!cleaned || !Array.isArray(files) || files.length === 0) {
return { question: String(question || ''), chunks: [] }; return { question: String(question || ''), chunks: [] };
@@ -241,8 +235,7 @@ async function ask({
offset, offset,
snippet: h.snippet, snippet: h.snippet,
score: h.score, score: h.score,
mtimeMs: mtimeMs: chunkCorpus.find((c) => c.path === h.filePath)?.mtimeMs || h.mtimeMs || 0,
chunkCorpus.find((c) => c.path === h.filePath)?.mtimeMs || h.mtimeMs || 0,
}; };
}); });
+8 -2
View File
@@ -98,7 +98,12 @@ function isHttpUrl(value) {
* @param {typeof fetch} [args.fetch] injectable for tests * @param {typeof fetch} [args.fetch] injectable for tests
* @returns {Promise<{url:string, title:string} | null>} * @returns {Promise<{url:string, title:string} | null>}
*/ */
async function fetchTitle({ url, timeoutMs = 5000, maxBytes = 2 * 1024 * 1024, fetch = globalThis.fetch }) { async function fetchTitle({
url,
timeoutMs = 5000,
maxBytes = 2 * 1024 * 1024,
fetch = globalThis.fetch,
}) {
if (!isHttpUrl(url) || typeof fetch !== 'function') return null; if (!isHttpUrl(url) || typeof fetch !== 'function') return null;
const controller = new AbortController(); const controller = new AbortController();
@@ -114,7 +119,8 @@ async function fetchTitle({ url, timeoutMs = 5000, maxBytes = 2 * 1024 * 1024, f
if (!ctype.toLowerCase().includes('text/html')) return null; if (!ctype.toLowerCase().includes('text/html')) return null;
// Read up to maxBytes + 1 so we can detect overflow // Read up to maxBytes + 1 so we can detect overflow
const reader = res.body && typeof res.body.getReader === 'function' ? res.body.getReader() : null; const reader =
res.body && typeof res.body.getReader === 'function' ? res.body.getReader() : null;
let body = ''; let body = '';
if (reader) { if (reader) {
while (true) { while (true) {
+4 -8
View File
@@ -2228,14 +2228,12 @@ document.addEventListener('DOMContentLoaded', async () => {
// the user can override the input. // the user can override the input.
sidebarManager.registerPanel('search', { sidebarManager.registerPanel('search', {
title: 'Search', title: 'Search',
icon: icon: '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 2a8 8 0 1 1-5.3 14L1 19.7 2.3 21l3.7-3.7A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12A6 6 0 0 0 10 4z"/></svg>',
'<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 2a8 8 0 1 1-5.3 14L1 19.7 2.3 21l3.7-3.7A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12A6 6 0 0 0 10 4z"/></svg>',
render: (container) => render: (container) =>
require('./sidebar/search-panel').renderSearchPanel(container, { require('./sidebar/search-panel').renderSearchPanel(container, {
search: ({ query, dir, limit }) => search: ({ query, dir, limit }) =>
ipcRenderer.invoke('workspace-search:query', { query, dir, limit }), ipcRenderer.invoke('workspace-search:query', { query, dir, limit }),
ask: ({ question, dir, topK }) => ask: ({ question, dir, topK }) => ipcRenderer.invoke('doc-qa:ask', { question, dir, topK }),
ipcRenderer.invoke('doc-qa:ask', { question, dir, topK }),
getCurrentDir: () => { getCurrentDir: () => {
// Re-read the explorer's path input each time the user opens the // Re-read the explorer's path input each time the user opens the
// search panel; survives the explorer being remounted. // search panel; survives the explorer being remounted.
@@ -2254,8 +2252,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// file-opened IPC path, which already handles any daily-notes path). // file-opened IPC path, which already handles any daily-notes path).
sidebarManager.registerPanel('daily-notes', { sidebarManager.registerPanel('daily-notes', {
title: 'Daily Notes', title: 'Daily Notes',
icon: icon: '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z"/></svg>',
'<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z"/></svg>',
render: (container) => render: (container) =>
require('./sidebar/daily-notes-panel').renderDailyNotesPanel(container, { require('./sidebar/daily-notes-panel').renderDailyNotesPanel(container, {
openToday: ({ date } = {}) => ipcRenderer.invoke('daily-notes:open-today', { date }), openToday: ({ date } = {}) => ipcRenderer.invoke('daily-notes:open-today', { date }),
@@ -2269,8 +2266,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// note via daily-templates:apply. // note via daily-templates:apply.
sidebarManager.registerPanel('daily-templates', { sidebarManager.registerPanel('daily-templates', {
title: 'Daily Templates', title: 'Daily Templates',
icon: icon: '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 5a2 2 0 0 1 2-2h11l-2 2H5v12h14V9l2-2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5zm5 0h2v2H8V5zm0 4h2v2H8V9zm0 4h2v2H8v-2z"/></svg>',
'<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 5a2 2 0 0 1 2-2h11l-2 2H5v12h14V9l2-2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5zm5 0h2v2H8V5zm0 4h2v2H8V9zm0 4h2v2H8v-2z"/></svg>',
render: (container) => render: (container) =>
require('./sidebar/daily-templates-panel').renderDailyTemplatesPanel(container, { require('./sidebar/daily-templates-panel').renderDailyTemplatesPanel(container, {
listTemplates: () => ipcRenderer.invoke('daily-templates:list'), listTemplates: () => ipcRenderer.invoke('daily-templates:list'),
+2 -1
View File
@@ -43,7 +43,8 @@ function mountFootnotePreview(previewRoot, opts = {}) {
// CSS.escape() is in modern browsers but not in every test env; fallback // CSS.escape() is in modern browsers but not in every test env; fallback
// strips characters that would break the selector without escaping the // strips characters that would break the selector without escaping the
// whole id (footnote ids are integers, so this is safe). // whole id (footnote ids are integers, so this is safe).
const safeId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/[^a-zA-Z0-9_-]/g, ''); const safeId =
typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/[^a-zA-Z0-9_-]/g, '');
const target = previewRoot.querySelector(`#${safeId}`); const target = previewRoot.querySelector(`#${safeId}`);
return target || null; return target || null;
} }
+5 -4
View File
@@ -101,11 +101,12 @@ function renderDailyNotesPanel(container, deps) {
// listExisting returns absolute paths (newest first). Pair each path // listExisting returns absolute paths (newest first). Pair each path
// with its basename for display. // with its basename for display.
const paths = Array.isArray(list) ? list : []; const paths = Array.isArray(list) ? list : [];
entries = paths entries = paths.filter((p) => /\.md$/i.test(p)).map((p) => ({ path: p, name: basename(p) }));
.filter((p) => /\.md$/i.test(p))
.map((p) => ({ path: p, name: basename(p) }));
renderList(); renderList();
if (!keepStatus) setStatus(entries.length === 0 ? '' : `${entries.length} note${entries.length === 1 ? '' : 's'}`); if (!keepStatus)
setStatus(
entries.length === 0 ? '' : `${entries.length} note${entries.length === 1 ? '' : 's'}`
);
} catch (err) { } catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`); setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
renderList(); renderList();
+4 -1
View File
@@ -160,7 +160,10 @@ function renderDailyTemplatesPanel(container, deps) {
const list = await listTemplates(); const list = await listTemplates();
entries = Array.isArray(list) ? list : []; entries = Array.isArray(list) ? list : [];
renderList(); renderList();
if (!keepStatus) setStatus(entries.length === 0 ? '' : `${entries.length} template${entries.length === 1 ? '' : 's'}`); if (!keepStatus)
setStatus(
entries.length === 0 ? '' : `${entries.length} template${entries.length === 1 ? '' : 's'}`
);
} catch (err) { } catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`); setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
entries = []; entries = [];
+6 -3
View File
@@ -92,8 +92,9 @@ function renderSearchPanel(container, { search, ask, getCurrentDir, onOpenFile }
} }
function escapeHtml(s) { function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => return String(s).replace(
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]) /[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
); );
} }
@@ -166,7 +167,9 @@ function renderSearchPanel(container, { search, ask, getCurrentDir, onOpenFile }
if (mode === 'ask') { if (mode === 'ask') {
const r = await ask({ question: q, dir, topK: 5 }); const r = await ask({ question: q, dir, topK: 5 });
lastResults = (r && r.chunks) || []; lastResults = (r && r.chunks) || [];
setStatus(`Asked: "${q}" — ${lastResults.length} passage${lastResults.length === 1 ? '' : 's'}`); setStatus(
`Asked: "${q}" — ${lastResults.length} passage${lastResults.length === 1 ? '' : 's'}`
);
} else { } else {
const r = await search({ query: q, dir, limit: 50 }); const r = await search({ query: q, dir, limit: 50 });
lastResults = Array.isArray(r) ? r : []; lastResults = Array.isArray(r) ? r : [];
+3 -1
View File
@@ -171,7 +171,9 @@ function looksLikeCsv(text) {
// Every non-empty line must have the same number of delimiters as the // Every non-empty line must have the same number of delimiters as the
// first, allowing a ±1 slack for trailing empties. // first, allowing a ±1 slack for trailing empties.
const delim = detectDelimiter(text); const delim = detectDelimiter(text);
const counts = lines.slice(0, 5).map((l) => (l.match(new RegExp(escapeRegex(delim), 'g')) || []).length); const counts = lines
.slice(0, 5)
.map((l) => (l.match(new RegExp(escapeRegex(delim), 'g')) || []).length);
const first = counts[0]; const first = counts[0];
if (first < 1) return false; if (first < 1) return false;
return counts.every((c) => Math.abs(c - first) <= 1); return counts.every((c) => Math.abs(c - first) <= 1);
+5 -6
View File
@@ -38,7 +38,7 @@ function stripMarkdown(content) {
// Links: keep the text, drop the URL part // Links: keep the text, drop the URL part
s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
// Wikilinks: keep the visible label // Wikilinks: keep the visible label
s = s.replace(/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g, (_, a, _b, c) => (c || a)); s = s.replace(/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g, (_, a, _b, c) => c || a);
// Headings at line start // Headings at line start
s = s.replace(/^\s{0,3}#{1,6}\s+/gm, ''); s = s.replace(/^\s{0,3}#{1,6}\s+/gm, '');
// Blockquote markers // Blockquote markers
@@ -62,9 +62,7 @@ function splitSentences(prose) {
if (!prose) return []; if (!prose) return [];
// Split on . ! ? followed by whitespace or end. Keep abbreviations rough. // Split on . ! ? followed by whitespace or end. Keep abbreviations rough.
const parts = prose.split(/(?<=[.!?])\s+/); const parts = prose.split(/(?<=[.!?])\s+/);
return parts return parts.map((s) => s.trim()).filter((s) => /[A-Za-z0-9À-ɏ]/.test(s) && s.length >= 2);
.map((s) => s.trim())
.filter((s) => /[A-Za-z0-9À-ɏ]/.test(s) && s.length >= 2);
} }
/** /**
@@ -85,7 +83,7 @@ function countSyllablesInWord(word) {
} }
function countSyllables(prose) { function countSyllables(prose) {
const words = (prose.match(/\b[A-Za-z'À-ɏ]+\b/g) || []); const words = prose.match(/\b[A-Za-z'À-ɏ]+\b/g) || [];
let total = 0; let total = 0;
for (const w of words) total += countSyllablesInWord(w); for (const w of words) total += countSyllablesInWord(w);
return total; return total;
@@ -117,7 +115,8 @@ function computeStats(content, opts = {}) {
const words = countWords(prose); const words = countWords(prose);
const sentences = splitSentences(prose).length; const sentences = splitSentences(prose).length;
const syllables = countSyllables(prose); const syllables = countSyllables(prose);
const wpm = typeof opts.wordsPerMinute === 'number' && opts.wordsPerMinute > 0 ? opts.wordsPerMinute : 220; const wpm =
typeof opts.wordsPerMinute === 'number' && opts.wordsPerMinute > 0 ? opts.wordsPerMinute : 220;
const readingTimeMinutes = words > 0 ? words / wpm : 0; const readingTimeMinutes = words > 0 ? words / wpm : 0;
let grade = null; let grade = null;
+11 -3
View File
@@ -3,7 +3,13 @@
* *
* csv-to-table tests — pure module. * csv-to-table tests — pure module.
*/ */
const { detectDelimiter, csvToTable, looksLikeCsv, parseRows, escapeCell } = require('../src/utils/csv-to-table'); const {
detectDelimiter,
csvToTable,
looksLikeCsv,
parseRows,
escapeCell,
} = require('../src/utils/csv-to-table');
describe('detectDelimiter', () => { describe('detectDelimiter', () => {
test('prefers tabs over commas when both are present', () => { test('prefers tabs over commas when both are present', () => {
@@ -155,7 +161,7 @@ describe('looksLikeCsv', () => {
expect(looksLikeCsv('a,b,c')).toBe(false); expect(looksLikeCsv('a,b,c')).toBe(false);
}); });
test('returns false when the column counts don\'t line up at all', () => { test("returns false when the column counts don't line up at all", () => {
expect(looksLikeCsv('a,b,c\n1\n2,3,4,5')).toBe(false); expect(looksLikeCsv('a,b,c\n1\n2,3,4,5')).toBe(false);
}); });
@@ -166,6 +172,8 @@ describe('looksLikeCsv', () => {
}); });
test('returns false for prose (no delimiter)', () => { test('returns false for prose (no delimiter)', () => {
expect(looksLikeCsv('The quick brown fox jumps over the lazy dog.\nThe cat sat on the mat.')).toBe(false); expect(
looksLikeCsv('The quick brown fox jumps over the lazy dog.\nThe cat sat on the mat.')
).toBe(false);
}); });
}); });
+18 -12
View File
@@ -9,7 +9,9 @@ function mountPanel(deps = {}) {
const container = document.createElement('div'); const container = document.createElement('div');
document.body.appendChild(container); document.body.appendChild(container);
const api = renderDailyNotesPanel(container, { const api = renderDailyNotesPanel(container, {
openToday: deps.openToday || jest.fn().mockResolvedValue({ path: '/notes/2026-09-14.md', created: true }), openToday:
deps.openToday ||
jest.fn().mockResolvedValue({ path: '/notes/2026-09-14.md', created: true }),
listExisting: deps.listExisting || jest.fn().mockResolvedValue([]), listExisting: deps.listExisting || jest.fn().mockResolvedValue([]),
onOpenFile: deps.onOpenFile || jest.fn(), onOpenFile: deps.onOpenFile || jest.fn(),
...deps, ...deps,
@@ -39,11 +41,13 @@ describe('renderDailyNotesPanel — mounting', () => {
test('renders one row per filename', async () => { test('renders one row per filename', async () => {
const { container } = mountPanel({ const { container } = mountPanel({
listExisting: jest.fn().mockResolvedValue([ listExisting: jest
'/notes/daily/2026-09-14.md', .fn()
'/notes/daily/2026-09-13.md', .mockResolvedValue([
'/notes/daily/2026-09-10.md', '/notes/daily/2026-09-14.md',
]), '/notes/daily/2026-09-13.md',
'/notes/daily/2026-09-10.md',
]),
}); });
await flush(); await flush();
const items = container.querySelectorAll('.daily-notes-item'); const items = container.querySelectorAll('.daily-notes-item');
@@ -107,14 +111,13 @@ describe('renderDailyNotesPanel — Today button', () => {
}); });
describe('renderDailyNotesPanel — click an existing entry', () => { describe('renderDailyNotesPanel — click an existing entry', () => {
test('clicking a row calls onOpenFile with that row\'s path', async () => { test("clicking a row calls onOpenFile with that row's path", async () => {
const onOpenFile = jest.fn(); const onOpenFile = jest.fn();
const { container } = mountPanel({ const { container } = mountPanel({
onOpenFile, onOpenFile,
listExisting: jest.fn().mockResolvedValue([ listExisting: jest
'/notes/daily/2026-09-14.md', .fn()
'/notes/daily/2026-09-10.md', .mockResolvedValue(['/notes/daily/2026-09-14.md', '/notes/daily/2026-09-10.md']),
]),
}); });
await flush(); await flush();
const items = container.querySelectorAll('.daily-notes-item'); const items = container.querySelectorAll('.daily-notes-item');
@@ -139,7 +142,10 @@ describe('renderDailyNotesPanel — click an existing entry', () => {
describe('renderDailyNotesPanel — refresh', () => { describe('renderDailyNotesPanel — refresh', () => {
test('refresh button re-fetches the list', async () => { test('refresh button re-fetches the list', async () => {
const listExisting = jest.fn().mockResolvedValueOnce([]).mockResolvedValueOnce(['2026-09-14.md']); const listExisting = jest
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce(['2026-09-14.md']);
const { container, api } = mountPanel({ listExisting }); const { container, api } = mountPanel({ listExisting });
await flush(); await flush();
expect(container.querySelectorAll('.daily-notes-item')).toHaveLength(0); expect(container.querySelectorAll('.daily-notes-item')).toHaveLength(0);
+24 -15
View File
@@ -10,9 +10,12 @@ function mountPanel(deps = {}) {
document.body.appendChild(container); document.body.appendChild(container);
const api = renderDailyTemplatesPanel(container, { const api = renderDailyTemplatesPanel(container, {
listTemplates: deps.listTemplates || jest.fn().mockResolvedValue([]), listTemplates: deps.listTemplates || jest.fn().mockResolvedValue([]),
saveTemplate: deps.saveTemplate || jest.fn().mockResolvedValue({ name: 'x.md', label: 'X', content: '' }), saveTemplate:
deps.saveTemplate || jest.fn().mockResolvedValue({ name: 'x.md', label: 'X', content: '' }),
deleteTemplate: deps.deleteTemplate || jest.fn().mockResolvedValue(true), deleteTemplate: deps.deleteTemplate || jest.fn().mockResolvedValue(true),
applyTemplate: deps.applyTemplate || jest.fn().mockResolvedValue({ path: '/d/2026-09-14.md', content: '', created: true }), applyTemplate:
deps.applyTemplate ||
jest.fn().mockResolvedValue({ path: '/d/2026-09-14.md', content: '', created: true }),
onOpenFile: deps.onOpenFile || jest.fn(), onOpenFile: deps.onOpenFile || jest.fn(),
...deps, ...deps,
}); });
@@ -53,9 +56,9 @@ describe('renderDailyTemplatesPanel — mounting', () => {
test('escapeHtml-style safety: a label with <script> does not produce a node', async () => { test('escapeHtml-style safety: a label with <script> does not produce a node', async () => {
const { container } = mountPanel({ const { container } = mountPanel({
listTemplates: jest.fn().mockResolvedValue([ listTemplates: jest
{ name: 'evil.md', label: '<script>alert(1)</script>', content: '' }, .fn()
]), .mockResolvedValue([{ name: 'evil.md', label: '<script>alert(1)</script>', content: '' }]),
}); });
await flush(); await flush();
expect(container.querySelector('script')).toBeNull(); expect(container.querySelector('script')).toBeNull();
@@ -72,7 +75,9 @@ describe('renderDailyTemplatesPanel — Use button', () => {
const { container } = mountPanel({ const { container } = mountPanel({
applyTemplate, applyTemplate,
onOpenFile, onOpenFile,
listTemplates: jest.fn().mockResolvedValue([{ name: 'morning.md', label: 'Morning', content: '# M' }]), listTemplates: jest
.fn()
.mockResolvedValue([{ name: 'morning.md', label: 'Morning', content: '# M' }]),
}); });
await flush(); await flush();
container.querySelector('[data-action="apply"]').click(); container.querySelector('[data-action="apply"]').click();
@@ -83,7 +88,9 @@ describe('renderDailyTemplatesPanel — Use button', () => {
test('reports an error when applyTemplate rejects', async () => { test('reports an error when applyTemplate rejects', async () => {
const applyTemplate = jest.fn().mockRejectedValue(new Error('write fail')); const applyTemplate = jest.fn().mockRejectedValue(new Error('write fail'));
const listTemplates = jest.fn().mockResolvedValue([{ name: 'morning.md', label: 'Morning', content: '' }]); const listTemplates = jest
.fn()
.mockResolvedValue([{ name: 'morning.md', label: 'Morning', content: '' }]);
const { container } = mountPanel({ applyTemplate, listTemplates }); const { container } = mountPanel({ applyTemplate, listTemplates });
await flush(); await flush();
container.querySelector('[data-action="apply"]').click(); container.querySelector('[data-action="apply"]').click();
@@ -128,11 +135,10 @@ describe('renderDailyTemplatesPanel — Delete button', () => {
describe('renderDailyTemplatesPanel — New template', () => { describe('renderDailyTemplatesPanel — New template', () => {
test('clicking + New prompts for name + content, then saves', async () => { test('clicking + New prompts for name + content, then saves', async () => {
const saveTemplate = jest.fn().mockResolvedValue({ name: 'custom.md', label: 'Custom', content: '# X' }); const saveTemplate = jest
window.prompt = jest
.fn() .fn()
.mockReturnValueOnce('custom') .mockResolvedValue({ name: 'custom.md', label: 'Custom', content: '# X' });
.mockReturnValueOnce('# X'); window.prompt = jest.fn().mockReturnValueOnce('custom').mockReturnValueOnce('# X');
const { container } = mountPanel({ saveTemplate }); const { container } = mountPanel({ saveTemplate });
await flush(); await flush();
@@ -158,9 +164,10 @@ describe('renderDailyTemplatesPanel — New template', () => {
describe('renderDailyTemplatesPanel — refresh', () => { describe('renderDailyTemplatesPanel — refresh', () => {
test('refresh button re-fetches the list', async () => { test('refresh button re-fetches the list', async () => {
const listTemplates = jest.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([ const listTemplates = jest
{ name: 'a.md', label: 'A', content: '' }, .fn()
]); .mockResolvedValueOnce([])
.mockResolvedValueOnce([{ name: 'a.md', label: 'A', content: '' }]);
const { container, api } = mountPanel({ listTemplates }); const { container, api } = mountPanel({ listTemplates });
await flush(); await flush();
expect(container.querySelectorAll('.daily-templates-item')).toHaveLength(0); expect(container.querySelectorAll('.daily-templates-item')).toHaveLength(0);
@@ -172,7 +179,9 @@ describe('renderDailyTemplatesPanel — refresh', () => {
}); });
test('shows an error when listTemplates rejects', async () => { test('shows an error when listTemplates rejects', async () => {
const { container } = mountPanel({ listTemplates: jest.fn().mockRejectedValue(new Error('nope')) }); const { container } = mountPanel({
listTemplates: jest.fn().mockRejectedValue(new Error('nope')),
});
await flush(); await flush();
expect(container.querySelector('#daily-templates-status').textContent).toMatch(/nope/); expect(container.querySelector('#daily-templates-status').textContent).toMatch(/nope/);
}); });
+6 -2
View File
@@ -151,7 +151,9 @@ describe('mountFootnotePreview', () => {
document.body.appendChild(root); document.body.appendChild(root);
const unmount = mountFootnotePreview(root, { delayMs: 0 }); const unmount = mountFootnotePreview(root, { delayMs: 0 });
root.querySelector('a[data-footnote-ref]').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); root
.querySelector('a[data-footnote-ref]')
.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
jest.advanceTimersByTime(0); jest.advanceTimersByTime(0);
expect(document.querySelector('.footnote-preview')).not.toBeNull(); expect(document.querySelector('.footnote-preview')).not.toBeNull();
@@ -160,7 +162,9 @@ describe('mountFootnotePreview', () => {
expect(document.querySelector('.footnote-preview')).toBeNull(); expect(document.querySelector('.footnote-preview')).toBeNull();
// Re-hovering after unmount is a no-op (no popover recreated) // Re-hovering after unmount is a no-op (no popover recreated)
root.querySelector('a[data-footnote-ref]').dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); root
.querySelector('a[data-footnote-ref]')
.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
jest.advanceTimersByTime(0); jest.advanceTimersByTime(0);
expect(document.querySelector('.footnote-preview')).toBeNull(); expect(document.querySelector('.footnote-preview')).toBeNull();
}); });
+1 -3
View File
@@ -144,9 +144,7 @@ describe('DocQA.ask with a custom engine', () => {
test('passes the question + chunks to a custom engine.rank()', async () => { test('passes the question + chunks to a custom engine.rank()', async () => {
const customEngine = { const customEngine = {
isNeural: true, isNeural: true,
rank: jest.fn().mockResolvedValue([ rank: jest.fn().mockResolvedValue([{ filePath: '/x.md#0', snippet: 'ranked', score: 0.9 }]),
{ filePath: '/x.md#0', snippet: 'ranked', score: 0.9 },
]),
}; };
const r = await DocQA.ask({ question: 'rust', files, engine: customEngine }); const r = await DocQA.ask({ question: 'rust', files, engine: customEngine });
expect(customEngine.rank).toHaveBeenCalled(); expect(customEngine.rank).toHaveBeenCalled();
+1 -2
View File
@@ -54,8 +54,7 @@ describe('PDFOperations - Task 15 new operations', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.text).toContain('Hello Task 15 Page One'); expect(result.text).toContain('Hello Task 15 Page One');
expect(result.text).toContain('Second Page Content'); expect(result.text).toContain('Second Page Content');
}, // CI runners (observed on windows-latest) // First pdfjs-dist legacy import can exceed the 5s default on slower }, 30000); // CI runners (observed on windows-latest) // First pdfjs-dist legacy import can exceed the 5s default on slower
30000);
it('returns failure for a nonexistent file', async () => { it('returns failure for a nonexistent file', async () => {
const result = await PDFOperations.pdfExtractText({ const result = await PDFOperations.pdfExtractText({
+6 -2
View File
@@ -57,7 +57,9 @@ describe('UrlTitle.decodeTitle', () => {
describe('UrlTitle.extractTitleFromHtml', () => { describe('UrlTitle.extractTitleFromHtml', () => {
test('extracts the first <title> in the body', () => { test('extracts the first <title> in the body', () => {
expect( expect(
UrlTitle.extractTitleFromHtml('<html><head><title>Hello World</title></head><body></body></html>') UrlTitle.extractTitleFromHtml(
'<html><head><title>Hello World</title></head><body></body></html>'
)
).toBe('Hello World'); ).toBe('Hello World');
}); });
@@ -174,7 +176,9 @@ describe('UrlTitle.fetchTitle — failure paths', () => {
headers: { get: () => 'text/html' }, headers: { get: () => 'text/html' },
text: async () => '<title>t</title>' + 'x'.repeat(200), text: async () => '<title>t</title>' + 'x'.repeat(200),
}); });
expect(await UrlTitle.fetchTitle({ url: 'https://x.com', fetch: fakeFetch, maxBytes: 100 })).toBeNull(); expect(
await UrlTitle.fetchTitle({ url: 'https://x.com', fetch: fakeFetch, maxBytes: 100 })
).toBeNull();
}); });
test('respects a streaming body that exceeds maxBytes (cancels the reader)', async () => { test('respects a streaming body that exceeds maxBytes (cancels the reader)', async () => {
+4 -6
View File
@@ -108,9 +108,9 @@ describe('renderSearchPanel — search mode', () => {
test('clicking a result calls onOpenFile with the right path', async () => { test('clicking a result calls onOpenFile with the right path', async () => {
const onOpenFile = jest.fn(); const onOpenFile = jest.fn();
const search = jest.fn().mockResolvedValue([ const search = jest
{ filePath: '/notes/x.md', snippet: 's', score: 1, matchedTerms: [] }, .fn()
]); .mockResolvedValue([{ filePath: '/notes/x.md', snippet: 's', score: 1, matchedTerms: [] }]);
const { container } = mountPanel({ search, onOpenFile }); const { container } = mountPanel({ search, onOpenFile });
container.querySelector('#search-input').value = 'x'; container.querySelector('#search-input').value = 'x';
container.querySelector('#search-run').click(); container.querySelector('#search-run').click();
@@ -246,9 +246,7 @@ describe('renderSearchPanel — escaping', () => {
describe('renderSearchPanel — clear/escape behaviors', () => { describe('renderSearchPanel — clear/escape behaviors', () => {
test('Escape on the input clears the query and results', async () => { test('Escape on the input clears the query and results', async () => {
const search = jest.fn().mockResolvedValue([ const search = jest.fn().mockResolvedValue([{ filePath: '/a.md', snippet: 's', score: 1 }]);
{ filePath: '/a.md', snippet: 's', score: 1 },
]);
const { container, container: c2 } = mountPanel({ search }); const { container, container: c2 } = mountPanel({ search });
const input = container.querySelector('#search-input'); const input = container.querySelector('#search-input');
input.value = 'x'; input.value = 'x';
+3 -1
View File
@@ -30,7 +30,9 @@ describe('stripMarkdown', () => {
test('keeps link text but drops URLs', () => { test('keeps link text but drops URLs', () => {
expect(stripMarkdown('read [the docs](https://docs.example.com) now')).toMatch(/the docs/); expect(stripMarkdown('read [the docs](https://docs.example.com) now')).toMatch(/the docs/);
expect(stripMarkdown('read [the docs](https://docs.example.com) now')).not.toMatch(/docs\.example/); expect(stripMarkdown('read [the docs](https://docs.example.com) now')).not.toMatch(
/docs\.example/
);
}); });
test('keeps wikilink visible labels', () => { test('keeps wikilink visible labels', () => {