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
|
## Version
|
||||||
|
|
||||||
v4.7.1
|
v4.8.0
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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 & Ask (Ctrl+Shift+F)">
|
<button
|
||||||
|
class="sidebar-icon"
|
||||||
|
data-panel="search"
|
||||||
|
title="Search & 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>
|
||||||
|
|||||||
+5
-2
@@ -5902,7 +5902,9 @@ 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(
|
||||||
|
'doc-qa:ask',
|
||||||
|
async (_event, { question, dir, topK = 5, engine: engineName } = {}) => {
|
||||||
if (typeof question !== 'string' || !question.trim()) {
|
if (typeof question !== 'string' || !question.trim()) {
|
||||||
return { question: '', chunks: [] };
|
return { question: '', chunks: [] };
|
||||||
}
|
}
|
||||||
@@ -5930,7 +5932,8 @@ ipcMain.handle('doc-qa:ask', async (_event, { question, dir, topK = 5, engine: e
|
|||||||
// single console warning — callers always get a usable engine.
|
// single console warning — callers always get a usable engine.
|
||||||
const engine = await SemanticEngine.getEngine(engineName || 'tf-idf');
|
const engine = await SemanticEngine.getEngine(engineName || 'tf-idf');
|
||||||
return DocQA.ask({ question, files: corpus, topK, engine });
|
return DocQA.ask({ question, files: corpus, topK, engine });
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// Smart-paste: URL → page title
|
// Smart-paste: URL → page title
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ 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 =
|
||||||
|
typeof seedContent === 'string' && seedContent.length > 0
|
||||||
? seedContent
|
? seedContent
|
||||||
: loadTemplate({ date, templateDir, fs, pathUtil, now });
|
: loadTemplate({ date, templateDir, fs, pathUtil, now });
|
||||||
fs.writeFileSync(notePath, content, 'utf-8');
|
fs.writeFileSync(notePath, content, 'utf-8');
|
||||||
|
|||||||
+2
-9
@@ -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,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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'),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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(
|
||||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])
|
/[&<>"']/g,
|
||||||
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 : [];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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,7 +41,9 @@ 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
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
'/notes/daily/2026-09-14.md',
|
'/notes/daily/2026-09-14.md',
|
||||||
'/notes/daily/2026-09-13.md',
|
'/notes/daily/2026-09-13.md',
|
||||||
'/notes/daily/2026-09-10.md',
|
'/notes/daily/2026-09-10.md',
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user