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
+12 -4
View File
@@ -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);
});
});
});
+19 -13
View File
@@ -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);
});
});
});
+25 -16
View File
@@ -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/);
});
});
});
+7 -3
View File
@@ -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();
});
});
});
+1 -1
View File
@@ -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);
});
});
});
+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 () => {
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();
+1 -2
View File
@@ -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({
+1 -1
View File
@@ -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);
});
});
});
+7 -3
View File
@@ -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();
});
});
});
+5 -7
View File
@@ -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('');
});
});
});
+1 -1
View File
@@ -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)');
});
});
});
+4 -2
View File
@@ -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"
});
});
});