mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(pkm): daily notes + workspace search + doc-aware Q&A
Three more features from the brainstorm menu, built on a shared search
algorithm so the codebase stays small.
- src/main/DailyNotes.js — Zettelkasten-style helper. One YYYY-MM-DD.md
per local date under <userData>/notes/daily/; loads skeleton from
<userData>/notes/templates/daily.md when present (built-in default
otherwise). openOrCreate never clobbers existing content.
- src/main/WorkspaceSearch.js — tag/wikilink-aware content search.
Pure module, injectable-IO tested. Query grammar: bare words,
#tag, @wikilink, "quoted phrases". Facets weight +3 each; prose
terms +1/occurrence capped at 5; edits within 7 days get a recency
nudge. Returns ranked results with snippets.
- src/main/DocQA.js — chunk-level Q&A wrapper over WorkspaceSearch.
cleanQuestion strips question words (what/how/why/...) and verb
noise (write/read/show/tell/...) so they don't drown the ranking.
Returns top-K passages instead of whole-file hits — multiple chunks
from the same file can appear in the answer.
- src/main.js — IPC: daily-notes:open-today, daily-notes:list,
workspace-search:query, doc-qa:ask. Path validation through the
existing validatePath gate; a global Ctrl+Alt+D shortcut creates
today's daily note from anywhere.
- src/preload.js — all four channels added to ALLOWED_SEND_CHANNELS.
Tests (56 new across the three modules):
- tests/main/DailyNotes.test.js (15): dateKey formatting, pathFor,
template load + {date}/{weekday} substitution, openOrCreate +
no-clobber, nested-dir creation, listExisting filtering,
isValidDir rejects NUL/non-string.
- tests/main/WorkspaceSearch.test.js (25): parseQuery grammar,
hasTag/hasWikilink word boundaries, scoreDocument scoring,
per-term spam cap, recency nudge, search ranking + limit +
empty-query short-circuit, bad-input safety.
- tests/main/DocQA.test.js (16): cleanQuestion stripping + facet
preservation, chunkDocument paragraph + hard-split, ask()
top-K, recency tiebreaker, missing-files fallback.
Full suite: 748 tests pass, 66 suites, lint+format clean.
Amit Haridas
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*
|
||||
* WorkspaceSearch tests — pure module, no IO.
|
||||
*/
|
||||
const WorkspaceSearch = require('../../src/main/WorkspaceSearch');
|
||||
|
||||
describe('WorkspaceSearch.parseQuery', () => {
|
||||
test('extracts bare terms', () => {
|
||||
const q = WorkspaceSearch.parseQuery('hello world');
|
||||
expect(q.terms).toEqual(['hello', 'world']);
|
||||
expect(q.phrases).toEqual([]);
|
||||
expect(q.tags).toEqual([]);
|
||||
expect(q.links).toEqual([]);
|
||||
});
|
||||
|
||||
test('extracts #tag and @wikilink facets', () => {
|
||||
const q = WorkspaceSearch.parseQuery('search query #rust @project-x');
|
||||
expect(q.terms).toEqual(['search', 'query']);
|
||||
expect(q.tags).toEqual(['rust']);
|
||||
expect(q.links).toEqual(['project-x']);
|
||||
});
|
||||
|
||||
test('extracts "quoted phrases" as a unit', () => {
|
||||
const q = WorkspaceSearch.parseQuery('hello "world peace" again');
|
||||
expect(q.terms).toEqual(['hello', 'again']);
|
||||
expect(q.phrases).toEqual(['world peace']);
|
||||
});
|
||||
|
||||
test('lowercases terms and facets for case-insensitive matching', () => {
|
||||
const q = WorkspaceSearch.parseQuery('Foo #BAR @Baz');
|
||||
expect(q.terms).toEqual(['foo']);
|
||||
expect(q.tags).toEqual(['bar']);
|
||||
expect(q.links).toEqual(['baz']);
|
||||
});
|
||||
|
||||
test('drops single-character noise terms', () => {
|
||||
const q = WorkspaceSearch.parseQuery('a big b');
|
||||
expect(q.terms).toEqual(['big']);
|
||||
});
|
||||
|
||||
test('handles non-string input safely', () => {
|
||||
expect(WorkspaceSearch.parseQuery(null)).toEqual({
|
||||
terms: [],
|
||||
phrases: [],
|
||||
tags: [],
|
||||
links: [],
|
||||
});
|
||||
expect(WorkspaceSearch.parseQuery(undefined)).toEqual({
|
||||
terms: [],
|
||||
phrases: [],
|
||||
tags: [],
|
||||
links: [],
|
||||
});
|
||||
expect(WorkspaceSearch.parseQuery(42)).toEqual({ terms: [], phrases: [], tags: [], links: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceSearch.hasTag', () => {
|
||||
test('matches #foo anywhere with word boundary', () => {
|
||||
expect(WorkspaceSearch.hasTag('#foo hello world', 'foo')).toBe(true);
|
||||
expect(WorkspaceSearch.hasTag('paragraph #foo end', 'foo')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not match a substring of another tag', () => {
|
||||
// #foobar should not match #foo
|
||||
expect(WorkspaceSearch.hasTag('#foobar text', 'foo')).toBe(false);
|
||||
});
|
||||
|
||||
test('is case-insensitive', () => {
|
||||
expect(WorkspaceSearch.hasTag('#FOO bar', 'foo')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceSearch.hasWikilink', () => {
|
||||
test('matches [[foo]] and [[foo|alias]]', () => {
|
||||
expect(WorkspaceSearch.hasWikilink('see [[foo]] for details', 'foo')).toBe(true);
|
||||
expect(WorkspaceSearch.hasWikilink('see [[foo|the thing]] for details', 'foo')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not match partial wiki-link references', () => {
|
||||
expect(WorkspaceSearch.hasWikilink('look at foobar', 'foo')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceSearch.scoreDocument', () => {
|
||||
test('returns null when nothing matches', () => {
|
||||
const r = WorkspaceSearch.scoreDocument({
|
||||
content: 'unrelated text',
|
||||
parsed: WorkspaceSearch.parseQuery('zebra'),
|
||||
});
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
|
||||
test('scores a single term match', () => {
|
||||
const r = WorkspaceSearch.scoreDocument({
|
||||
content: 'the quick brown fox jumps',
|
||||
parsed: WorkspaceSearch.parseQuery('fox'),
|
||||
});
|
||||
expect(r).not.toBeNull();
|
||||
expect(r.score).toBeGreaterThan(0);
|
||||
expect(r.matchedTerms).toContain('fox');
|
||||
});
|
||||
|
||||
test('caps per-term repetition spam', () => {
|
||||
const content = 'foo '.repeat(50) + 'unrelated';
|
||||
const r = WorkspaceSearch.scoreDocument({
|
||||
content,
|
||||
parsed: WorkspaceSearch.parseQuery('foo'),
|
||||
});
|
||||
// 50 occurrences but capped at +5/term
|
||||
expect(r.score).toBe(5);
|
||||
});
|
||||
|
||||
test('tag hits weigh more than prose hits', () => {
|
||||
const r = WorkspaceSearch.scoreDocument({
|
||||
content: 'just a #rust mention',
|
||||
parsed: WorkspaceSearch.parseQuery('#rust'),
|
||||
});
|
||||
// Tag facet = +3, no prose matching
|
||||
expect(r.score).toBe(3);
|
||||
expect(r.matchedTags).toEqual(['rust']);
|
||||
});
|
||||
|
||||
test('wikilink hits are recorded separately', () => {
|
||||
const r = WorkspaceSearch.scoreDocument({
|
||||
content: 'see [[project-x]]',
|
||||
parsed: WorkspaceSearch.parseQuery('@project-x'),
|
||||
});
|
||||
expect(r.score).toBe(3);
|
||||
expect(r.matchedLinks).toEqual(['project-x']);
|
||||
});
|
||||
|
||||
test('phrase hits weigh more than single terms', () => {
|
||||
const r = WorkspaceSearch.scoreDocument({
|
||||
content: 'world peace is lovely',
|
||||
parsed: WorkspaceSearch.parseQuery('"world peace"'),
|
||||
});
|
||||
expect(r.score).toBe(3);
|
||||
});
|
||||
|
||||
test('snippet is a windowed slice around the first match', () => {
|
||||
const content = 'A'.repeat(100) + ' fox here ' + 'B'.repeat(100);
|
||||
const r = WorkspaceSearch.scoreDocument({
|
||||
content,
|
||||
parsed: WorkspaceSearch.parseQuery('fox'),
|
||||
});
|
||||
expect(r.snippet).toContain('fox');
|
||||
expect(r.snippet.length).toBeLessThan(200);
|
||||
});
|
||||
|
||||
test('recency nudge adds a small boost for edits within 7 days', () => {
|
||||
const now = Date.now();
|
||||
const content = 'fox mention';
|
||||
const recent = WorkspaceSearch.scoreDocument({
|
||||
content,
|
||||
parsed: WorkspaceSearch.parseQuery('fox'),
|
||||
mtimeMs: now - 1 * 24 * 60 * 60 * 1000, // 1 day ago
|
||||
nowMs: now,
|
||||
});
|
||||
const stale = WorkspaceSearch.scoreDocument({
|
||||
content,
|
||||
parsed: WorkspaceSearch.parseQuery('fox'),
|
||||
mtimeMs: now - 30 * 24 * 60 * 60 * 1000, // 30 days ago
|
||||
nowMs: now,
|
||||
});
|
||||
expect(recent.score).toBeGreaterThan(stale.score);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceSearch.search', () => {
|
||||
const files = [
|
||||
{ path: '/notes/a.md', content: 'rust language overview' },
|
||||
{ path: '/notes/b.md', content: '#rust tag-only entry with #rust mentions' },
|
||||
{ path: '/notes/c.md', content: 'unrelated content' },
|
||||
{ path: '/notes/d.md', content: '[[project-x]] link' },
|
||||
{ path: '/notes/e.md', content: 'rust rust rust rust rust rust rust' },
|
||||
];
|
||||
|
||||
test('returns matches sorted by score descending', () => {
|
||||
const results = WorkspaceSearch.search({ query: 'rust', files });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
// b.md has #rust (tag + prose) and a.md has prose only
|
||||
expect(results[0].filePath).toBe('/notes/e.md'); // highest prose count
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
|
||||
}
|
||||
});
|
||||
|
||||
test('filters out non-matching docs', () => {
|
||||
const results = WorkspaceSearch.search({ query: 'rust', files });
|
||||
expect(results.find((r) => r.filePath === '/notes/c.md')).toBeUndefined();
|
||||
expect(results.find((r) => r.filePath === '/notes/d.md')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('honors a limit', () => {
|
||||
const results = WorkspaceSearch.search({ query: 'rust', files, limit: 2 });
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
test('returns [] for an empty query', () => {
|
||||
expect(WorkspaceSearch.search({ query: '', files })).toEqual([]);
|
||||
expect(WorkspaceSearch.search({ query: ' ', files })).toEqual([]);
|
||||
expect(WorkspaceSearch.search({ query: '#', files })).toEqual([]);
|
||||
});
|
||||
|
||||
test('combines bare terms with facets in a single query', () => {
|
||||
const files2 = [
|
||||
{ path: '/x.md', content: 'rust #rust content' },
|
||||
{ path: '/y.md', content: 'rust content without tag' },
|
||||
];
|
||||
const results = WorkspaceSearch.search({ query: 'rust #rust', files: files2 });
|
||||
expect(results[0].filePath).toBe('/x.md');
|
||||
expect(results[0].matchedTerms).toContain('rust');
|
||||
expect(results[0].matchedTags).toEqual(['rust']);
|
||||
});
|
||||
|
||||
test('skips files without content (no crash on bad input)', () => {
|
||||
const files3 = [
|
||||
null,
|
||||
{ path: '/a.md' }, // missing content
|
||||
{ path: '/b.md', content: 'fox' },
|
||||
];
|
||||
const results = WorkspaceSearch.search({ query: 'fox', files: files3 });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].filePath).toBe('/b.md');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user