diff --git a/src/main.js b/src/main.js index eb7c629..263c262 100644 --- a/src/main.js +++ b/src/main.js @@ -4003,8 +4003,10 @@ ipcMain.on('set-current-file', (event, filePath) => { const VersionHistory = require('./main/VersionHistory'); const AutosaveBuffer = require('./main/AutosaveBuffer'); const DailyNotes = require('./main/DailyNotes'); +const DailyNotesTemplates = require('./main/DailyNotesTemplates'); const WorkspaceSearch = require('./main/WorkspaceSearch'); const DocQA = require('./main/DocQA'); +const SemanticEngine = require('./main/SemanticEngine'); const UrlTitle = require('./main/UrlTitle'); /** IO bundle for VersionHistory bound to /versions. */ @@ -5765,6 +5767,77 @@ ipcMain.handle('daily-notes:list', async () => { return filenames.map((name) => path.join(dir, name)); }); +// Daily-note template gallery: the user can keep multiple .md skeletons in +// /notes/templates/. The first one (`daily.md`) is the default. +// Other files become selectable in the gallery. +ipcMain.handle('daily-templates:list', async () => { + const dir = dailyNotesTemplateDir(); + const validation = validatePath(dir); + if (!validation.valid) return []; + return DailyNotesTemplates.listTemplates({ dir, fs, pathUtil: path }); +}); + +ipcMain.handle('daily-templates:save', async (_event, { name, content } = {}) => { + const dir = dailyNotesTemplateDir(); + const validation = validatePath(dir); + if (!validation.valid) throw new Error('Invalid templates directory'); + if (typeof name !== 'string' || !name.trim()) throw new Error('Template name is required'); + return DailyNotesTemplates.saveTemplate({ + dir, + name: name.trim(), + content: typeof content === 'string' ? content : '', + fs, + pathUtil: path, + }); +}); + +ipcMain.handle('daily-templates:delete', async (_event, { name } = {}) => { + const dir = dailyNotesTemplateDir(); + const validation = validatePath(dir); + if (!validation.valid) return false; + if (typeof name !== 'string' || !name) return false; + return DailyNotesTemplates.deleteTemplate({ dir, name, fs, pathUtil: path }); +}); + +// Pick a template for today's note. The renderer / daily-notes panel calls +// this with the chosen template name; main writes the rendered content via +// DailyNotes.openOrCreate so the rest of the flow stays unchanged. +ipcMain.handle('daily-templates:apply', async (_event, { templateName } = {}) => { + const dir = dailyNotesDir(); + const tplDir = dailyNotesTemplateDir(); + const validation = validatePath(dir); + if (!validation.valid) throw new Error('Invalid daily-notes directory'); + // Resolve the template (or fall back to default) + let body = null; + if (templateName && typeof templateName === 'string') { + const tpl = path.join(tplDir, templateName); + try { + body = fs.readFileSync(tpl, 'utf-8'); + } catch { + /* fall through to default */ + } + } + const when = new Date(); + if (body) { + body = body + .replace(/\{date\}/g, DailyNotes.dateKey(when)) + .replace(/\{weekday\}/g, when.toLocaleDateString('en-US', { weekday: 'long' })); + } + // openOrCreate will load the default template if body is null and the + // file doesn't exist; if the file exists we just open it (no clobber). + return DailyNotes.openOrCreate({ + date: when, + dir, + templateDir: tplDir, + fs, + pathUtil: path, + now: when, + // If we resolved a custom template, write it as the seed content for a + // NEW note. openOrCreate only writes when the file is missing. + seedContent: body, + }); +}); + // ================================ // Workspace content search (tag/wikilink-aware) // ================================ @@ -5829,7 +5902,7 @@ 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 } = {}) => { +ipcMain.handle('doc-qa:ask', async (_event, { question, dir, topK = 5, engine: engineName } = {}) => { if (typeof question !== 'string' || !question.trim()) { return { question: '', chunks: [] }; } @@ -5852,7 +5925,11 @@ ipcMain.handle('doc-qa:ask', async (_event, { question, dir, topK = 5 } = {}) => } } - return DocQA.ask({ question, files: corpus, topK }); + // 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 }); }); // ================================ diff --git a/src/main/DailyNotes.js b/src/main/DailyNotes.js index 796dfc3..24ee0d0 100644 --- a/src/main/DailyNotes.js +++ b/src/main/DailyNotes.js @@ -71,7 +71,7 @@ function loadTemplate({ * Caller (main.js IPC handler) decides what to do with `created` — * typically: open in the existing tab if any, else createNewTab. */ -function openOrCreate({ date, dir, templateDir, fs, pathUtil, now = new Date() }) { +function openOrCreate({ date, dir, templateDir, fs, pathUtil, now = new Date(), seedContent }) { if (!dir) throw new Error('DailyNotes: dir is required'); fs.mkdirSync(dir, { recursive: true }); const notePath = pathFor(date, dir, pathUtil); @@ -81,7 +81,12 @@ function openOrCreate({ date, dir, templateDir, fs, pathUtil, now = new Date() } content = fs.readFileSync(notePath, 'utf-8'); } catch (err) { if (err.code !== 'ENOENT') throw err; - content = loadTemplate({ date, templateDir, fs, pathUtil, now }); + // 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 }); fs.writeFileSync(notePath, content, 'utf-8'); created = true; } diff --git a/src/main/DailyNotesTemplates.js b/src/main/DailyNotesTemplates.js new file mode 100644 index 0000000..c792468 --- /dev/null +++ b/src/main/DailyNotesTemplates.js @@ -0,0 +1,83 @@ +/** + * Daily-note template gallery. + * + * The user can keep multiple daily-note skeletons in + * /notes/templates/ — each .md file in that directory is one + * template. The first line of the filename becomes the template's display + * label (case-folded to title-case, extension stripped). + * + * Pure module: takes a `rootDir` and injectable IO so it stays unit-testable. + * + * @module DailyNotesTemplates + */ + +/** Title-case a name. "morning-pages.md" → "Morning Pages". */ +function labelFor(filename) { + if (typeof filename !== 'string' || filename.length === 0) return ''; + const stem = filename.replace(/\.md$/i, ''); + return stem + .split(/[-_\s]+/) + .filter((s) => s.length > 0) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()) + .join(' '); +} + +/** Read the template files in `dir` (non-recursive). */ +function listTemplates({ dir, fs, pathUtil }) { + if (!dir) return []; + let entries; + try { + entries = fs.readdirSync(dir); + } catch (err) { + if (err.code === 'ENOENT') return []; + throw err; + } + const out = []; + for (const name of entries) { + if (!/\.md$/i.test(name)) continue; + let content; + try { + content = fs.readFileSync(pathUtil.join(dir, name), 'utf-8'); + } catch { + continue; + } + out.push({ + name, + label: labelFor(name), + content, + }); + } + // Stable order: alphabetical by label + out.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0)); + return out; +} + +/** Write a new template file. Returns the saved entry. */ +function saveTemplate({ dir, name, content, fs, pathUtil }) { + if (!dir) throw new Error('DailyNotesTemplates: dir is required'); + if (!name || typeof name !== 'string') throw new Error('name is required'); + if (!/\.md$/i.test(name)) name = `${name}.md`; + fs.mkdirSync(dir, { recursive: true }); + const fullPath = pathUtil.join(dir, name); + fs.writeFileSync(fullPath, String(content || ''), 'utf-8'); + return { name, label: labelFor(name), content: String(content || '') }; +} + +/** Delete a template file. Returns true if removed. */ +function deleteTemplate({ dir, name, fs, pathUtil }) { + if (!dir || !name) return false; + try { + fs.unlinkSync(pathUtil.join(dir, name)); + return true; + } catch (err) { + if (err.code === 'ENOENT') return false; + throw err; + } +} + +module.exports = { + labelFor, + listTemplates, + saveTemplate, + deleteTemplate, +}; \ No newline at end of file diff --git a/src/main/DocQA.js b/src/main/DocQA.js index 18876d9..476351f 100644 --- a/src/main/DocQA.js +++ b/src/main/DocQA.js @@ -21,6 +21,7 @@ */ const WorkspaceSearch = require('./WorkspaceSearch'); +const SemanticEngine = require('./SemanticEngine'); const QUESTION_WORDS = new Set([ 'what', @@ -172,51 +173,76 @@ function chunkDocument(content, maxChunkChars = 800) { * @param {Array<{path:string, content:string, mtimeMs?:number}>} args.files * @param {number} [args.topK=5] number of chunks to return * @param {number} [args.nowMs=Date.now()] + * @param {object} [args.engine] Optional SemanticEngine instance. When + * omitted, the default TF-idF engine is used. Pass a neural engine to + * swap in semantic embeddings. * @returns {{question:string, chunks:Array<{filePath:string, snippet:string, score:number, mtimeMs:number}>}} */ -function ask({ question, files, topK = 5, nowMs = Date.now() }) { +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: [] }; } - // First, find the docs that match at all (cheap, broad pass). - const docHits = WorkspaceSearch.search({ query: cleaned, files, limit: 20, nowMs }); - - // Then re-rank at the chunk level within those docs. + // Chunk the corpus up front — both default and neural engines rank at + // chunk granularity. const chunkCorpus = []; - for (const hit of docHits) { - const file = files.find((f) => f.path === hit.filePath); + for (const file of files) { if (!file || typeof file.content !== 'string') continue; const chunks = chunkDocument(file.content); for (const chunk of chunks) { chunkCorpus.push({ - path: `${hit.filePath}#${chunk.start}`, + path: `${file.path}#${chunk.start}`, content: chunk.text, + offset: chunk.start, mtimeMs: file.mtimeMs, }); } } + if (chunkCorpus.length === 0) { + return { question: String(question), chunks: [] }; + } - const chunkHits = WorkspaceSearch.search({ - query: cleaned, - files: chunkCorpus, - limit: topK, - nowMs, - }); + // Resolve engine (default = tf-idf) + const eng = engine || SemanticEngine.defaultEngine(); - // Translate the per-chunk hits back into the public shape. The fake path - // "#" carries the chunk start; the renderer's existing - // file-opened handler can split it on '#' if it wants to deep-link. - const chunks = chunkHits.map((h) => { + let chunkHits; + if (eng.isNeural) { + // Neural: rank directly on the question against the chunk corpus. + chunkHits = await eng.rank(cleaned, chunkCorpus); + } else { + // TF-idF: broad doc pass first (caps the chunk corpus), then chunk rank. + const docHits = WorkspaceSearch.search({ query: cleaned, files, limit: 20, nowMs }); + const docPaths = new Set(docHits.map((h) => h.filePath)); + const filtered = chunkCorpus.filter((c) => { + const filePath = c.path.replace(/#\d+$/, ''); + return docPaths.has(filePath); + }); + chunkHits = WorkspaceSearch.search({ + query: cleaned, + files: filtered.length > 0 ? filtered : chunkCorpus, + limit: topK, + nowMs, + }); + } + + // Translate the per-chunk hits back into the public shape. + const chunks = chunkHits.slice(0, topK).map((h) => { const offsetMatch = /#(\d+)$/.exec(h.filePath); - const offset = offsetMatch ? Number(offsetMatch[1]) : 0; + const offset = offsetMatch ? Number(offsetMatch[1]) : h.offset || 0; return { filePath: h.filePath.replace(/#\d+$/, ''), offset, snippet: h.snippet, score: h.score, - mtimeMs: chunkCorpus.find((c) => c.path === h.filePath)?.mtimeMs || 0, + mtimeMs: + chunkCorpus.find((c) => c.path === h.filePath)?.mtimeMs || h.mtimeMs || 0, }; }); diff --git a/src/main/SemanticEngine.js b/src/main/SemanticEngine.js new file mode 100644 index 0000000..b3e33bb --- /dev/null +++ b/src/main/SemanticEngine.js @@ -0,0 +1,168 @@ +/** + * Semantic engine — pluggable interface for DocQA's ranking algorithm. + * + * Default engine: term-frequency (TF-idF style) using the WorkspaceSearch + * ranking. Always present, no install, runs in milliseconds. + * + * Optional engine: dense vector embeddings via @xenova/transformers + * (all-MiniLM-L6-v2 — small, ~25 MiB on disk, good general English quality). + * Lazy-loaded; if the dep is missing or the model can't be reached, the + * default engine is used and a one-line warning is emitted. + * + * The DocQA.ask() function takes an `engine` arg (optional). When omitted, + * the default is used. When a neural engine is provided, DocQA will call + * engine.encode(text) on both the question and each chunk, then rank by + * cosine similarity. The interface is intentionally minimal so swapping + * engines is a one-line change in callers. + * + * Activation (manual): + * 1. `npm install @xenova/transformers` (heavy; ~50 MiB with deps) + * 2. require('@xenova/transformers') will then succeed and the neural + * engine will be available + * + * Until step 1 is run, all engines are TF-idF — fully functional. + * + * @module SemanticEngine + */ + +const { search } = require('./WorkspaceSearch'); + +/** + * Default engine: delegates to WorkspaceSearch's term-frequency ranking. + * No setup, no IO, always available. + */ +function defaultEngine() { + return { + name: 'tf-idf', + isNeural: false, + /** + * Rank chunks by relevance to `question`. Returns the same shape as + * WorkspaceSearch.search(): [{filePath, snippet, score, ...}] + * + * @param {string} question + * @param {Array<{path:string, content:string, mtimeMs?:number}>} chunks + * @returns {Promise} + */ + async rank(question, chunks) { + // WorkspaceSearch.search expects raw "files" — each chunk is a file. + // We pass them through and translate the per-chunk paths back. + const r = search({ query: question, files: chunks, limit: chunks.length }); + return r; + }, + }; +} + +/** + * Neural engine (when @xenova/transformers is installed). + * + * Uses all-MiniLM-L6-v2 (384-dim, ~25 MiB on first use). Lazy-loads on first + * call so the cold-start of the app isn't slowed down. Embeddings are + * cached per-question text in a Map to avoid + * recomputation within a session. + * + * Falls back to defaultEngine() if the dep is missing or the model fails + * to load — logged once. + * + * @returns {Promise} engine with rank(question, chunks) + */ +async function neuralEngine(opts = {}) { + const modelName = typeof opts.model === 'string' ? opts.model : 'Xenova/all-MiniLM-L6-v2'; + + let transformers; + try { + transformers = require('@xenova/transformers'); + } catch { + console.warn('[SemanticEngine] @xenova/transformers not installed — using tf-idf instead'); + return defaultEngine(); + } + + // Disable remote model downloads in CI / offline environments + const env = typeof transformers.env !== 'undefined' ? transformers.env : null; + if (env && opts.allowRemote === false) { + env.allowRemoteModels = false; + env.allowLocalModels = true; + } + + let pipeline; + try { + pipeline = await transformers.pipeline('feature-extraction', modelName, { + // quantized for size; the default fp32 model is 90 MiB + quantized: opts.quantized !== false, + }); + } catch (err) { + console.warn( + `[SemanticEngine] Failed to load ${modelName} (${err && err.message}); using tf-idf` + ); + return defaultEngine(); + } + + const cache = new Map(); + + async function encode(text) { + if (cache.has(text)) return cache.get(text); + const out = await pipeline(text, { pooling: 'mean', normalize: true }); + const vec = out.data instanceof Float32Array ? out.data : Float32Array.from(out.data); + cache.set(text, vec); + return vec; + } + + function cosine(a, b) { + // Both are normalized so this is a dot product, but we compute the full + // formula defensively in case the model doesn't normalize. + let dot = 0; + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) dot += a[i] * b[i]; + return dot; + } + + return { + name: `transformers:${modelName}`, + isNeural: true, + async rank(question, chunks) { + const qVec = await encode(question); + const scored = []; + for (const chunk of chunks) { + let cVec; + try { + cVec = await encode(chunk.content.slice(0, 2000)); + // Truncate to keep encoding fast — MiniLM has a 256-token window + // and longer texts just dilute the signal. + } catch { + // Encoding failed for this chunk — skip it + continue; + } + const score = cosine(qVec, cVec); + scored.push({ + filePath: chunk.path, + snippet: (chunk.content || '').slice(0, 200).replace(/\s+/g, ' ').trim() + '…', + score, + mtimeMs: chunk.mtimeMs, + offset: chunk.offset || 0, + }); + } + scored.sort((a, b) => b.score - a.score); + return scored; + }, + }; +} + +/** + * Get an engine by name. Unknown names fall back to defaultEngine(). + * + * @param {string} [name='tf-idf'] + * @param {object} [opts] + * @returns {Promise} engine + */ +async function getEngine(name = 'tf-idf', opts = {}) { + if (name === 'tf-idf') return defaultEngine(); + if (name === 'transformers' || name.startsWith('transformers:')) { + return neuralEngine(opts); + } + return defaultEngine(); +} + +module.exports = { + defaultEngine, + neuralEngine, + getEngine, +}; \ No newline at end of file diff --git a/src/preload.js b/src/preload.js index ac35bf9..455a0a8 100644 --- a/src/preload.js +++ b/src/preload.js @@ -191,6 +191,12 @@ const ALLOWED_SEND_CHANNELS = [ 'daily-notes:open-today', 'daily-notes:list', + // Daily-note template gallery + 'daily-templates:list', + 'daily-templates:save', + 'daily-templates:delete', + 'daily-templates:apply', + // Workspace content search (tag/wikilink-aware) 'workspace-search:query', diff --git a/src/renderer.js b/src/renderer.js index dfb7e1e..320208c 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -2263,6 +2263,25 @@ document.addEventListener('DOMContentLoaded', async () => { onOpenFile: (filePath) => ipcRenderer.send('open-file-path', { path: filePath }), }), }); + + // Daily-note template gallery — list / create / apply / delete the .md + // templates in /notes/templates/. Picking one seeds today's + // note via daily-templates:apply. + sidebarManager.registerPanel('daily-templates', { + title: 'Daily Templates', + icon: + '', + render: (container) => + require('./sidebar/daily-templates-panel').renderDailyTemplatesPanel(container, { + listTemplates: () => ipcRenderer.invoke('daily-templates:list'), + saveTemplate: ({ name, content }) => + ipcRenderer.invoke('daily-templates:save', { name, content }), + deleteTemplate: ({ name }) => ipcRenderer.invoke('daily-templates:delete', { name }), + applyTemplate: ({ templateName }) => + ipcRenderer.invoke('daily-templates:apply', { templateName }), + onOpenFile: (filePath) => ipcRenderer.send('open-file-path', { path: filePath }), + }), + }); sidebarManager.registerPanel('git', { title: 'Git', render: (container) => diff --git a/src/sidebar/daily-templates-panel.js b/src/sidebar/daily-templates-panel.js new file mode 100644 index 0000000..aa487ff --- /dev/null +++ b/src/sidebar/daily-templates-panel.js @@ -0,0 +1,175 @@ +/** + * Sidebar panel: daily-note template gallery. + * + * Lists every .md in /notes/templates/, lets the user pick one + * before opening today's daily note, and exposes create/edit/delete. + * + * Pure DOM module — same pattern as the other sidebar panels. + * + * @param {HTMLElement} container + * @param {object} deps + * @param {() => Promise>} deps.listTemplates + * @param {({name:string, content:string}) => Promise<{name:string,label:string,content:string}>} deps.saveTemplate + * @param {({name:string}) => Promise} deps.deleteTemplate + * @param {({templateName:string}) => Promise<{path:string,content:string,created:boolean}>} deps.applyTemplate + * @param {(filePath:string) => void} deps.onOpenFile + */ +function renderDailyTemplatesPanel(container, deps) { + const { listTemplates, saveTemplate, deleteTemplate, applyTemplate, onOpenFile } = deps; + let entries = []; + + container.innerHTML = ` +
+
+ + +
+
+
+
+ `; + + const newBtn = container.querySelector('#daily-templates-new'); + const refreshBtn = container.querySelector('#daily-templates-refresh'); + const statusEl = container.querySelector('#daily-templates-status'); + const listEl = container.querySelector('#daily-templates-list'); + + function setStatus(text) { + if (statusEl) statusEl.textContent = text || ''; + } + + function renderList() { + if (!listEl) return; + if (!entries || entries.length === 0) { + listEl.innerHTML = + '
No templates yet. Press + New Template to create one.
'; + return; + } + listEl.innerHTML = entries + .map( + (t, i) => + `
+
+
+
+ + +
+
+
` + ) + .join(''); + listEl.querySelectorAll('.daily-templates-item').forEach((el) => { + const idx = Number(el.dataset.idx); + const t = entries[idx]; + el.querySelector('.daily-templates-label').textContent = t.label; + }); + listEl.querySelectorAll('[data-action="apply"]').forEach((btn) => { + btn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + const idx = Number(btn.dataset.idx); + const t = entries[idx]; + await useTemplate(t); + }); + }); + listEl.querySelectorAll('[data-action="delete"]').forEach((btn) => { + btn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + const idx = Number(btn.dataset.idx); + const t = entries[idx]; + await removeTemplate(t); + }); + }); + } + + async function useTemplate(t) { + if (typeof applyTemplate !== 'function') { + setStatus('Template application is unavailable.'); + return; + } + setStatus(`Using "${t.label}" for today's note…`); + try { + const result = await applyTemplate({ templateName: t.name }); + if (result && result.path && onOpenFile) onOpenFile(result.path); + setStatus(result && result.created ? 'Created today.' : 'Today already exists.'); + } catch (err) { + setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`); + } + } + + async function removeTemplate(t) { + // Don't allow deleting the only template — would break the daily-notes + // default. The user can add more first. + if (entries.length <= 1) { + setStatus('Keep at least one template (the default daily.md).'); + return; + } + if (typeof deleteTemplate !== 'function') return; + try { + await deleteTemplate({ name: t.name }); + setStatus(`Deleted "${t.label}".`); + await refresh({ keepStatus: true }); + } catch (err) { + setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`); + } + } + + function promptForName(defaultName) { + // Vanilla prompt is sufficient here — this is a small modal one-liner. + const answer = window.prompt('Template file name (.md added if missing):', defaultName); + if (typeof answer !== 'string') return null; + const trimmed = answer.trim(); + if (!trimmed) return null; + return trimmed; + } + + function promptForContent(defaultContent) { + const answer = window.prompt( + 'Template body (use {date} and {weekday} as placeholders):', + defaultContent || '# {date} ({weekday})\n\n## Notes\n\n' + ); + if (typeof answer !== 'string') return null; + return answer; + } + + newBtn.addEventListener('click', async () => { + if (typeof saveTemplate !== 'function') return; + const name = promptForName('morning-pages.md'); + if (!name) return; + const content = promptForContent('# {date} ({weekday})\n\n## Notes\n\n'); + if (content === null) return; + setStatus('Saving…'); + try { + await saveTemplate({ name, content }); + setStatus(`Saved "${name}".`); + await refresh({ keepStatus: true }); + } catch (err) { + setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`); + } + }); + + refreshBtn.addEventListener('click', () => refresh()); + + async function refresh({ keepStatus = false } = {}) { + if (typeof listTemplates !== 'function') { + setStatus('Template list is unavailable.'); + return; + } + if (!keepStatus) setStatus('Loading…'); + try { + const list = await listTemplates(); + entries = Array.isArray(list) ? list : []; + renderList(); + if (!keepStatus) setStatus(entries.length === 0 ? '' : `${entries.length} template${entries.length === 1 ? '' : 's'}`); + } catch (err) { + setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`); + entries = []; + renderList(); + } + } + + refresh(); + return { refresh }; +} + +module.exports = { renderDailyTemplatesPanel }; \ No newline at end of file diff --git a/tests/daily-templates-panel.test.js b/tests/daily-templates-panel.test.js new file mode 100644 index 0000000..5b27cc3 --- /dev/null +++ b/tests/daily-templates-panel.test.js @@ -0,0 +1,179 @@ +/** + * @jest-environment jsdom + * + * Daily-templates sidebar panel tests. + */ +const { renderDailyTemplatesPanel } = require('../src/sidebar/daily-templates-panel'); + +function mountPanel(deps = {}) { + const container = document.createElement('div'); + 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: '' }), + deleteTemplate: deps.deleteTemplate || jest.fn().mockResolvedValue(true), + applyTemplate: deps.applyTemplate || jest.fn().mockResolvedValue({ path: '/d/2026-09-14.md', content: '', created: true }), + onOpenFile: deps.onOpenFile || jest.fn(), + ...deps, + }); + return { container, api }; +} + +async function flush() { + for (let i = 0; i < 5; i++) await Promise.resolve(); +} + +describe('renderDailyTemplatesPanel — mounting', () => { + test('mounts the panel structure', () => { + const { container } = mountPanel(); + expect(container.querySelector('.daily-templates-panel')).not.toBeNull(); + expect(container.querySelector('#daily-templates-new')).not.toBeNull(); + expect(container.querySelector('#daily-templates-refresh')).not.toBeNull(); + }); + + test('shows the empty state when there are no templates', async () => { + const { container } = mountPanel({ listTemplates: jest.fn().mockResolvedValue([]) }); + await flush(); + expect(container.querySelector('.daily-templates-empty')).not.toBeNull(); + }); + + test('renders one row per template', async () => { + const { container } = mountPanel({ + listTemplates: jest.fn().mockResolvedValue([ + { name: 'morning-pages.md', label: 'Morning Pages', content: '# Morning' }, + { name: 'evening.md', label: 'Evening', content: '# Evening' }, + ]), + }); + await flush(); + const items = container.querySelectorAll('.daily-templates-item'); + expect(items).toHaveLength(2); + expect(items[0].textContent).toContain('Morning Pages'); + expect(items[1].textContent).toContain('Evening'); + }); + + test('escapeHtml-style safety: a label with ', content: '' }, + ]), + }); + await flush(); + expect(container.querySelector('script')).toBeNull(); + // The literal text survives in the row (textContent keeps it visible as + // text rather than as a node — the safety we want). + expect(container.querySelector('.daily-templates-item').textContent).toContain('