Changeset 0.17.3 (#78)

This commit is contained in:
2026-02-28 15:20:23 +00:00
parent a008dac488
commit 12e316c234
29 changed files with 3347 additions and 65 deletions

View File

@@ -7,6 +7,7 @@ var _editingNoteId = null;
var _notesSelectMode = false;
var _selectedNoteIds = new Set();
var _notesSort = 'updated_desc';
var _noteEditor = null; // CM6 noteEditor instance
// ── Notes ─────────────────────────────────────
@@ -172,7 +173,13 @@ async function loadNoteFolders() {
function showNotesList() {
document.getElementById('notesListView').style.display = '';
document.getElementById('notesEditorView').style.display = 'none';
const graphView = document.getElementById('notesGraphView');
if (graphView) graphView.style.display = 'none';
_editingNoteId = null;
_destroyNoteEditor();
// Reset list to loading state to prevent stale content flash
const list = document.getElementById('notesList');
if (list) list.innerHTML = '<div class="notes-loading">Loading…</div>';
}
var _currentNote = null; // cached note for read mode
@@ -211,14 +218,83 @@ function _populateEditFields(note) {
document.getElementById('noteEditorTitle').value = note.title || '';
document.getElementById('noteEditorFolder').value = note.folder_path || '';
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
document.getElementById('noteEditorContent').value = note.content || '';
_setNoteEditorContent(note.content || '');
}
function _clearEditFields() {
document.getElementById('noteEditorTitle').value = '';
document.getElementById('noteEditorFolder').value = '';
document.getElementById('noteEditorTags').value = '';
document.getElementById('noteEditorContent').value = '';
_setNoteEditorContent('');
}
/** Get content from CM6 editor or fallback textarea */
function _getNoteEditorContent() {
if (_noteEditor) return _noteEditor.getValue();
const ta = document.querySelector('#noteEditorContentContainer textarea');
return ta ? ta.value : '';
}
/** Set content in CM6 editor or fallback textarea */
function _setNoteEditorContent(text) {
if (_noteEditor) {
_noteEditor.setValue(text);
return;
}
// Lazy-init CM6 note editor
const container = document.getElementById('noteEditorContentContainer');
if (!container) return;
if (window.CM?.noteEditor) {
container.innerHTML = ''; // clear any previous
_noteEditor = CM.noteEditor(container, {
value: text,
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
onChange: null, // could wire auto-save later
onLink: (title) => {
_navigateToLinkedNote(title);
},
linkCompleter: async (query) => {
if (!query || query.length < 1) return [];
try {
const resp = await API.searchNoteTitles(query, 8);
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
} catch { return []; }
},
});
} else {
// Fallback: plain textarea
if (!container.querySelector('textarea')) {
container.innerHTML = '<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown..."></textarea>';
}
const ta = container.querySelector('textarea');
if (ta) ta.value = text;
}
}
/** Navigate to a note by title (from wikilink click) */
async function _navigateToLinkedNote(title) {
try {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (match) {
await openNoteEditor(match.id);
} else {
// Offer to create
if (await showConfirm(`Note "${title}" not found. Create it?`)) {
const created = await API.createNote(title, `# ${title}\n\n`, '/', []);
await openNoteEditor(created.id);
}
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
}
/** Destroy CM6 editor on panel close */
function _destroyNoteEditor() {
if (_noteEditor) {
_noteEditor.destroy();
_noteEditor = null;
}
}
function _showNoteReadMode() {
@@ -239,6 +315,9 @@ function _showNoteReadMode() {
noteReadEl.innerHTML = formatMessage(n.content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
// Render [[wikilinks]] as clickable chips in read mode
_renderWikilinksInReadMode(noteReadEl);
// Show/hide delete
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
@@ -247,6 +326,12 @@ function _showNoteReadMode() {
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
// Load backlinks
if (_editingNoteId) _loadBacklinks(_editingNoteId);
// Show daily note navigation if applicable
_updateDailyNav(_currentNote);
}
function _showNoteEditMode() {
@@ -259,9 +344,9 @@ function _showNoteEditMode() {
}
function _showNotePreview() {
// Live preview from textarea without saving
// Live preview from editor without saving
const title = document.getElementById('noteEditorTitle').value.trim();
const content = document.getElementById('noteEditorContent').value;
const content = _getNoteEditorContent();
const folderVal = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
@@ -273,6 +358,7 @@ function _showNotePreview() {
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(document.getElementById('noteReadContent'));
_renderWikilinksInReadMode(document.getElementById('noteReadContent'));
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
@@ -284,7 +370,7 @@ function _showNotePreview() {
async function saveNote() {
const title = document.getElementById('noteEditorTitle').value.trim();
const content = document.getElementById('noteEditorContent').value;
const content = _getNoteEditorContent();
const folder = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
@@ -305,6 +391,8 @@ async function saveNote() {
UI.toast('Note created', 'success');
_showNoteReadMode();
}
// Invalidate graph cache
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
} catch (e) { UI.toast(e.message, 'error'); }
}
@@ -314,17 +402,356 @@ async function deleteNote() {
try {
await API.deleteNote(_editingNoteId);
UI.toast('Note deleted', 'success');
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
showNotesList();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Backlinks ───────────────────
async function _loadBacklinks(noteId) {
const container = document.getElementById('noteBacklinks');
const listEl = document.getElementById('noteBacklinksList');
const countEl = document.getElementById('noteBacklinksCount');
if (!container || !listEl || !countEl) return;
try {
const resp = await API.getNoteBacklinks(noteId);
const links = resp.data || [];
countEl.textContent = links.length;
container.style.display = links.length > 0 ? '' : 'none';
listEl.innerHTML = links.map(n => `
<div class="note-backlink-item" onclick="openNoteEditor('${n.id}')">
<span class="note-backlink-title">${esc(n.title)}</span>
<span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span>
</div>
`).join('');
} catch {
container.style.display = 'none';
}
}
function toggleBacklinks() {
const list = document.getElementById('noteBacklinksList');
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
}
// ── Wikilink Read-Mode Rendering ──
function _renderWikilinksInReadMode(containerEl) {
if (!containerEl) return;
const html = containerEl.innerHTML;
let transclusionId = 0;
// Replace [[Title|display]] and [[Title]] with clickable spans
// For ![[transclusion]], render a placeholder that gets filled async
const rendered = html.replace(
/(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
(match, bang, title, display) => {
const titleTrimmed = title.trim();
const displayText = display ? display.trim() : titleTrimmed;
if (bang === '!') {
const tid = `transclusion-${++transclusionId}`;
return `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}">
<div class="transclusion-header">
<span class="wikilink-chip wikilink-transclusion" onclick="_navigateToLinkedNote('${esc(titleTrimmed)}')" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
</div>
<div class="transclusion-content"><span class="text-muted">Loading…</span></div>
</div>`;
}
return `<span class="wikilink-chip" onclick="_navigateToLinkedNote('${esc(titleTrimmed)}')" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
}
);
containerEl.innerHTML = rendered;
// Async: resolve and embed transclusion content
_resolveTransclusions(containerEl);
}
/** Fetch content for each ![[embed]] placeholder — max depth 1 (no recursive transclusion) */
async function _resolveTransclusions(containerEl) {
const embeds = containerEl.querySelectorAll('.transclusion-embed');
if (!embeds.length) return;
// Cache to avoid duplicate fetches in same note
const cache = {};
for (const el of embeds) {
const title = el.dataset.title;
const contentEl = el.querySelector('.transclusion-content');
if (!title || !contentEl) continue;
try {
let note = cache[title.toLowerCase()];
if (!note) {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (!match) {
contentEl.innerHTML = '<span class="text-muted">Note not found</span>';
continue;
}
note = await API.getNote(match.id);
cache[title.toLowerCase()] = note;
}
// Render embedded content (no recursive transclusion — strip ![[]] )
const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
return `[Embedded: ${inner}]`;
});
contentEl.innerHTML = formatMessage(safeContent);
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(contentEl);
} catch {
contentEl.innerHTML = '<span class="text-muted">Failed to load</span>';
}
}
}
// ── Daily Notes ─────────────────
async function openDailyNote() {
const today = new Date().toISOString().slice(0, 10);
const title = `Daily — ${today}`;
const folder = '/daily/';
try {
const resp = await API.searchNoteTitles(title, 1);
const existing = (resp.data || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await openNoteEditor(existing.id);
} else {
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, folder, ['daily']);
await openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to open daily note: ' + e.message, 'error'); }
}
/** Detect if current note is a daily note */
function _isDailyNote(note) {
return note && /^Daily — \d{4}-\d{2}-\d{2}$/.test(note.title);
}
/** Show prev/next nav for daily notes */
function _updateDailyNav(note) {
let nav = document.getElementById('noteDailyNav');
if (!_isDailyNote(note)) {
if (nav) nav.style.display = 'none';
return;
}
// Ensure nav element exists
if (!nav) {
const toolbar = document.querySelector('.notes-editor-toolbar');
if (!toolbar) return;
nav = document.createElement('div');
nav.id = 'noteDailyNav';
nav.className = 'note-daily-nav';
nav.innerHTML = `
<button class="btn-small" id="dailyPrevBtn" title="Previous day"> Prev</button>
<span class="note-daily-date" id="dailyDateLabel"></span>
<button class="btn-small" id="dailyNextBtn" title="Next day">Next </button>
`;
toolbar.after(nav);
document.getElementById('dailyPrevBtn')?.addEventListener('click', () => _navigateDaily(-1));
document.getElementById('dailyNextBtn')?.addEventListener('click', () => _navigateDaily(1));
}
nav.style.display = '';
const dateStr = note.title.replace('Daily — ', '');
document.getElementById('dailyDateLabel').textContent = dateStr;
// Disable "Next" if it's today or future
const today = new Date().toISOString().slice(0, 10);
document.getElementById('dailyNextBtn').disabled = dateStr >= today;
}
/** Navigate to prev/next daily note */
async function _navigateDaily(offset) {
if (!_currentNote || !_isDailyNote(_currentNote)) return;
const dateStr = _currentNote.title.replace('Daily — ', '');
const d = new Date(dateStr + 'T12:00:00'); // noon to avoid DST edge
d.setDate(d.getDate() + offset);
const newDateStr = d.toISOString().slice(0, 10);
const title = `Daily — ${newDateStr}`;
try {
const resp = await API.searchNoteTitles(title, 1);
const existing = (resp.data || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await openNoteEditor(existing.id);
} else {
// Create the daily note for that day
const content = `# ${newDateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, '/daily/', ['daily']);
await openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
}
// ── Save-to-Note (from chat messages) ──
async function saveMessageToNote(msgIndex) {
const chat = App.chats?.find(c => c.id === App.currentChatId);
const msg = chat?.messages?.[msgIndex];
if (!msg) return;
// Check for text selection within the message element
const sel = window.getSelection();
let content = msg.content;
const msgId = msg.id || '';
const msgEl = msgId ? document.querySelector(`.message[data-msg-id="${msgId}"] .msg-text`) : null;
if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
const selected = sel.toString().trim();
if (selected) content = selected;
}
// Extract title from first line
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
const defaultTitle = firstLine.slice(0, 60) || 'Chat excerpt';
// Show save-to-note modal
_showSaveToNoteModal({
content,
sourceChannelId: App.currentChatId || '',
sourceMessageId: msg.id || '',
defaultTitle,
});
}
/** Modal for save-to-note with create/append options */
function _showSaveToNoteModal(opts) {
// Remove existing modal if any
document.getElementById('saveToNoteModal')?.remove();
const modal = document.createElement('div');
modal.id = 'saveToNoteModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content save-to-note-modal">
<h3>Save to Note</h3>
<div class="save-note-mode-toggle">
<label><input type="radio" name="saveNoteMode" value="create" checked> Create new note</label>
<label><input type="radio" name="saveNoteMode" value="append"> Append to existing</label>
</div>
<div id="saveNoteCreateFields">
<div class="form-group">
<input type="text" id="saveNoteTitle" placeholder="Title" value="${esc(opts.defaultTitle)}" class="notes-title-input">
</div>
<div class="form-row">
<div class="form-group"><input type="text" id="saveNoteFolder" placeholder="Folder (e.g. /work)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="saveNoteTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
</div>
<div id="saveNoteAppendFields" style="display:none">
<div class="form-group">
<input type="text" id="saveNoteSearch" placeholder="Search notes to append to..." class="notes-title-input" autocomplete="off">
<div id="saveNoteSearchResults" class="save-note-search-results"></div>
</div>
</div>
<div class="form-group">
<label class="text-muted" style="font-size:12px">Preview (${opts.content.length} chars)</label>
<div class="save-note-preview msg-text">${formatMessage(opts.content.slice(0, 300))}${opts.content.length > 300 ? '…' : ''}</div>
</div>
<div class="modal-actions">
<button class="btn-small" id="saveNoteCancelBtn">Cancel</button>
<button class="btn-small btn-primary" id="saveNoteConfirmBtn">Create</button>
</div>
</div>
`;
document.body.appendChild(modal);
let selectedAppendNote = null;
// Toggle create vs append
modal.querySelectorAll('input[name="saveNoteMode"]').forEach(radio => {
radio.addEventListener('change', (e) => {
const isAppend = e.target.value === 'append';
document.getElementById('saveNoteCreateFields').style.display = isAppend ? 'none' : '';
document.getElementById('saveNoteAppendFields').style.display = isAppend ? '' : 'none';
document.getElementById('saveNoteConfirmBtn').textContent = isAppend ? 'Append' : 'Create';
selectedAppendNote = null;
document.getElementById('saveNoteSearchResults').innerHTML = '';
});
});
// Append search with debounce
let _searchTimer;
document.getElementById('saveNoteSearch')?.addEventListener('input', (e) => {
clearTimeout(_searchTimer);
const q = e.target.value.trim();
selectedAppendNote = null;
_searchTimer = setTimeout(async () => {
const resultsEl = document.getElementById('saveNoteSearchResults');
if (q.length < 2) { resultsEl.innerHTML = ''; return; }
try {
const resp = await API.searchNoteTitles(q, 8);
const notes = resp.data || [];
resultsEl.innerHTML = notes.map(n =>
`<div class="save-note-result" data-id="${n.id}" data-title="${esc(n.title)}">${esc(n.title)}</div>`
).join('') || '<div class="text-muted" style="padding:6px">No results</div>';
resultsEl.querySelectorAll('.save-note-result').forEach(el => {
el.addEventListener('click', () => {
resultsEl.querySelectorAll('.save-note-result').forEach(r => r.classList.remove('selected'));
el.classList.add('selected');
selectedAppendNote = { id: el.dataset.id, title: el.dataset.title };
document.getElementById('saveNoteSearch').value = el.dataset.title;
});
});
} catch { resultsEl.innerHTML = '<div class="text-muted" style="padding:6px">Search failed</div>'; }
}, 250);
});
// Cancel
document.getElementById('saveNoteCancelBtn').addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
// Confirm
document.getElementById('saveNoteConfirmBtn').addEventListener('click', async () => {
const isAppend = modal.querySelector('input[name="saveNoteMode"]:checked')?.value === 'append';
try {
if (isAppend) {
if (!selectedAppendNote) { UI.toast('Select a note to append to', 'warning'); return; }
await API.updateNote(selectedAppendNote.id, {
content: '\n\n---\n\n' + opts.content,
mode: 'append',
});
UI.toast(`Appended to "${selectedAppendNote.title}"`, 'success');
} else {
const title = document.getElementById('saveNoteTitle').value.trim();
if (!title) { UI.toast('Title is required', 'warning'); return; }
const folder = document.getElementById('saveNoteFolder').value.trim();
const tagsStr = document.getElementById('saveNoteTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
await API.createNote(title, opts.content, folder, tags, opts.sourceChannelId, opts.sourceMessageId);
UI.toast('Note created', 'success');
}
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
modal.remove();
} catch (e) { UI.toast(e.message, 'error'); }
});
// Focus title
document.getElementById('saveNoteTitle')?.focus();
document.getElementById('saveNoteTitle')?.select();
}
// ── Notes Listeners (extracted from initListeners) ──
function _initNotesListeners() {
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
document.getElementById('notesGraphBtn')?.addEventListener('click', () => {
if (typeof openNoteGraph === 'function') openNoteGraph();
});
document.getElementById('notesTodayBtn')?.addEventListener('click', openDailyNote);
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
if (_notesSelectMode) _exitSelectMode();
else _enterSelectMode();
@@ -334,7 +761,6 @@ function _initNotesListeners() {
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode);
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }