mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(sidebar): daily-notes panel + Q&A deep-link to file offset
Daily notes panel:
- src/sidebar/daily-notes-panel.js — new sidebar panel that lists every
YYYY-MM-DD.md in the daily-notes dir (newest first), with a Today
button that creates/opens today's entry. Refresh button reloads.
Clicking a row calls onOpenFile(path). DOM is built with textContent
for dynamic fields — no XSS surface from a hostile filename.
- src/main.js — daily-notes:list IPC now returns absolute paths (joined
with the daily-notes dir) so the renderer can pass them straight to
open-file-path without re-synthesizing.
- src/renderer.js — registers the panel; icon for the daily-notes
button (calendar icon).
- src/index.html — calendar SVG icon for the daily-notes sidebar entry.
Q&A deep-link:
- src/main.js — open-file-path accepts either a string (legacy) or an
object {path, offset}. The offset is forwarded to the renderer via
file-opened.
- src/renderer.js — file-opened handler scrolls the editor to the
offset using EditorView.scrollIntoView(y: 'center') so the matching
passage lands in the middle of the visible area.
- search panel now passes {path, offset} to open-file-path so Q&A
results with chunk offsets jump straight to the passage.
Tests (12 new, tests/daily-notes-panel.test.js):
- mount, empty state, list rendering
- Today button → onOpenFile, 'Created today' / 'Today already exists' status
- error paths (openToday reject, listExisting reject, listExisting missing)
- click + Enter/Space on a row open the right path
- refresh() re-fetches, keeps status when keepStatus:true
- non-md entries filtered out
- XSS-safe textContent for dynamic data
Full suite: 73 suites, 873 tests, lint+format clean.
Amit Haridas
This commit is contained in:
@@ -2574,6 +2574,24 @@
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Daily notes: list of YYYY-MM-DD.md files + Today button -->
|
||||
<button class="sidebar-icon" data-panel="daily-notes" title="Daily Notes (Ctrl+Alt+D)">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-panel" id="sidebar-panel">
|
||||
<div class="sidebar-panel-header">
|
||||
|
||||
+16
-3
@@ -5759,7 +5759,10 @@ ipcMain.handle('daily-notes:list', async () => {
|
||||
const dir = dailyNotesDir();
|
||||
const validation = validatePath(dir);
|
||||
if (!validation.valid) return [];
|
||||
return DailyNotes.listExisting({ dir, fs, pathUtil: path });
|
||||
// Return absolute paths so the renderer can pass them straight to
|
||||
// open-file-path without re-synthesizing.
|
||||
const filenames = DailyNotes.listExisting({ dir, fs, pathUtil: path });
|
||||
return filenames.map((name) => path.join(dir, name));
|
||||
});
|
||||
|
||||
// ================================
|
||||
@@ -6172,8 +6175,17 @@ ipcMain.handle('move-path', async (event, payload) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Open a file by path (from explorer panel)
|
||||
ipcMain.on('open-file-path', (event, filePath) => {
|
||||
// Open a file by path (from explorer panel). Accepts an optional `offset`
|
||||
// (char offset in the file) so deep-links from search results / Q&A hits
|
||||
// can scroll to the matching passage.
|
||||
ipcMain.on('open-file-path', (event, payload) => {
|
||||
// Backwards-compatible: callers passing a plain string still work.
|
||||
let filePath = payload;
|
||||
let offset = 0;
|
||||
if (payload && typeof payload === 'object') {
|
||||
filePath = payload.path;
|
||||
offset = typeof payload.offset === 'number' && payload.offset >= 0 ? payload.offset : 0;
|
||||
}
|
||||
try {
|
||||
// Validate path to prevent traversal attacks
|
||||
const validation = validatePath(filePath);
|
||||
@@ -6191,6 +6203,7 @@ ipcMain.on('open-file-path', (event, filePath) => {
|
||||
mainWindow.webContents.send('file-opened', {
|
||||
path: validation.resolved,
|
||||
content,
|
||||
offset,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('open-file-path error:', err);
|
||||
|
||||
+35
-2
@@ -22,6 +22,7 @@ const DOMPurify = createDOMPurify(window);
|
||||
const hljs = require('highlight.js');
|
||||
const { createEditor } = require('./editor/codemirror-setup');
|
||||
const { undo, redo } = require('@codemirror/commands');
|
||||
const { EditorView } = require('@codemirror/view');
|
||||
const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog');
|
||||
const { showPdfBatchDialog } = require('./renderer/pdf-batch-dialog');
|
||||
const { showDocumentCompareDialog } = require('./renderer/document-compare-dialog');
|
||||
@@ -2241,11 +2242,27 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const el = document.getElementById('explorer-path');
|
||||
return el ? el.value.trim() || null : null;
|
||||
},
|
||||
onOpenFile: (filePath /* , offset */) => {
|
||||
ipcRenderer.send('open-file-path', filePath);
|
||||
onOpenFile: (filePath, offset) => {
|
||||
ipcRenderer.send('open-file-path', { path: filePath, offset });
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Daily notes journal browser — lists existing YYYY-MM-DD.md files and
|
||||
// surfaces a "Today" button that creates/opens today's entry. Renders
|
||||
// paths as a placeholder string; main rewrites them on click (via the
|
||||
// file-opened IPC path, which already handles any daily-notes path).
|
||||
sidebarManager.registerPanel('daily-notes', {
|
||||
title: 'Daily Notes',
|
||||
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>',
|
||||
render: (container) =>
|
||||
require('./sidebar/daily-notes-panel').renderDailyNotesPanel(container, {
|
||||
openToday: ({ date } = {}) => ipcRenderer.invoke('daily-notes:open-today', { date }),
|
||||
listExisting: () => ipcRenderer.invoke('daily-notes:list'),
|
||||
onOpenFile: (filePath) => ipcRenderer.send('open-file-path', { path: filePath }),
|
||||
}),
|
||||
});
|
||||
sidebarManager.registerPanel('git', {
|
||||
title: 'Git',
|
||||
render: (container) =>
|
||||
@@ -2900,6 +2917,22 @@ ipcRenderer.on('file-opened', (event, data) => {
|
||||
|
||||
if (tabManager) {
|
||||
tabManager.openFile(data.path, data.content);
|
||||
// Deep-link from search/Q&A: scroll the editor to the matching offset.
|
||||
// openFile() loads the doc into the active tab; once it returns the
|
||||
// CodeMirror view exists and we can dispatch a selection.
|
||||
if (data && typeof data.offset === 'number' && data.offset > 0) {
|
||||
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||
const view = tab && tab.editorView;
|
||||
if (view) {
|
||||
const docLen = view.state.doc.length;
|
||||
const pos = Math.min(data.offset, docLen);
|
||||
view.dispatch({
|
||||
selection: { anchor: pos, head: pos },
|
||||
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('[RENDERER] tabManager not initialized!');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Sidebar panel: daily notes journal browser.
|
||||
*
|
||||
* Lists the YYYY-MM-DD.md files in <userData>/notes/daily/ newest-first,
|
||||
* lets the user click one to open it, and surfaces a "Today" button that
|
||||
* creates/opens today's note. Pure DOM module — same pattern as
|
||||
* snippets-panel.js / search-panel.js.
|
||||
*
|
||||
* @param {HTMLElement} container
|
||||
* @param {object} deps
|
||||
* @param {() => Promise<Array<{docPath:string, savedAt:number, byteSize:number, appVersion:string}>>} [deps.listPendingRecoveries]
|
||||
* @param {(args:{date?:string}) => Promise<{path:string, content:string, created:boolean}>} deps.openToday
|
||||
* @param {() => Promise<string[]>} deps.listExisting
|
||||
* @param {(filePath:string) => void} deps.onOpenFile
|
||||
* @param {(filePath:string) => Promise<void>} [deps.deleteEntry] optional — deletes a daily note
|
||||
*/
|
||||
function renderDailyNotesPanel(container, deps) {
|
||||
const { openToday, listExisting, onOpenFile } = deps;
|
||||
let entries = []; // [{ path, name }]
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="daily-notes-panel">
|
||||
<div class="daily-notes-toolbar">
|
||||
<button class="daily-notes-today-btn" id="daily-notes-today">Today</button>
|
||||
<button class="daily-notes-refresh-btn" id="daily-notes-refresh" title="Reload list">↻</button>
|
||||
</div>
|
||||
<div class="daily-notes-status" id="daily-notes-status"></div>
|
||||
<div class="daily-notes-list" id="daily-notes-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const todayBtn = container.querySelector('#daily-notes-today');
|
||||
const refreshBtn = container.querySelector('#daily-notes-refresh');
|
||||
const statusEl = container.querySelector('#daily-notes-status');
|
||||
const listEl = container.querySelector('#daily-notes-list');
|
||||
|
||||
function basename(p) {
|
||||
return String(p).split(/[/\\]/).pop();
|
||||
}
|
||||
|
||||
function statusOf(name) {
|
||||
// YYYY-MM-DD.md → "Sun, 13 Sep 2026" (or local equivalent)
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})\.md$/.exec(name);
|
||||
if (!m) return name;
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
if (Number.isNaN(d.getTime())) return name;
|
||||
return d.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
if (statusEl) statusEl.textContent = text || '';
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!listEl) return;
|
||||
if (!entries || entries.length === 0) {
|
||||
listEl.innerHTML =
|
||||
'<div class="daily-notes-empty">No daily notes yet. Press <b>Today</b> to start.</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = entries
|
||||
.map((entry, i) => {
|
||||
return `<div class="daily-notes-item" role="button" tabindex="0" data-idx="${i}">
|
||||
<div class="daily-notes-name"></div>
|
||||
<div class="daily-notes-sub"></div>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
// Set dynamic content via textContent so a malicious filename can't
|
||||
// inject HTML.
|
||||
listEl.querySelectorAll('.daily-notes-item').forEach((el) => {
|
||||
const idx = Number(el.dataset.idx);
|
||||
const entry = entries[idx];
|
||||
el.querySelector('.daily-notes-name').textContent = entry.name.replace(/\.md$/, '');
|
||||
el.querySelector('.daily-notes-sub').textContent = statusOf(entry.name);
|
||||
el.addEventListener('click', () => {
|
||||
if (onOpenFile) onOpenFile(entry.path);
|
||||
});
|
||||
el.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
el.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh({ keepStatus = false } = {}) {
|
||||
if (typeof listExisting !== 'function') {
|
||||
setStatus('Daily notes list is unavailable.');
|
||||
return;
|
||||
}
|
||||
if (!keepStatus) setStatus('Loading…');
|
||||
try {
|
||||
const list = await listExisting();
|
||||
// listExisting returns absolute paths (newest first). Pair each path
|
||||
// with its basename for display.
|
||||
const paths = Array.isArray(list) ? list : [];
|
||||
entries = paths
|
||||
.filter((p) => /\.md$/i.test(p))
|
||||
.map((p) => ({ path: p, name: basename(p) }));
|
||||
renderList();
|
||||
if (!keepStatus) setStatus(entries.length === 0 ? '' : `${entries.length} note${entries.length === 1 ? '' : 's'}`);
|
||||
} catch (err) {
|
||||
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
|
||||
renderList();
|
||||
}
|
||||
}
|
||||
|
||||
todayBtn.addEventListener('click', async () => {
|
||||
setStatus('Opening today…');
|
||||
todayBtn.disabled = true;
|
||||
try {
|
||||
const result = await openToday({});
|
||||
if (result && result.path && onOpenFile) onOpenFile(result.path);
|
||||
setStatus(result && result.created ? 'Created today.' : 'Today already exists.');
|
||||
await refresh({ keepStatus: true });
|
||||
} catch (err) {
|
||||
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
|
||||
} finally {
|
||||
todayBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
refresh();
|
||||
return {
|
||||
refresh,
|
||||
basename,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { renderDailyNotesPanel };
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*
|
||||
* Daily notes sidebar panel tests.
|
||||
*/
|
||||
const { renderDailyNotesPanel } = require('../src/sidebar/daily-notes-panel');
|
||||
|
||||
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 }),
|
||||
listExisting: deps.listExisting || jest.fn().mockResolvedValue([]),
|
||||
onOpenFile: deps.onOpenFile || jest.fn(),
|
||||
...deps,
|
||||
});
|
||||
return { container, api };
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('renderDailyNotesPanel — mounting', () => {
|
||||
test('mounts the panel structure', () => {
|
||||
const { container } = mountPanel();
|
||||
expect(container.querySelector('.daily-notes-panel')).not.toBeNull();
|
||||
expect(container.querySelector('#daily-notes-today')).not.toBeNull();
|
||||
expect(container.querySelector('#daily-notes-refresh')).not.toBeNull();
|
||||
expect(container.querySelector('#daily-notes-list')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('shows the empty-state message when there are no notes', async () => {
|
||||
const { container } = mountPanel({ listExisting: jest.fn().mockResolvedValue([]) });
|
||||
await flush();
|
||||
expect(container.querySelector('.daily-notes-empty')).not.toBeNull();
|
||||
expect(container.textContent).toMatch(/Press.*Today/);
|
||||
});
|
||||
|
||||
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',
|
||||
]),
|
||||
});
|
||||
await flush();
|
||||
const items = container.querySelectorAll('.daily-notes-item');
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].textContent).toContain('2026-09-14');
|
||||
expect(items[0].textContent).toMatch(/Sep/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDailyNotesPanel — Today button', () => {
|
||||
test('clicking Today opens (or creates) today and calls onOpenFile with the path', async () => {
|
||||
const openToday = jest.fn().mockResolvedValue({
|
||||
path: '/notes/2026-09-14.md',
|
||||
created: true,
|
||||
content: '',
|
||||
});
|
||||
const onOpenFile = jest.fn();
|
||||
const listExisting = jest.fn().mockResolvedValue(['2026-09-14.md']);
|
||||
const { container } = mountPanel({ openToday, onOpenFile, listExisting });
|
||||
await flush();
|
||||
|
||||
container.querySelector('#daily-notes-today').click();
|
||||
await flush();
|
||||
|
||||
expect(openToday).toHaveBeenCalledWith({});
|
||||
expect(onOpenFile).toHaveBeenCalledWith('/notes/2026-09-14.md');
|
||||
});
|
||||
|
||||
test('shows a "Created today." / "Today already exists." status depending on the result', async () => {
|
||||
const onOpenFile = jest.fn();
|
||||
const { container } = mountPanel({
|
||||
openToday: jest.fn().mockResolvedValue({ path: '/x.md', created: true }),
|
||||
listExisting: jest.fn().mockResolvedValue([]),
|
||||
onOpenFile,
|
||||
});
|
||||
await flush();
|
||||
container.querySelector('#daily-notes-today').click();
|
||||
await flush();
|
||||
expect(container.querySelector('#daily-notes-status').textContent).toMatch(/Created today/);
|
||||
|
||||
// Re-mount with a "not created" result
|
||||
const { container: c2 } = mountPanel({
|
||||
openToday: jest.fn().mockResolvedValue({ path: '/x.md', created: false }),
|
||||
listExisting: jest.fn().mockResolvedValue([]),
|
||||
onOpenFile,
|
||||
});
|
||||
await flush();
|
||||
c2.querySelector('#daily-notes-today').click();
|
||||
await flush();
|
||||
expect(c2.querySelector('#daily-notes-status').textContent).toMatch(/already exists/);
|
||||
});
|
||||
|
||||
test('handles openToday errors gracefully (no crash, status reports)', async () => {
|
||||
const openToday = jest.fn().mockRejectedValue(new Error('disk gone'));
|
||||
const { container } = mountPanel({ openToday });
|
||||
await flush();
|
||||
container.querySelector('#daily-notes-today').click();
|
||||
await flush();
|
||||
expect(container.querySelector('#daily-notes-status').textContent).toMatch(/disk gone/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDailyNotesPanel — click an existing entry', () => {
|
||||
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',
|
||||
]),
|
||||
});
|
||||
await flush();
|
||||
const items = container.querySelectorAll('.daily-notes-item');
|
||||
items[1].click();
|
||||
expect(onOpenFile).toHaveBeenCalledWith('/notes/daily/2026-09-10.md');
|
||||
});
|
||||
|
||||
test('Enter / Space on a focused row opens it', async () => {
|
||||
const onOpenFile = jest.fn();
|
||||
const { container } = mountPanel({
|
||||
onOpenFile,
|
||||
listExisting: jest.fn().mockResolvedValue(['/notes/daily/2026-09-14.md']),
|
||||
});
|
||||
await flush();
|
||||
const row = container.querySelector('.daily-notes-item');
|
||||
row.focus();
|
||||
row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await flush();
|
||||
expect(onOpenFile).toHaveBeenCalledWith('/notes/daily/2026-09-14.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDailyNotesPanel — refresh', () => {
|
||||
test('refresh button re-fetches the list', async () => {
|
||||
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);
|
||||
|
||||
api.refresh();
|
||||
await flush();
|
||||
expect(container.querySelectorAll('.daily-notes-item')).toHaveLength(1);
|
||||
expect(listExisting).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDailyNotesPanel — graceful degradation', () => {
|
||||
test('shows an error when listExisting rejects', async () => {
|
||||
const listExisting = jest.fn().mockRejectedValue(new Error('ENOENT'));
|
||||
const { container } = mountPanel({ listExisting });
|
||||
await flush();
|
||||
expect(container.querySelector('#daily-notes-status').textContent).toMatch(/ENOENT/);
|
||||
});
|
||||
|
||||
test('shows an error when listExisting is not provided', async () => {
|
||||
const { container } = mountPanel({ listExisting: null });
|
||||
await flush();
|
||||
expect(container.querySelector('#daily-notes-status').textContent).toMatch(/unavailable/);
|
||||
});
|
||||
|
||||
test('filters out non-md entries', async () => {
|
||||
const { container } = mountPanel({
|
||||
listExisting: jest.fn().mockResolvedValue(['2026-09-14.md', 'readme.txt', '.DS_Store']),
|
||||
});
|
||||
await flush();
|
||||
expect(container.querySelectorAll('.daily-notes-item')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user