/**
* Notes — Surface Entry Point (v0.5.0)
*
* Markdown notes surface using the SDK:
* sw.api.ext('notes') — scoped API client
* sw.ui.* — primitive components
* sw.shell.Topbar — navigation bar
* window.CM — CodeMirror 6 bundle (optional)
*/
(async function () {
'use strict';
var mount = document.getElementById('extension-mount');
if (!mount) return;
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
// ── Dynamic Script Loader ────────────────────
function _loadScript(src) {
return new Promise(function(resolve, reject) {
if (document.querySelector('script[src*="' + src.split('?')[0] + '"]')) {
resolve();
return;
}
var s = document.createElement('script');
s.src = base + src;
s.onload = resolve;
s.onerror = function() { resolve(); }; // Non-fatal
document.head.appendChild(s);
});
}
// ── Boot SDK ───────────────────────────────
try {
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
mount.innerHTML = '
SDK boot failed: ' + e.message + '
';
return;
}
// ── Load CM6 bundle (optional — falls back to textarea) ──
await _loadScript('/vendor/codemirror/codemirror.bundle.js?v=' + ver);
var hasCM = !!(window.CM && window.CM.noteEditor);
var { html } = window;
var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
var { render } = preact;
// ── SDK modules ────────────────────────────
var api = sw.api.ext('notes');
var { Button, Spinner } = sw.ui;
var Topbar = sw.shell.Topbar;
// ── Debounce helper ────────────────────────
var _timers = {};
function debounce(key, fn, ms) {
clearTimeout(_timers[key]);
_timers[key] = setTimeout(fn, ms);
}
// ═══════════════════════════════════════════
// Markdown renderer (inline, no deps)
// ═══════════════════════════════════════════
function renderMarkdown(src) {
if (!src) return '';
var lines = src.split('\n');
var out = [];
var inCode = false;
var inList = false;
var listTag = '';
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// fenced code blocks
if (line.trim().startsWith('```')) {
if (inCode) {
out.push('');
inCode = false;
} else {
if (inList) { out.push('' + listTag + '>'); inList = false; }
out.push('');
inCode = true;
}
continue;
}
if (inCode) {
out.push(escHtml(line) + '\n');
continue;
}
// blank line
if (!line.trim()) {
if (inList) { out.push('' + listTag + '>'); inList = false; }
continue;
}
// headings
if (line.startsWith('### ')) { closeList(); out.push('' + inline(line.slice(4)) + '
'); continue; }
if (line.startsWith('## ')) { closeList(); out.push('' + inline(line.slice(3)) + '
'); continue; }
if (line.startsWith('# ')) { closeList(); out.push('' + inline(line.slice(2)) + '
'); continue; }
// hr
if (/^[-*_]{3,}\s*$/.test(line)) { closeList(); out.push('
'); continue; }
// blockquote
if (line.startsWith('> ')) { closeList(); out.push('' + inline(line.slice(2)) + '
'); continue; }
// unordered list
if (/^[\-*+]\s/.test(line)) {
if (!inList || listTag !== 'ul') { closeList(); out.push(''); inList = true; listTag = 'ul'; }
out.push('- ' + inline(line.replace(/^[\-*+]\s/, '')) + '
');
continue;
}
// ordered list
if (/^\d+\.\s/.test(line)) {
if (!inList || listTag !== 'ol') { closeList(); out.push(''); inList = true; listTag = 'ol'; }
out.push('- ' + inline(line.replace(/^\d+\.\s/, '')) + '
');
continue;
}
// paragraph
closeList();
out.push('' + inline(line) + '
');
}
if (inCode) out.push('
');
if (inList) out.push('' + listTag + '>');
return out.join('\n');
function closeList() { if (inList) { out.push('' + listTag + '>'); inList = false; } }
}
function escHtml(s) {
return s.replace(/&/g, '&').replace(//g, '>');
}
function inline(s) {
s = escHtml(s);
// inline code
s = s.replace(/`([^`]+)`/g, '$1');
// bold
s = s.replace(/\*\*(.+?)\*\*/g, '$1');
s = s.replace(/__(.+?)__/g, '$1');
// italic
s = s.replace(/\*(.+?)\*/g, '$1');
s = s.replace(/_(.+?)_/g, '$1');
// links
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
// wikilinks: [[Note Title]] → clickable internal link
s = s.replace(/\[\[([^\]]+)\]\]/g, function(match, linkText) {
return '' + linkText.trim() + '';
});
return s;
}
// ═══════════════════════════════════════════
// TagInput — editor tag management
// ═══════════════════════════════════════════
function TagInput({ tags, allTags, onChange }) {
var [input, setInput] = useState('');
var [showAC, setShowAC] = useState(false);
var inputRef = useRef(null);
var suggestions = useMemo(function() {
if (!input.trim()) return [];
var q = input.trim().toLowerCase();
return (allTags || []).filter(function(t) {
return t.toLowerCase().indexOf(q) !== -1 && (tags || []).indexOf(t) === -1;
}).slice(0, 8);
}, [input, allTags, tags]);
function addTag(tag) {
var t = tag.trim().toLowerCase();
if (!t) return;
var current = tags || [];
if (current.indexOf(t) === -1) {
onChange(current.concat([t]));
}
setInput('');
setShowAC(false);
if (inputRef.current) inputRef.current.focus();
}
function removeTag(tag) {
onChange((tags || []).filter(function(t) { return t !== tag; }));
}
function handleKeyDown(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
if (input.trim()) addTag(input);
}
if (e.key === 'Backspace' && !input && tags && tags.length > 0) {
removeTag(tags[tags.length - 1]);
}
if (e.key === 'Escape') {
setShowAC(false);
}
}
function handleInput(e) {
var val = e.target.value.replace(',', '');
setInput(val);
setShowAC(val.trim().length > 0);
}
return html`
`;
}
// ═══════════════════════════════════════════
// TagFilter — sidebar tag filter
// ═══════════════════════════════════════════
function TagFilter({ tags, activeTag, onSelect }) {
if (!tags || tags.length === 0) return null;
return html`
Tags
${activeTag && html`
`}
${tags.map(function(tag) {
return html`
${tag}
`;
})}
`;
}
// ═══════════════════════════════════════════
// NoteCard — sidebar list item (draggable)
// ═══════════════════════════════════════════
function NoteCard({ note, active, onClick }) {
var [dragging, setDragging] = useState(false);
var dateStr = note.updated_at || note.created_at || '';
if (dateStr) {
try { dateStr = new Date(dateStr).toLocaleDateString(); } catch(e) { /* keep raw */ }
}
var tags = note.tags || [];
var showTags = tags.slice(0, 3);
var overflow = tags.length - 3;
function handleDragStart(e) {
e.dataTransfer.setData('text/plain', note.id);
e.dataTransfer.effectAllowed = 'move';
setDragging(true);
}
function handleDragEnd() {
setDragging(false);
}
return html`
${note.pinned ? html`📌` : null}
${note.title || 'Untitled'}
${note.snippet && html`
${note.snippet}
`}
${showTags.length > 0 && html`
${showTags.map(function(t) { return html`${t}`; })}
${overflow > 0 && html`+${overflow}`}
`}
${dateStr && html`
${dateStr}
`}
`;
}
// ═══════════════════════════════════════════
// FolderContextMenu — right-click menu
// ═══════════════════════════════════════════
function FolderContextMenu({ x, y, onRename, onDelete, onAddSub, onClose }) {
var menuRef = useRef(null);
useEffect(function() {
function handleClick(e) {
if (menuRef.current && !menuRef.current.contains(e.target)) onClose();
}
document.addEventListener('mousedown', handleClick);
return function() { document.removeEventListener('mousedown', handleClick); };
}, [onClose]);
return html`
`;
}
// ═══════════════════════════════════════════
// FolderNode — recursive tree node (drop target)
// ═══════════════════════════════════════════
function FolderNode({ node, depth, activeFolderId, onSelect, onRename, onDelete, onCreateSub, onDropNote }) {
var [expanded, setExpanded] = useState(true);
var [editing, setEditing] = useState(false);
var [editName, setEditName] = useState(node.folder.name);
var [dropOver, setDropOver] = useState(false);
var [menu, setMenu] = useState(null);
var hasChildren = node.children.length > 0;
var isActive = activeFolderId === node.folder.id;
var indent = 20 + depth * 16;
function handleToggle(e) {
e.stopPropagation();
setExpanded(!expanded);
}
function handleRename() {
if (editName.trim() && editName !== node.folder.name) {
onRename(node.folder.id, editName.trim());
}
setEditing(false);
}
function handleContext(e) {
e.preventDefault();
e.stopPropagation();
setMenu({ x: e.clientX, y: e.clientY });
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDropOver(true);
}
function handleDragLeave() { setDropOver(false); }
function handleDrop(e) {
e.preventDefault();
setDropOver(false);
var noteId = e.dataTransfer.getData('text/plain');
if (noteId && onDropNote) onDropNote(noteId, node.folder.id);
}
return html`
`;
}
// ═══════════════════════════════════════════
// FolderTree — navigation panel (drop targets)
// ═══════════════════════════════════════════
function FolderTree({ folders, activeFolderId, showUnfiled, onSelectFolder, onSelectAll, onSelectUnfiled, onCreateFolder, onRenameFolder, onDeleteFolder, onDropNote }) {
var [dropAll, setDropAll] = useState(false);
var [dropUnfiled, setDropUnfiled] = useState(false);
// build tree from flat list
var tree = useMemo(function() {
var byId = {};
var roots = [];
for (var i = 0; i < folders.length; i++) {
byId[folders[i].id] = { folder: folders[i], children: [] };
}
for (var i = 0; i < folders.length; i++) {
var f = folders[i];
var node = byId[f.id];
if (f.parent_id && byId[f.parent_id]) {
byId[f.parent_id].children.push(node);
} else {
roots.push(node);
}
}
function sortNodes(arr) {
arr.sort(function(a, b) {
if (a.folder.sort_order !== b.folder.sort_order) return a.folder.sort_order - b.folder.sort_order;
return (a.folder.name || '').localeCompare(b.folder.name || '');
});
for (var j = 0; j < arr.length; j++) sortNodes(arr[j].children);
}
sortNodes(roots);
return roots;
}, [folders]);
// drop handlers for "All Notes" / "Unfiled" (move to unfiled = folder_id "")
function makeDropHandlers(setOver) {
return {
onDragOver: function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setOver(true); },
onDragLeave: function() { setOver(false); },
onDrop: function(e) {
e.preventDefault(); setOver(false);
var noteId = e.dataTransfer.getData('text/plain');
if (noteId && onDropNote) onDropNote(noteId, '');
}
};
}
var allDrop = makeDropHandlers(setDropAll);
var unfiledDrop = makeDropHandlers(setDropUnfiled);
return html`
📋
All Notes
📄
Unfiled
${tree.map(function(node) {
return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0}
activeFolderId=${activeFolderId}
onSelect=${onSelectFolder} onRename=${onRenameFolder}
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder}
onDropNote=${onDropNote} />`;
})}
+ New Folder
`;
}
// ═══════════════════════════════════════════
// BacklinksPanel — incoming wikilinks
// ═══════════════════════════════════════════
function BacklinksPanel({ noteId, onNavigate }) {
var [backlinks, setBacklinks] = useState([]);
var [collapsed, setCollapsed] = useState(false);
useEffect(function() {
if (!noteId) { setBacklinks([]); return; }
api.get('/backlinks/' + noteId).then(function(res) {
var items = (res && res.data) || res || [];
setBacklinks(Array.isArray(items) ? items : []);
}).catch(function() { setBacklinks([]); });
}, [noteId]);
if (!backlinks.length) return null;
return html`
${!collapsed && backlinks.map(function(bl) {
return html`
${bl.source_title}
via [[${bl.link_text}]]
`;
})}
`;
}
// ═══════════════════════════════════════════
// EditorPane — note editor + preview + tags
// ═══════════════════════════════════════════
// ═══════════════════════════════════════════
// Export / Import helpers
// ═══════════════════════════════════════════
function exportNoteAsMarkdown(note, tags) {
var parts = ['---'];
parts.push('title: ' + (note.title || 'Untitled'));
if (tags && tags.length) parts.push('tags: [' + tags.join(', ') + ']');
if (note.created_at) parts.push('created: ' + note.created_at);
parts.push('---');
parts.push('');
parts.push(note.body || '');
var text = parts.join('\n');
var blob = new Blob([text], { type: 'text/markdown;charset=utf-8' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = (note.title || 'Untitled').replace(/[^a-zA-Z0-9_\- ]/g, '').trim() + '.md';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function parseFrontmatter(text) {
var result = { title: '', tags: [], body: text };
if (!text.startsWith('---')) return result;
var end = text.indexOf('\n---', 3);
if (end === -1) return result;
var fm = text.substring(3, end).trim();
var bodyStart = end + 4;
// skip leading newline after frontmatter
if (text[bodyStart] === '\n') bodyStart++;
result.body = text.substring(bodyStart);
var lines = fm.split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (line.startsWith('title:')) {
result.title = line.substring(6).trim().replace(/^["']|["']$/g, '');
} else if (line.startsWith('tags:')) {
var tagStr = line.substring(5).trim();
// parse [tag1, tag2] or tag1, tag2
tagStr = tagStr.replace(/^\[|\]$/g, '');
result.tags = tagStr.split(',').map(function(t) { return t.trim().toLowerCase(); }).filter(Boolean);
}
}
return result;
}
function EditorPane({ note, folders, allTags, onSave, onDelete, onRefresh, onTagsChanged, onNavigate, onNavigateToTitle }) {
var [title, setTitle] = useState(note ? note.title : '');
var [body, setBody] = useState(note ? note.body : '');
var [noteTags, setNoteTags] = useState([]);
var [preview, setPreview] = useState(false);
var [dirty, setDirty] = useState(false);
var [saving, setSaving] = useState(false);
var [saveCount, setSaveCount] = useState(0);
var textareaRef = useRef(null);
var cmContainerRef = useRef(null);
var cmEditorRef = useRef(null);
// keep mutable refs for the latest title/body so CM6 callbacks see current values
var titleRef = useRef(title);
var bodyRef = useRef(body);
titleRef.current = title;
bodyRef.current = body;
// sync when note changes
useEffect(function() {
if (note) {
setTitle(note.title || '');
setBody(note.body || '');
titleRef.current = note.title || '';
bodyRef.current = note.body || '';
setNoteTags(note.tags || []);
setDirty(false);
setPreview(false);
// update CM6 content if editor exists
if (cmEditorRef.current) {
cmEditorRef.current.setValue(note.body || '');
}
}
}, [note ? note.id : null]);
// ── CM6 lifecycle ─────────────────────────
useEffect(function() {
if (!hasCM || preview || !note) return;
var el = cmContainerRef.current;
if (!el) return;
// destroy previous
if (cmEditorRef.current) {
cmEditorRef.current.destroy();
cmEditorRef.current = null;
}
var editor = CM.noteEditor(el, {
value: bodyRef.current,
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
onChange: function(text) {
bodyRef.current = text;
setBody(text);
setDirty(true);
debounce('save', function() { doSave(titleRef.current, text); }, 1000);
},
onLink: function(linkTitle) {
if (onNavigateToTitle) onNavigateToTitle(linkTitle);
},
linkCompleter: async function(query) {
try {
var res = await api.get('/search?q=' + encodeURIComponent(query));
var items = (res && res.data) || res || [];
if (!Array.isArray(items)) items = [];
return items.map(function(n) { return { label: n.title, id: n.id }; });
} catch (e) { return []; }
},
});
cmEditorRef.current = editor;
// Ctrl/Cmd+S via CM6 keymap injection
var view = editor.getView();
if (view) {
var handleSaveKey = function(e) {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
doSave(titleRef.current, bodyRef.current);
}
};
view.dom.addEventListener('keydown', handleSaveKey);
}
editor.focus();
return function() {
if (cmEditorRef.current) {
cmEditorRef.current.destroy();
cmEditorRef.current = null;
}
};
}, [note ? note.id : null, preview]);
function handleTitleChange(e) {
setTitle(e.target.value);
titleRef.current = e.target.value;
setDirty(true);
debounce('save', function() { doSave(e.target.value, bodyRef.current); }, 1000);
}
function handleBodyChange(e) {
setBody(e.target.value);
bodyRef.current = e.target.value;
setDirty(true);
debounce('save', function() { doSave(titleRef.current, e.target.value); }, 1000);
}
async function doSave(t, b) {
if (!note) return;
setSaving(true);
try {
await api.put('/notes/' + note.id, { title: t || 'Untitled', body: b });
setDirty(false);
setSaveCount(function(c) { return c + 1; });
onRefresh();
} catch (e) {
console.error('Save failed:', e);
} finally {
setSaving(false);
}
}
async function handleTagsChange(newTags) {
if (!note) return;
setNoteTags(newTags);
try {
await api.put('/tags/' + note.id, { tags: newTags });
if (onTagsChanged) onTagsChanged();
} catch (e) {
console.error('Save tags failed:', e);
}
}
async function handlePin() {
if (!note) return;
await api.put('/notes/' + note.id, { pinned: note.pinned ? 0 : 1 });
onRefresh();
}
async function handleArchive() {
if (!note || !confirm('Archive this note?')) return;
await api.del('/notes/' + note.id);
onDelete();
}
function handleExport() {
if (!note) return;
exportNoteAsMarkdown(note, noteTags);
}
function handleKeyDown(e) {
// Tab inserts two spaces
if (e.key === 'Tab') {
e.preventDefault();
var ta = e.target;
var start = ta.selectionStart;
var end = ta.selectionEnd;
var val = ta.value;
ta.value = val.substring(0, start) + ' ' + val.substring(end);
ta.selectionStart = ta.selectionEnd = start + 2;
setBody(ta.value);
bodyRef.current = ta.value;
setDirty(true);
debounce('save', function() { doSave(title, ta.value); }, 1000);
}
// Ctrl/Cmd+S force save
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
doSave(title, body);
}
}
// ── Outgoing links for resolved/unresolved styling ──
var [outgoingLinks, setOutgoingLinks] = useState([]);
useEffect(function() {
if (!note) { setOutgoingLinks([]); return; }
api.get('/links/' + note.id).then(function(res) {
var items = (res && res.data) || res || [];
setOutgoingLinks(Array.isArray(items) ? items : []);
}).catch(function() { setOutgoingLinks([]); });
}, [note ? note.id : null, saveCount]);
var previewHtml = useMemo(function() {
var h = renderMarkdown(body);
// mark unresolved wikilinks
for (var i = 0; i < outgoingLinks.length; i++) {
var link = outgoingLinks[i];
if (!link.target_id) {
var search = 'class="wikilink" data-wikilink="' + escHtml(link.link_text) + '"';
var replace = 'class="wikilink wikilink--unresolved" data-wikilink="' + escHtml(link.link_text) + '"';
h = h.split(search).join(replace);
}
}
return h;
}, [body, outgoingLinks]);
// ── Wikilink click handling in preview ──
var previewRef = useRef(null);
useEffect(function() {
var el = previewRef.current;
if (!el || !preview) return;
function handleClick(e) {
var target = e.target;
if (target.classList && target.classList.contains('wikilink')) {
e.preventDefault();
var linkText = target.getAttribute('data-wikilink');
if (linkText && onNavigateToTitle) onNavigateToTitle(linkText);
}
}
el.addEventListener('click', handleClick);
return function() { el.removeEventListener('click', handleClick); };
}, [preview, onNavigateToTitle]);
// build indented folder list for the select dropdown
var folderOptions = useMemo(function() {
var list = folders || [];
var byId = {};
var roots = [];
for (var i = 0; i < list.length; i++) {
byId[list[i].id] = { folder: list[i], children: [] };
}
for (var i = 0; i < list.length; i++) {
var f = list[i];
if (f.parent_id && byId[f.parent_id]) {
byId[f.parent_id].children.push(byId[f.id]);
} else {
roots.push(byId[f.id]);
}
}
var flat = [];
function walk(nodes, depth) {
nodes.sort(function(a, b) {
if (a.folder.sort_order !== b.folder.sort_order) return a.folder.sort_order - b.folder.sort_order;
return (a.folder.name || '').localeCompare(b.folder.name || '');
});
for (var j = 0; j < nodes.length; j++) {
var prefix = '';
for (var k = 0; k < depth; k++) prefix += '\u00A0\u00A0';
if (depth > 0) prefix += '\u2514 ';
flat.push({ id: nodes[j].folder.id, label: prefix + nodes[j].folder.name });
walk(nodes[j].children, depth + 1);
}
}
walk(roots, 0);
return flat;
}, [folders]);
if (!note) {
return html`Select a note or create a new one
`;
}
// ── Render editor body ─────────────────────
function renderEditorBody() {
if (preview) {
return html``;
}
if (hasCM) {
return html``;
}
// fallback textarea
return html``;
}
return html`
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
${renderEditorBody()}
<${BacklinksPanel} noteId=${note.id} onNavigate=${onNavigate} />
`;
}
// ═══════════════════════════════════════════
// NotesApp — root component
// ═══════════════════════════════════════════
function NotesApp() {
var [notes, setNotes] = useState([]);
var [loading, setLoading] = useState(true);
var [activeId, setActiveId] = useState(null);
var [activeNote, setActiveNote] = useState(null);
var [searchTerm, setSearchTerm] = useState('');
var [stats, setStats] = useState(null);
var [folders, setFolders] = useState([]);
var [activeFolderId, setActiveFolderId] = useState(null);
var [showUnfiled, setShowUnfiled] = useState(false);
var [allTags, setAllTags] = useState([]);
var [activeTag, setActiveTag] = useState(null);
// ── Load note list ────────────────────────
var loadNotes = useCallback(async function() {
try {
var res;
if (searchTerm.trim()) {
res = await api.get('/search?q=' + encodeURIComponent(searchTerm.trim()));
} else if (activeFolderId) {
res = await api.get('/notes?folder_id=' + encodeURIComponent(activeFolderId));
} else {
res = await api.get('/notes');
}
var items = (res && res.data) || res || [];
if (!Array.isArray(items)) items = [];
// filter to unfiled when that view is active
if (showUnfiled && !searchTerm.trim() && !activeFolderId) {
items = items.filter(function(n) { return !n.folder_id; });
}
// filter by active tag (client-side)
if (activeTag) {
items = items.filter(function(n) {
return n.tags && n.tags.indexOf(activeTag) !== -1;
});
}
// sort: pinned first, then by updated_at desc
items.sort(function(a, b) {
if (a.pinned !== b.pinned) return b.pinned - a.pinned;
return (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || '');
});
setNotes(items);
} catch (e) {
console.error('Load notes failed:', e);
} finally {
setLoading(false);
}
}, [searchTerm, activeFolderId, showUnfiled, activeTag]);
// ── Load single note (full body) ──────────
var loadNote = useCallback(async function(id) {
if (!id) { setActiveNote(null); return; }
try {
var note = await api.get('/notes/' + id);
setActiveNote(note);
} catch (e) {
console.error('Load note failed:', e);
setActiveNote(null);
}
}, []);
// ── Load folders ──────────────────────────
var loadFolders = useCallback(async function() {
try {
var res = await api.get('/folders');
var items = (res && res.data) || res || [];
setFolders(Array.isArray(items) ? items : []);
} catch (e) { console.error('Load folders failed:', e); }
}, []);
// ── Load stats ────────────────────────────
var loadStats = useCallback(async function() {
try {
var s = await api.get('/stats');
setStats(s);
} catch (e) { /* ignore */ }
}, []);
// ── Load all tags ─────────────────────────
var loadTags = useCallback(async function() {
try {
var res = await api.get('/tags');
var items = (res && res.data) || res || [];
setAllTags(Array.isArray(items) ? items : []);
} catch (e) { console.error('Load tags failed:', e); }
}, []);
useEffect(function() { loadNotes(); loadFolders(); loadStats(); loadTags(); }, [loadNotes]);
// select note
function handleSelect(id) {
setActiveId(id);
loadNote(id);
}
// ── Create note ───────────────────────────
async function handleNew() {
try {
var payload = { title: 'Untitled', body: '' };
if (activeFolderId) payload.folder_id = activeFolderId;
var note = await api.post('/notes', payload);
if (note && note.id) {
setActiveId(note.id);
loadNote(note.id);
loadNotes();
loadStats();
}
} catch (e) {
console.error('Create failed:', e);
}
}
// ── Refresh after save ────────────────────
function handleRefresh() {
loadNotes();
if (activeId) loadNote(activeId);
loadStats();
}
// ── After delete ──────────────────────────
function handleDelete() {
setActiveId(null);
setActiveNote(null);
loadNotes();
loadStats();
loadTags();
}
// ── Tags changed in editor ────────────────
function handleTagsChanged() {
loadTags();
loadNotes();
loadStats();
}
// ── Tag filter ────────────────────────────
function handleTagFilter(tag) {
setActiveTag(tag);
}
// ── Drag-and-drop move note to folder ─────
async function handleDropNote(noteId, folderId) {
try {
await api.post('/notes/move', { note_id: noteId, folder_id: folderId });
loadNotes();
loadStats();
if (activeId === noteId) loadNote(activeId);
} catch (e) { console.error('Move note failed:', e); }
}
// ── Folder actions ───────────────────────
function handleSelectFolder(folderId) {
setActiveFolderId(folderId);
setShowUnfiled(false);
setSearchTerm('');
}
function handleSelectAll() {
setActiveFolderId(null);
setShowUnfiled(false);
}
function handleSelectUnfiled() {
setActiveFolderId(null);
setShowUnfiled(true);
}
async function handleCreateFolder(parentId) {
var name = prompt('Folder name:');
if (!name || !name.trim()) return;
try {
await api.post('/folders', { name: name.trim(), parent_id: parentId || '' });
loadFolders();
loadStats();
} catch (e) { console.error('Create folder failed:', e); }
}
async function handleRenameFolder(folderId, newName) {
try {
await api.put('/folders/' + folderId, { name: newName });
loadFolders();
} catch (e) { console.error('Rename folder failed:', e); }
}
async function handleDeleteFolder(folderId) {
if (!confirm('Delete this folder? Notes will be moved to Unfiled.')) return;
try {
await api.del('/folders/' + folderId);
if (activeFolderId === folderId) setActiveFolderId(null);
loadFolders();
loadNotes();
loadStats();
} catch (e) { console.error('Delete folder failed:', e); }
}
// ── Wikilink navigation by title ─────────
async function handleNavigateToTitle(title) {
try {
var res = await api.get('/search?q=' + encodeURIComponent(title.trim()));
var items = (res && res.data) || res || [];
if (!Array.isArray(items)) items = [];
var match = null;
for (var i = 0; i < items.length; i++) {
if ((items[i].title || '').toLowerCase().trim() === title.toLowerCase().trim()) {
match = items[i];
break;
}
}
if (match) {
handleSelect(match.id);
} else {
// create the note and navigate to it
var sourceId = activeId;
var newNote = await api.post('/notes', { title: title.trim(), body: '' });
if (newNote && newNote.id) {
// re-save source note so _sync_links resolves the new target
if (sourceId && activeNote) {
await api.put('/notes/' + sourceId, { body: activeNote.body || '' });
}
setActiveId(newNote.id);
loadNote(newNote.id);
loadNotes();
loadStats();
}
}
} catch (e) {
console.error('Navigate to title failed:', e);
}
}
// ── Import .md file ─────────────────────
function handleImport() {
var input = document.createElement('input');
input.type = 'file';
input.accept = '.md,.markdown,.txt';
input.style.display = 'none';
input.onchange = async function() {
var file = input.files && input.files[0];
if (!file) return;
try {
var text = await new Promise(function(resolve, reject) {
var reader = new FileReader();
reader.onload = function() { resolve(reader.result); };
reader.onerror = reject;
reader.readAsText(file);
});
var parsed = parseFrontmatter(text);
var noteTitle = parsed.title || file.name.replace(/\.(md|markdown|txt)$/i, '') || 'Imported';
var payload = { title: noteTitle, body: parsed.body };
if (activeFolderId) payload.folder_id = activeFolderId;
var newNote = await api.post('/notes', payload);
if (newNote && newNote.id) {
if (parsed.tags.length) {
await api.put('/tags/' + newNote.id, { tags: parsed.tags });
}
setActiveId(newNote.id);
loadNote(newNote.id);
loadNotes();
loadStats();
loadTags();
}
} catch (e) {
console.error('Import failed:', e);
}
document.body.removeChild(input);
};
document.body.appendChild(input);
input.click();
}
// ── Search ────────────────────────────────
function handleSearch(e) {
var val = e.target.value;
setSearchTerm(val);
debounce('search', function() { loadNotes(); }, 300);
}
return html`
<${Topbar} title="Notes">
<${Button} variant="primary" size="sm" onClick=${handleNew}>+ New Note/>
<${Button} variant="secondary" size="sm" onClick=${handleImport}>Import .md/>
/>
<${EditorPane} note=${activeNote} folders=${folders} allTags=${allTags}
onSave=${handleRefresh}
onDelete=${handleDelete}
onRefresh=${handleRefresh}
onTagsChanged=${handleTagsChanged}
onNavigate=${handleSelect}
onNavigateToTitle=${handleNavigateToTitle} />
`;
}
// ── Mount ──────────────────────────────────
render(html`<${NotesApp} />`, mount);
})();