mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
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:
@@ -225,4 +225,4 @@ Amit Haridas (amit.wh@gmail.com)
|
||||
|
||||
## Version
|
||||
|
||||
v4.7.1
|
||||
v4.8.0
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"main": "src/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -80,4 +80,4 @@ function smartPaste(deps) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { smartPaste, URL_ONLY_RE };
|
||||
module.exports = { smartPaste, URL_ONLY_RE };
|
||||
|
||||
+17
-3
@@ -2559,7 +2559,11 @@
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Search + Q&A: workspace content search / doc-aware Q&A -->
|
||||
<button class="sidebar-icon" data-panel="search" title="Search & Ask (Ctrl+Shift+F)">
|
||||
<button
|
||||
class="sidebar-icon"
|
||||
data-panel="search"
|
||||
title="Search & Ask (Ctrl+Shift+F)"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
@@ -2865,9 +2869,19 @@
|
||||
<span class="status-separator">|</span>
|
||||
<span class="status-item" id="char-count">Chars: 0</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-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-item" id="line-col">Ln 1, Col 1</span>
|
||||
<span class="status-separator">|</span>
|
||||
|
||||
+30
-27
@@ -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
|
||||
// so the renderer can show multiple passages from the same file. No neural
|
||||
// model — same ranking algorithm — so results stay explainable and offline.
|
||||
ipcMain.handle('doc-qa:ask', async (_event, { question, dir, topK = 5, engine: engineName } = {}) => {
|
||||
if (typeof question !== 'string' || !question.trim()) {
|
||||
return { question: '', chunks: [] };
|
||||
}
|
||||
const validation = typeof dir === 'string' ? validatePath(dir) : { valid: false };
|
||||
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 */
|
||||
ipcMain.handle(
|
||||
'doc-qa:ask',
|
||||
async (_event, { question, dir, topK = 5, engine: engineName } = {}) => {
|
||||
if (typeof question !== 'string' || !question.trim()) {
|
||||
return { question: '', chunks: [] };
|
||||
}
|
||||
}
|
||||
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
|
||||
// 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 });
|
||||
});
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
// template chosen via the gallery); otherwise fall back to the
|
||||
// template-loader (which honors templateDir + the built-in default).
|
||||
content = typeof seedContent === 'string' && seedContent.length > 0
|
||||
? seedContent
|
||||
: loadTemplate({ date, templateDir, fs, pathUtil, now });
|
||||
content =
|
||||
typeof seedContent === 'string' && seedContent.length > 0
|
||||
? seedContent
|
||||
: loadTemplate({ date, templateDir, fs, pathUtil, now });
|
||||
fs.writeFileSync(notePath, content, 'utf-8');
|
||||
created = true;
|
||||
}
|
||||
|
||||
@@ -80,4 +80,4 @@ module.exports = {
|
||||
listTemplates,
|
||||
saveTemplate,
|
||||
deleteTemplate,
|
||||
};
|
||||
};
|
||||
|
||||
+2
-9
@@ -178,13 +178,7 @@ function chunkDocument(content, maxChunkChars = 800) {
|
||||
* swap in semantic embeddings.
|
||||
* @returns {{question:string, chunks:Array<{filePath:string, snippet:string, score:number, mtimeMs:number}>}}
|
||||
*/
|
||||
async function ask({
|
||||
question,
|
||||
files,
|
||||
topK = 5,
|
||||
nowMs = Date.now(),
|
||||
engine = null,
|
||||
}) {
|
||||
async function ask({ question, files, topK = 5, nowMs = Date.now(), engine = null }) {
|
||||
const cleaned = cleanQuestion(question);
|
||||
if (!cleaned || !Array.isArray(files) || files.length === 0) {
|
||||
return { question: String(question || ''), chunks: [] };
|
||||
@@ -241,8 +235,7 @@ async function ask({
|
||||
offset,
|
||||
snippet: h.snippet,
|
||||
score: h.score,
|
||||
mtimeMs:
|
||||
chunkCorpus.find((c) => c.path === h.filePath)?.mtimeMs || h.mtimeMs || 0,
|
||||
mtimeMs: chunkCorpus.find((c) => c.path === h.filePath)?.mtimeMs || h.mtimeMs || 0,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -165,4 +165,4 @@ module.exports = {
|
||||
defaultEngine,
|
||||
neuralEngine,
|
||||
getEngine,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -98,7 +98,12 @@ function isHttpUrl(value) {
|
||||
* @param {typeof fetch} [args.fetch] injectable for tests
|
||||
* @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;
|
||||
|
||||
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;
|
||||
|
||||
// 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 = '';
|
||||
if (reader) {
|
||||
while (true) {
|
||||
@@ -152,4 +158,4 @@ module.exports = {
|
||||
decodeTitle,
|
||||
isHttpUrl,
|
||||
fetchTitle,
|
||||
};
|
||||
};
|
||||
|
||||
+4
-8
@@ -2228,14 +2228,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// the user can override the input.
|
||||
sidebarManager.registerPanel('search', {
|
||||
title: 'Search',
|
||||
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>',
|
||||
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>',
|
||||
render: (container) =>
|
||||
require('./sidebar/search-panel').renderSearchPanel(container, {
|
||||
search: ({ query, dir, limit }) =>
|
||||
ipcRenderer.invoke('workspace-search:query', { query, dir, limit }),
|
||||
ask: ({ question, dir, topK }) =>
|
||||
ipcRenderer.invoke('doc-qa:ask', { question, dir, topK }),
|
||||
ask: ({ question, dir, topK }) => ipcRenderer.invoke('doc-qa:ask', { question, dir, topK }),
|
||||
getCurrentDir: () => {
|
||||
// Re-read the explorer's path input each time the user opens the
|
||||
// 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).
|
||||
sidebarManager.registerPanel('daily-notes', {
|
||||
title: 'Daily Notes',
|
||||
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>',
|
||||
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>',
|
||||
render: (container) =>
|
||||
require('./sidebar/daily-notes-panel').renderDailyNotesPanel(container, {
|
||||
openToday: ({ date } = {}) => ipcRenderer.invoke('daily-notes:open-today', { date }),
|
||||
@@ -2269,8 +2266,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// note via daily-templates:apply.
|
||||
sidebarManager.registerPanel('daily-templates', {
|
||||
title: 'Daily Templates',
|
||||
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>',
|
||||
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>',
|
||||
render: (container) =>
|
||||
require('./sidebar/daily-templates-panel').renderDailyTemplatesPanel(container, {
|
||||
listTemplates: () => ipcRenderer.invoke('daily-templates:list'),
|
||||
|
||||
@@ -43,7 +43,8 @@ function mountFootnotePreview(previewRoot, opts = {}) {
|
||||
// CSS.escape() is in modern browsers but not in every test env; fallback
|
||||
// strips characters that would break the selector without escaping the
|
||||
// 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}`);
|
||||
return target || null;
|
||||
}
|
||||
@@ -121,4 +122,4 @@ function mountFootnotePreview(previewRoot, opts = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { mountFootnotePreview };
|
||||
module.exports = { mountFootnotePreview };
|
||||
|
||||
@@ -101,11 +101,12 @@ function renderDailyNotesPanel(container, deps) {
|
||||
// listExisting returns absolute paths (newest first). Pair each path
|
||||
// with its basename for display.
|
||||
const paths = Array.isArray(list) ? list : [];
|
||||
entries = paths
|
||||
.filter((p) => /\.md$/i.test(p))
|
||||
.map((p) => ({ path: p, name: basename(p) }));
|
||||
entries = paths.filter((p) => /\.md$/i.test(p)).map((p) => ({ path: p, name: basename(p) }));
|
||||
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) {
|
||||
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
|
||||
renderList();
|
||||
@@ -138,4 +139,4 @@ function renderDailyNotesPanel(container, deps) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { renderDailyNotesPanel };
|
||||
module.exports = { renderDailyNotesPanel };
|
||||
|
||||
@@ -160,7 +160,10 @@ function renderDailyTemplatesPanel(container, deps) {
|
||||
const list = await listTemplates();
|
||||
entries = Array.isArray(list) ? list : [];
|
||||
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) {
|
||||
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
|
||||
entries = [];
|
||||
@@ -172,4 +175,4 @@ function renderDailyTemplatesPanel(container, deps) {
|
||||
return { refresh };
|
||||
}
|
||||
|
||||
module.exports = { renderDailyTemplatesPanel };
|
||||
module.exports = { renderDailyTemplatesPanel };
|
||||
|
||||
@@ -92,8 +92,9 @@ function renderSearchPanel(container, { search, ask, getCurrentDir, onOpenFile }
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])
|
||||
return String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,7 +167,9 @@ function renderSearchPanel(container, { search, ask, getCurrentDir, onOpenFile }
|
||||
if (mode === 'ask') {
|
||||
const r = await ask({ question: q, dir, topK: 5 });
|
||||
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 {
|
||||
const r = await search({ query: q, dir, limit: 50 });
|
||||
lastResults = Array.isArray(r) ? r : [];
|
||||
@@ -220,4 +223,4 @@ function renderSearchPanel(container, { search, ask, getCurrentDir, onOpenFile }
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { renderSearchPanel };
|
||||
module.exports = { renderSearchPanel };
|
||||
|
||||
@@ -171,7 +171,9 @@ function looksLikeCsv(text) {
|
||||
// Every non-empty line must have the same number of delimiters as the
|
||||
// first, allowing a ±1 slack for trailing empties.
|
||||
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];
|
||||
if (first < 1) return false;
|
||||
return counts.every((c) => Math.abs(c - first) <= 1);
|
||||
@@ -187,4 +189,4 @@ module.exports = {
|
||||
escapeCell,
|
||||
csvToTable,
|
||||
looksLikeCsv,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ function stripMarkdown(content) {
|
||||
// Links: keep the text, drop the URL part
|
||||
s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
|
||||
// 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
|
||||
s = s.replace(/^\s{0,3}#{1,6}\s+/gm, '');
|
||||
// Blockquote markers
|
||||
@@ -62,9 +62,7 @@ function splitSentences(prose) {
|
||||
if (!prose) return [];
|
||||
// Split on . ! ? followed by whitespace or end. Keep abbreviations rough.
|
||||
const parts = prose.split(/(?<=[.!?])\s+/);
|
||||
return parts
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => /[A-Za-z0-9À-ɏ]/.test(s) && s.length >= 2);
|
||||
return parts.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) {
|
||||
const words = (prose.match(/\b[A-Za-z'À-ɏ]+\b/g) || []);
|
||||
const words = prose.match(/\b[A-Za-z'À-ɏ]+\b/g) || [];
|
||||
let total = 0;
|
||||
for (const w of words) total += countSyllablesInWord(w);
|
||||
return total;
|
||||
@@ -117,7 +115,8 @@ function computeStats(content, opts = {}) {
|
||||
const words = countWords(prose);
|
||||
const sentences = splitSentences(prose).length;
|
||||
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;
|
||||
|
||||
let grade = null;
|
||||
@@ -144,4 +143,4 @@ module.exports = {
|
||||
countSyllables,
|
||||
countWords,
|
||||
computeStats,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
*
|
||||
* 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', () => {
|
||||
test('prefers tabs over commas when both are present', () => {
|
||||
@@ -155,7 +161,7 @@ describe('looksLikeCsv', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -166,6 +172,8 @@ describe('looksLikeCsv', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,9 @@ function mountPanel(deps = {}) {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(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([]),
|
||||
onOpenFile: deps.onOpenFile || jest.fn(),
|
||||
...deps,
|
||||
@@ -39,11 +41,13 @@ describe('renderDailyNotesPanel — mounting', () => {
|
||||
|
||||
test('renders one row per filename', async () => {
|
||||
const { container } = mountPanel({
|
||||
listExisting: jest.fn().mockResolvedValue([
|
||||
'/notes/daily/2026-09-14.md',
|
||||
'/notes/daily/2026-09-13.md',
|
||||
'/notes/daily/2026-09-10.md',
|
||||
]),
|
||||
listExisting: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
'/notes/daily/2026-09-14.md',
|
||||
'/notes/daily/2026-09-13.md',
|
||||
'/notes/daily/2026-09-10.md',
|
||||
]),
|
||||
});
|
||||
await flush();
|
||||
const items = container.querySelectorAll('.daily-notes-item');
|
||||
@@ -107,14 +111,13 @@ describe('renderDailyNotesPanel — Today button', () => {
|
||||
});
|
||||
|
||||
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 { container } = mountPanel({
|
||||
onOpenFile,
|
||||
listExisting: jest.fn().mockResolvedValue([
|
||||
'/notes/daily/2026-09-14.md',
|
||||
'/notes/daily/2026-09-10.md',
|
||||
]),
|
||||
listExisting: jest
|
||||
.fn()
|
||||
.mockResolvedValue(['/notes/daily/2026-09-14.md', '/notes/daily/2026-09-10.md']),
|
||||
});
|
||||
await flush();
|
||||
const items = container.querySelectorAll('.daily-notes-item');
|
||||
@@ -139,7 +142,10 @@ describe('renderDailyNotesPanel — click an existing entry', () => {
|
||||
|
||||
describe('renderDailyNotesPanel — refresh', () => {
|
||||
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 });
|
||||
await flush();
|
||||
expect(container.querySelectorAll('.daily-notes-item')).toHaveLength(0);
|
||||
@@ -172,4 +178,4 @@ describe('renderDailyNotesPanel — graceful degradation', () => {
|
||||
await flush();
|
||||
expect(container.querySelectorAll('.daily-notes-item')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,12 @@ function mountPanel(deps = {}) {
|
||||
document.body.appendChild(container);
|
||||
const api = renderDailyTemplatesPanel(container, {
|
||||
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),
|
||||
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(),
|
||||
...deps,
|
||||
});
|
||||
@@ -53,9 +56,9 @@ describe('renderDailyTemplatesPanel — mounting', () => {
|
||||
|
||||
test('escapeHtml-style safety: a label with <script> does not produce a node', async () => {
|
||||
const { container } = mountPanel({
|
||||
listTemplates: jest.fn().mockResolvedValue([
|
||||
{ name: 'evil.md', label: '<script>alert(1)</script>', content: '' },
|
||||
]),
|
||||
listTemplates: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ name: 'evil.md', label: '<script>alert(1)</script>', content: '' }]),
|
||||
});
|
||||
await flush();
|
||||
expect(container.querySelector('script')).toBeNull();
|
||||
@@ -72,7 +75,9 @@ describe('renderDailyTemplatesPanel — Use button', () => {
|
||||
const { container } = mountPanel({
|
||||
applyTemplate,
|
||||
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();
|
||||
container.querySelector('[data-action="apply"]').click();
|
||||
@@ -83,7 +88,9 @@ describe('renderDailyTemplatesPanel — Use button', () => {
|
||||
|
||||
test('reports an error when applyTemplate rejects', async () => {
|
||||
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 });
|
||||
await flush();
|
||||
container.querySelector('[data-action="apply"]').click();
|
||||
@@ -128,11 +135,10 @@ describe('renderDailyTemplatesPanel — Delete button', () => {
|
||||
|
||||
describe('renderDailyTemplatesPanel — New template', () => {
|
||||
test('clicking + New prompts for name + content, then saves', async () => {
|
||||
const saveTemplate = jest.fn().mockResolvedValue({ name: 'custom.md', label: 'Custom', content: '# X' });
|
||||
window.prompt = jest
|
||||
const saveTemplate = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce('custom')
|
||||
.mockReturnValueOnce('# X');
|
||||
.mockResolvedValue({ name: 'custom.md', label: 'Custom', content: '# X' });
|
||||
window.prompt = jest.fn().mockReturnValueOnce('custom').mockReturnValueOnce('# X');
|
||||
|
||||
const { container } = mountPanel({ saveTemplate });
|
||||
await flush();
|
||||
@@ -158,9 +164,10 @@ describe('renderDailyTemplatesPanel — New template', () => {
|
||||
|
||||
describe('renderDailyTemplatesPanel — refresh', () => {
|
||||
test('refresh button re-fetches the list', async () => {
|
||||
const listTemplates = jest.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([
|
||||
{ name: 'a.md', label: 'A', content: '' },
|
||||
]);
|
||||
const listTemplates = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ name: 'a.md', label: 'A', content: '' }]);
|
||||
const { container, api } = mountPanel({ listTemplates });
|
||||
await flush();
|
||||
expect(container.querySelectorAll('.daily-templates-item')).toHaveLength(0);
|
||||
@@ -172,8 +179,10 @@ describe('renderDailyTemplatesPanel — refresh', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
expect(container.querySelector('#daily-templates-status').textContent).toMatch(/nope/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +151,9 @@ describe('mountFootnotePreview', () => {
|
||||
document.body.appendChild(root);
|
||||
|
||||
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);
|
||||
expect(document.querySelector('.footnote-preview')).not.toBeNull();
|
||||
|
||||
@@ -160,8 +162,10 @@ describe('mountFootnotePreview', () => {
|
||||
expect(document.querySelector('.footnote-preview')).toBeNull();
|
||||
|
||||
// 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);
|
||||
expect(document.querySelector('.footnote-preview')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,4 +168,4 @@ describe('DailyNotesTemplates.deleteTemplate', () => {
|
||||
expect(DailyNotesTemplates.deleteTemplate({ name: 'x', fs, pathUtil: path })).toBe(false);
|
||||
expect(DailyNotesTemplates.deleteTemplate({ dir: tmpDir, fs, pathUtil: path })).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,9 +144,7 @@ describe('DocQA.ask with a custom engine', () => {
|
||||
test('passes the question + chunks to a custom engine.rank()', async () => {
|
||||
const customEngine = {
|
||||
isNeural: true,
|
||||
rank: jest.fn().mockResolvedValue([
|
||||
{ filePath: '/x.md#0', snippet: 'ranked', score: 0.9 },
|
||||
]),
|
||||
rank: jest.fn().mockResolvedValue([{ filePath: '/x.md#0', snippet: 'ranked', score: 0.9 }]),
|
||||
};
|
||||
const r = await DocQA.ask({ question: 'rust', files, engine: customEngine });
|
||||
expect(customEngine.rank).toHaveBeenCalled();
|
||||
|
||||
@@ -54,8 +54,7 @@ describe('PDFOperations - Task 15 new operations', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.text).toContain('Hello Task 15 Page One');
|
||||
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);
|
||||
}, 30000); // CI runners (observed on windows-latest) // First pdfjs-dist legacy import can exceed the 5s default on slower
|
||||
|
||||
it('returns failure for a nonexistent file', async () => {
|
||||
const result = await PDFOperations.pdfExtractText({
|
||||
|
||||
@@ -85,4 +85,4 @@ describe('SemanticEngine — WorkspaceSearch parity', () => {
|
||||
expect(engineResult.map((r) => r.filePath)).toEqual(directResult.map((r) => r.filePath));
|
||||
expect(engineResult[0].score).toBe(directResult[0].score);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,9 @@ describe('UrlTitle.decodeTitle', () => {
|
||||
describe('UrlTitle.extractTitleFromHtml', () => {
|
||||
test('extracts the first <title> in the body', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -174,7 +176,9 @@ describe('UrlTitle.fetchTitle — failure paths', () => {
|
||||
headers: { get: () => 'text/html' },
|
||||
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 () => {
|
||||
@@ -195,4 +199,4 @@ describe('UrlTitle.fetchTitle — failure paths', () => {
|
||||
expect(r).toBeNull();
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,9 +108,9 @@ describe('renderSearchPanel — search mode', () => {
|
||||
|
||||
test('clicking a result calls onOpenFile with the right path', async () => {
|
||||
const onOpenFile = jest.fn();
|
||||
const search = jest.fn().mockResolvedValue([
|
||||
{ filePath: '/notes/x.md', snippet: 's', score: 1, matchedTerms: [] },
|
||||
]);
|
||||
const search = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ filePath: '/notes/x.md', snippet: 's', score: 1, matchedTerms: [] }]);
|
||||
const { container } = mountPanel({ search, onOpenFile });
|
||||
container.querySelector('#search-input').value = 'x';
|
||||
container.querySelector('#search-run').click();
|
||||
@@ -246,9 +246,7 @@ describe('renderSearchPanel — escaping', () => {
|
||||
|
||||
describe('renderSearchPanel — clear/escape behaviors', () => {
|
||||
test('Escape on the input clears the query and results', async () => {
|
||||
const search = jest.fn().mockResolvedValue([
|
||||
{ filePath: '/a.md', snippet: 's', score: 1 },
|
||||
]);
|
||||
const search = jest.fn().mockResolvedValue([{ filePath: '/a.md', snippet: 's', score: 1 }]);
|
||||
const { container, container: c2 } = mountPanel({ search });
|
||||
const input = container.querySelector('#search-input');
|
||||
input.value = 'x';
|
||||
@@ -283,4 +281,4 @@ describe('renderSearchPanel — clear/escape behaviors', () => {
|
||||
api.clear();
|
||||
expect(container.querySelector('#search-input').value).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,4 +71,4 @@ describe('Smart paste replacement formatting', () => {
|
||||
const replacement = `[T](${url})`;
|
||||
expect(replacement).toBe('[T](https://example.com/path?a=1&b=2#frag)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,9 @@ describe('stripMarkdown', () => {
|
||||
|
||||
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')).not.toMatch(/docs\.example/);
|
||||
expect(stripMarkdown('read [the docs](https://docs.example.com) now')).not.toMatch(
|
||||
/docs\.example/
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps wikilink visible labels', () => {
|
||||
@@ -177,4 +179,4 @@ A second paragraph with [a link](https://x.com) and **bold** words.`;
|
||||
const s = computeStats(' hello world ');
|
||||
expect(s.charCount).toBe(10); // "helloworld"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user