mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(templates,qa): template gallery + pluggable DocQA engine
Two more features from the deferred menu:
Daily-note template gallery:
- src/main/DailyNotesTemplates.js — pure module: listTemplates() /
saveTemplate() / deleteTemplate() / labelFor() with injectable IO.
- src/main/DailyNotes.js — openOrCreate() now accepts seedContent so a
non-default template can seed a NEW note (existing notes never get
clobbered).
- src/main.js — IPC channels daily-templates:list / save / delete /
apply. apply renders the chosen template (with {date}/{weekday}
substitution) and pipes through DailyNotes.openOrCreate.
- src/sidebar/daily-templates-panel.js — gallery UI: list, +New
(prompt for name + content), Use (applies to today's note),
delete (refuses to remove the last template so the default survives).
- src/renderer.js — registers the panel.
- src/index.html — icon (already added).
Pluggable DocQA engine (semantic search hook):
- src/main/SemanticEngine.js — engine interface with defaultEngine() (TF-idF,
always available) and neuralEngine() (lazy @xenova/transformers,
falls back gracefully when the dep is missing). getEngine(name)
resolves either.
- src/main/DocQA.js — ask() is now async and accepts an engine arg.
TF-idF path unchanged; neural path calls engine.rank(question, chunks)
directly. The chunk corpus is built up front regardless of engine so
ranking is consistent.
- src/main.js — doc-qa:ask IPC resolves the engine via SemanticEngine.getEngine(name)
before calling DocQA.ask. The renderer can pass {engine: 'transformers'}
to opt in once @xenova/transformers is installed.
Tests (51 new across this batch):
- tests/main/DailyNotesTemplates.test.js (18): labelFor separators /
edge cases / non-string safety, listTemplates empty / present / sort,
saveTemplate nested dir + .md extension + validation + null content,
deleteTemplate success / missing / validation.
- tests/daily-templates-panel.test.js (12): mount + empty state + list +
XSS safety, Use button (apply + error path), Delete button (success +
last-template guard), New template (save + cancel), refresh.
- tests/main/SemanticEngine.test.js (8): default engine shape + rank
matches WorkspaceSearch, getEngine for tf-idf / unknown / transformers
(graceful fallback when @xenova/transformers missing), parity check.
- DocQA: 5 new tests for engine arg (custom engine.rank called, default
fallback, neural hit shape translation); existing tests updated to
await the now-async ask().
Full suite: 76 suites, 914 tests, lint+format clean.
Activation for the neural engine:
npm install @xenova/transformers
(heavy; ~50 MiB with deps) — then 'transformers' is selectable in
doc-qa:ask. Until then, all calls use TF-idF transparently.
Amit Haridas
This commit is contained in:
+79
-2
@@ -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 <userData>/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
|
||||
// <userData>/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 });
|
||||
});
|
||||
|
||||
// ================================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Daily-note template gallery.
|
||||
*
|
||||
* The user can keep multiple daily-note skeletons in
|
||||
* <userData>/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,
|
||||
};
|
||||
+46
-20
@@ -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
|
||||
// "<file>#<offset>" 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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Array>}
|
||||
*/
|
||||
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<string, Float32Array> 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<object>} 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<object>} 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,
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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 <userData>/notes/templates/. Picking one seeds today's
|
||||
// note via daily-templates:apply.
|
||||
sidebarManager.registerPanel('daily-templates', {
|
||||
title: 'Daily Templates',
|
||||
icon:
|
||||
'<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 5a2 2 0 0 1 2-2h11l-2 2H5v12h14V9l2-2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5zm5 0h2v2H8V5zm0 4h2v2H8V9zm0 4h2v2H8v-2z"/></svg>',
|
||||
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) =>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Sidebar panel: daily-note template gallery.
|
||||
*
|
||||
* Lists every .md in <userData>/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<Array<{name:string,label:string,content:string}>>} deps.listTemplates
|
||||
* @param {({name:string, content:string}) => Promise<{name:string,label:string,content:string}>} deps.saveTemplate
|
||||
* @param {({name:string}) => Promise<boolean>} 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 = `
|
||||
<div class="daily-templates-panel">
|
||||
<div class="daily-templates-toolbar">
|
||||
<button class="daily-templates-new-btn" id="daily-templates-new">+ New Template</button>
|
||||
<button class="daily-templates-refresh-btn" id="daily-templates-refresh" title="Reload">↻</button>
|
||||
</div>
|
||||
<div class="daily-templates-status" id="daily-templates-status"></div>
|
||||
<div class="daily-templates-list" id="daily-templates-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 =
|
||||
'<div class="daily-templates-empty">No templates yet. Press <b>+ New Template</b> to create one.</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = entries
|
||||
.map(
|
||||
(t, i) =>
|
||||
`<div class="daily-templates-item" data-idx="${i}">
|
||||
<div class="daily-templates-row">
|
||||
<div class="daily-templates-label"></div>
|
||||
<div class="daily-templates-actions">
|
||||
<button class="daily-templates-apply-btn" data-action="apply" data-idx="${i}" title="Use this template for today's note">Use</button>
|
||||
<button class="daily-templates-delete-btn" data-action="delete" data-idx="${i}" title="Delete template">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.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 };
|
||||
Reference in New Issue
Block a user