Feat v0.4.2 tags search
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 3s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m37s
CI/CD / test-sqlite (pull_request) Successful in 2m40s
CI/CD / build-and-deploy (pull_request) Successful in 1m53s

Tags table (ext_notes_tags), tag CRUD API, tag filtering in sidebar,
tag pills on note cards, drag-and-drop note-to-folder moves, folder
context menu replacing prompt() hack. Notes package bumped to v0.3.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 13:42:03 +00:00
parent 03c182b9d1
commit f8b42187e7
7 changed files with 661 additions and 48 deletions

View File

@@ -373,6 +373,162 @@
color: var(--warning);
}
/* ── Tag Pills (shared) ─────────────────── */
.tag-pill {
display: inline-block;
padding: 1px 8px;
font-size: 11px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-2);
margin: 2px 2px;
white-space: nowrap;
cursor: pointer;
line-height: 1.6;
transition: var(--transition);
}
.tag-pill:hover { background: var(--bg-hover); color: var(--text); }
.tag-pill--active { background: var(--accent); color: #fff; border-color: var(--accent); }
.tag-pill__remove {
margin-left: 4px;
font-size: 10px;
opacity: 0.6;
cursor: pointer;
}
.tag-pill__remove:hover { opacity: 1; }
/* ── Tag Filter (sidebar) ──────────────── */
.tag-filter {
padding: 6px 14px 4px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tag-filter__label {
font-size: 11px;
color: var(--text-3);
margin-bottom: 3px;
display: flex;
align-items: center;
justify-content: space-between;
}
.tag-filter__clear {
font-size: 10px;
color: var(--accent);
cursor: pointer;
background: none;
border: none;
padding: 0;
font-family: var(--font);
}
.tag-filter__clear:hover { text-decoration: underline; }
.tag-filter__pills {
display: flex;
flex-wrap: wrap;
gap: 3px;
max-height: 60px;
overflow-y: auto;
}
.tag-filter__pills .tag-pill { font-size: 10px; padding: 0 6px; }
/* ── Tag Input (editor) ────────────────── */
.tag-input {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
padding: 4px 20px 6px;
border-bottom: 1px solid var(--border);
position: relative;
}
.tag-input__field {
border: none;
outline: none;
font-size: 12px;
background: transparent;
color: var(--text);
min-width: 80px;
flex: 1;
padding: 2px 0;
font-family: var(--font);
}
.tag-input__field::placeholder { color: var(--text-3); }
.tag-input .tag-pill { font-size: 11px; }
/* ── Tag Autocomplete ──────────────────── */
.tag-autocomplete {
position: absolute;
top: 100%;
left: 20px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
max-height: 120px;
overflow-y: auto;
z-index: 10;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
min-width: 140px;
}
.tag-autocomplete__item {
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
color: var(--text);
}
.tag-autocomplete__item:hover { background: var(--bg-hover); }
/* ── Note Card Tags ────────────────────── */
.note-card__tags {
display: flex;
gap: 3px;
margin-top: 3px;
overflow: hidden;
}
.note-card__tags .tag-pill { font-size: 10px; padding: 0 6px; cursor: default; }
.note-card__tags .tag-pill--overflow {
background: transparent;
border: none;
color: var(--text-3);
padding: 0 2px;
cursor: default;
}
/* ── Folder Context Menu ───────────────── */
.folder-context-menu {
position: fixed;
z-index: 100;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
min-width: 150px;
padding: 4px 0;
}
.folder-context-menu__item {
padding: 6px 14px;
font-size: 13px;
color: var(--text);
cursor: pointer;
transition: var(--transition);
}
.folder-context-menu__item:hover {
background: var(--bg-hover);
}
.folder-context-menu__item--danger {
color: var(--danger);
}
.folder-context-menu__item--danger:hover {
background: var(--danger-dim);
}
/* ── Drag and Drop ─────────────────────── */
.note-card--dragging { opacity: 0.4; }
.folder-tree__item--drop-target {
background: color-mix(in srgb, var(--accent) 10%, transparent) !important;
outline: 2px dashed var(--accent);
outline-offset: -2px;
}
/* ── Responsive ──────────────────────────── */
@media (max-width: 700px) {
.notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }

View File

@@ -1,5 +1,5 @@
/**
* Notes — Surface Entry Point (v0.2.0)
* Notes — Surface Entry Point (v0.3.0)
*
* Markdown notes surface using the SDK:
* sw.api.ext('notes') — scoped API client
@@ -143,21 +143,158 @@
// ═══════════════════════════════════════════
// NoteCard — sidebar list item
// 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`
<div class="tag-input">
${(tags || []).map(function(tag) {
return html`
<span key=${tag} class="tag-pill">
${tag}
<span class="tag-pill__remove" onClick=${function() { removeTag(tag); }}>×</span>
</span>
`;
})}
<input ref=${inputRef} class="tag-input__field" type="text"
placeholder=${(tags && tags.length) ? 'Add tag…' : 'Add tags (comma to separate)…'}
value=${input}
onInput=${handleInput}
onKeyDown=${handleKeyDown}
onFocus=${function() { if (input.trim()) setShowAC(true); }}
onBlur=${function() { setTimeout(function() { setShowAC(false); }, 150); }} />
${showAC && suggestions.length > 0 && html`
<div class="tag-autocomplete">
${suggestions.map(function(s) {
return html`<div key=${s} class="tag-autocomplete__item"
onClick=${function() { addTag(s); }}>${s}</div>`;
})}
</div>
`}
</div>
`;
}
// ═══════════════════════════════════════════
// TagFilter — sidebar tag filter
// ═══════════════════════════════════════════
function TagFilter({ tags, activeTag, onSelect }) {
if (!tags || tags.length === 0) return null;
return html`
<div class="tag-filter">
<div class="tag-filter__label">
<span>Tags</span>
${activeTag && html`
<button class="tag-filter__clear" onClick=${function() { onSelect(null); }}>Clear</button>
`}
</div>
<div class="tag-filter__pills">
${tags.map(function(tag) {
return html`
<span key=${tag} class="tag-pill ${activeTag === tag ? 'tag-pill--active' : ''}"
onClick=${function() { onSelect(activeTag === tag ? null : tag); }}>
${tag}
</span>
`;
})}
</div>
</div>
`;
}
// ═══════════════════════════════════════════
// 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`
<div class="note-card ${active ? 'note-card--active' : ''}" onClick=${onClick}>
<div class="note-card ${active ? 'note-card--active' : ''} ${dragging ? 'note-card--dragging' : ''}"
onClick=${onClick}
draggable="true"
onDragStart=${handleDragStart}
onDragEnd=${handleDragEnd}>
<div class="note-card__title">
${note.pinned ? html`<span class="note-card__pin">📌</span>` : null}
${note.title || 'Untitled'}
</div>
${note.snippet && html`<div class="note-card__snippet">${note.snippet}</div>`}
${showTags.length > 0 && html`
<div class="note-card__tags">
${showTags.map(function(t) { return html`<span key=${t} class="tag-pill">${t}</span>`; })}
${overflow > 0 && html`<span class="tag-pill tag-pill--overflow">+${overflow}</span>`}
</div>
`}
${dateStr && html`<div class="note-card__date">${dateStr}</div>`}
</div>
`;
@@ -165,13 +302,40 @@
// ═══════════════════════════════════════════
// FolderNode — recursive tree node
// FolderContextMenu — right-click menu
// ═══════════════════════════════════════════
function FolderNode({ node, depth, activeFolderId, onSelect, onRename, onDelete, onCreateSub }) {
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`
<div ref=${menuRef} class="folder-context-menu" style="top: ${y}px; left: ${x}px">
<div class="folder-context-menu__item" onClick=${onAddSub}>Add subfolder</div>
<div class="folder-context-menu__item" onClick=${onRename}>Rename</div>
<div class="folder-context-menu__item folder-context-menu__item--danger" onClick=${onDelete}>Delete</div>
</div>
`;
}
// ═══════════════════════════════════════════
// 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;
@@ -190,18 +354,32 @@
function handleContext(e) {
e.preventDefault();
var action = prompt('Type "rename" to rename, "delete" to delete, "subfolder" to add subfolder');
if (action === 'rename') { setEditing(true); setEditName(node.folder.name); }
else if (action === 'delete') { onDelete(node.folder.id); }
else if (action === 'subfolder') { onCreateSub(node.folder.id); }
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`
<div>
<div class="folder-tree__item ${isActive ? 'folder-tree__item--active' : ''}"
<div class="folder-tree__item ${isActive ? 'folder-tree__item--active' : ''} ${dropOver ? 'folder-tree__item--drop-target' : ''}"
style="padding-left: ${indent}px"
onClick=${function() { onSelect(node.folder.id); }}
onContextMenu=${handleContext}>
onContextMenu=${handleContext}
onDragOver=${handleDragOver}
onDragLeave=${handleDragLeave}
onDrop=${handleDrop}>
<span class="folder-tree__toggle" onClick=${handleToggle}>
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
</span>
@@ -216,11 +394,17 @@
: html`<span class="folder-tree__name">${node.folder.name}</span>`
}
</div>
${menu && html`<${FolderContextMenu} x=${menu.x} y=${menu.y}
onRename=${function() { setMenu(null); setEditing(true); setEditName(node.folder.name); }}
onDelete=${function() { setMenu(null); onDelete(node.folder.id); }}
onAddSub=${function() { setMenu(null); onCreateSub(node.folder.id); }}
onClose=${function() { setMenu(null); }} />`}
${expanded && node.children.map(function(child) {
return html`<${FolderNode} key=${child.folder.id} node=${child} depth=${depth + 1}
activeFolderId=${activeFolderId}
onSelect=${onSelect} onRename=${onRename}
onDelete=${onDelete} onCreateSub=${onCreateSub} />`;
onDelete=${onDelete} onCreateSub=${onCreateSub}
onDropNote=${onDropNote} />`;
})}
</div>
`;
@@ -228,10 +412,13 @@
// ═══════════════════════════════════════════
// FolderTree — navigation panel
// FolderTree — navigation panel (drop targets)
// ═══════════════════════════════════════════
function FolderTree({ folders, activeFolderId, showUnfiled, onSelectFolder, onSelectAll, onSelectUnfiled, onCreateFolder, onRenameFolder, onDeleteFolder }) {
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 = {};
@@ -259,15 +446,32 @@
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`
<div class="folder-tree">
<div class="folder-tree__item ${!activeFolderId && !showUnfiled ? 'folder-tree__item--active' : ''}"
onClick=${onSelectAll}>
<div class="folder-tree__item ${!activeFolderId && !showUnfiled ? 'folder-tree__item--active' : ''} ${dropAll ? 'folder-tree__item--drop-target' : ''}"
onClick=${onSelectAll}
onDragOver=${allDrop.onDragOver} onDragLeave=${allDrop.onDragLeave} onDrop=${allDrop.onDrop}>
<span class="folder-tree__icon">📋</span>
<span class="folder-tree__name">All Notes</span>
</div>
<div class="folder-tree__item ${showUnfiled ? 'folder-tree__item--active' : ''}"
onClick=${onSelectUnfiled}>
<div class="folder-tree__item ${showUnfiled ? 'folder-tree__item--active' : ''} ${dropUnfiled ? 'folder-tree__item--drop-target' : ''}"
onClick=${onSelectUnfiled}
onDragOver=${unfiledDrop.onDragOver} onDragLeave=${unfiledDrop.onDragLeave} onDrop=${unfiledDrop.onDrop}>
<span class="folder-tree__icon">📄</span>
<span class="folder-tree__name">Unfiled</span>
</div>
@@ -275,7 +479,8 @@
return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0}
activeFolderId=${activeFolderId}
onSelect=${onSelectFolder} onRename=${onRenameFolder}
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder} />`;
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder}
onDropNote=${onDropNote} />`;
})}
<div class="folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
+ New Folder
@@ -286,12 +491,13 @@
// ═══════════════════════════════════════════
// EditorPane — note editor + preview
// EditorPane — note editor + preview + tags
// ═══════════════════════════════════════════
function EditorPane({ note, folders, onSave, onDelete, onRefresh }) {
function EditorPane({ note, folders, allTags, onSave, onDelete, onRefresh, onTagsChanged }) {
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);
@@ -302,6 +508,7 @@
if (note) {
setTitle(note.title || '');
setBody(note.body || '');
setNoteTags(note.tags || []);
setDirty(false);
setPreview(false);
}
@@ -333,6 +540,17 @@
}
}
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 });
@@ -439,6 +657,7 @@
title="Archive">🗑</button>
</div>
</div>
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
<div class="notes-editor__body">
${preview
? html`<div class="notes-preview" dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
@@ -467,6 +686,8 @@
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() {
@@ -485,6 +706,12 @@
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;
@@ -496,7 +723,7 @@
} finally {
setLoading(false);
}
}, [searchTerm, activeFolderId, showUnfiled]);
}, [searchTerm, activeFolderId, showUnfiled, activeTag]);
// ── Load single note (full body) ──────────
var loadNote = useCallback(async function(id) {
@@ -527,7 +754,16 @@
} catch (e) { /* ignore */ }
}, []);
useEffect(function() { loadNotes(); loadFolders(); loadStats(); }, [loadNotes]);
// ── 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) {
@@ -565,6 +801,29 @@
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 ───────────────────────
@@ -630,7 +889,9 @@
onSelectUnfiled=${handleSelectUnfiled}
onCreateFolder=${handleCreateFolder}
onRenameFolder=${handleRenameFolder}
onDeleteFolder=${handleDeleteFolder} />
onDeleteFolder=${handleDeleteFolder}
onDropNote=${handleDropNote} />
<${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} />
<div class="notes-search">
<input type="text" placeholder="Search notes…"
value=${searchTerm} onInput=${handleSearch} />
@@ -639,7 +900,7 @@
${loading && html`<div class="notes-list__loading"><${Spinner} size="md" /></div>`}
${!loading && notes.length === 0 && html`
<div class="notes-list__empty">
${searchTerm ? 'No matching notes' : 'No notes yet. Click + New Note to start.'}
${searchTerm ? 'No matching notes' : (activeTag ? 'No notes with tag "' + activeTag + '"' : 'No notes yet. Click + New Note to start.')}
</div>
`}
${!loading && notes.map(function(n) {
@@ -651,10 +912,11 @@
})}
</div>
</div>
<${EditorPane} note=${activeNote} folders=${folders}
<${EditorPane} note=${activeNote} folders=${folders} allTags=${allTags}
onSave=${handleRefresh}
onDelete=${handleDelete}
onRefresh=${handleRefresh} />
onRefresh=${handleRefresh}
onTagsChanged=${handleTagsChanged} />
</div>
`;
}

View File

@@ -6,7 +6,7 @@
"route": "/s/notes",
"auth": "authenticated",
"layout": "single",
"version": "0.2.0",
"version": "0.3.0",
"icon": "📝",
"description": "Markdown notes surface with folders, tags, and backlinks.",
"author": "switchboard",
@@ -25,7 +25,10 @@
{"method": "POST", "path": "/folders"},
{"method": "PUT", "path": "/folders/*"},
{"method": "DELETE", "path": "/folders/*"},
{"method": "POST", "path": "/notes/move"}
{"method": "POST", "path": "/notes/move"},
{"method": "GET", "path": "/tags"},
{"method": "GET", "path": "/tags/*"},
{"method": "PUT", "path": "/tags/*"}
],
"db_tables": {
@@ -46,6 +49,16 @@
["updated_at"]
]
},
"tags": {
"columns": {
"note_id": "text",
"tag": "text"
},
"indexes": [
["note_id"],
["tag"]
]
},
"folders": {
"columns": {
"name": "text",

View File

@@ -1,4 +1,4 @@
# Notes — Starlark Backend (v0.2.0)
# Notes — Starlark Backend (v0.3.0)
#
# Markdown notes surface using ext_data.
#
@@ -39,6 +39,21 @@ def _snippet(body, max_len = 120):
return line[:max_len]
return line
def _tags_for_notes(note_ids):
"""Batch-fetch tags for a list of note IDs. Returns {note_id: [tag, ...]}."""
if not note_ids:
return {}
all_tags = db.query("tags", limit=5000)
result = {}
for t in (all_tags or []):
nid = _str(t.get("note_id", ""))
tag = _str(t.get("tag", ""))
if nid and tag:
if nid not in result:
result[nid] = []
result[nid].append(tag)
return result
# ═══════════════════════════════════════════════
# Surface API routes (/s/notes/api/*)
@@ -64,6 +79,24 @@ def on_request(req):
if method == "GET" and path == "/search":
return _search_notes(req)
# POST /notes/move (must be before /notes/:id catch-all)
if method == "POST" and path == "/notes/move":
return _move_note(req)
# ── Tag routes ────────────────────────────
# GET /tags — all unique tags
if method == "GET" and path == "/tags":
return _list_tags()
# GET /tags/:note_id
if method == "GET" and path.startswith("/tags/"):
return _get_note_tags(path[len("/tags/"):])
# PUT /tags/:note_id
if method == "PUT" and path.startswith("/tags/"):
return _set_note_tags(path[len("/tags/"):], req)
# ── Note routes ───────────────────────────
# GET /notes/:id
if method == "GET" and path.startswith("/notes/"):
return _get_note(path[len("/notes/"):])
@@ -76,11 +109,7 @@ def on_request(req):
if method == "DELETE" and path.startswith("/notes/"):
return _delete_note(path[len("/notes/"):], req)
# POST /notes/move
if method == "POST" and path == "/notes/move":
return _move_note(req)
# ── Folder routes ──────────────────────────
# ── Folder routes ─────────────────────────
# GET /folders
if method == "GET" and path == "/folders":
return _list_folders()
@@ -101,7 +130,7 @@ def on_request(req):
# ═══════════════════════════════════════════════
# CRUD
# Notes CRUD
# ═══════════════════════════════════════════════
def _list_notes(req):
@@ -132,11 +161,19 @@ def _list_notes(req):
limit = int(limit_str) if limit_str else 200
rows = db.query("notes", filters=filters, order=order, limit=limit)
# batch-fetch tags for all notes
note_ids = []
for r in (rows or []):
note_ids.append(r.get("id", ""))
tags_map = _tags_for_notes(note_ids)
# lightweight projection: strip body for list view
items = []
for r in (rows or []):
nid = r.get("id", "")
items.append({
"id": r.get("id", ""),
"id": nid,
"title": r.get("title", ""),
"snippet": _snippet(r.get("body", "")),
"folder_id": r.get("folder_id", ""),
@@ -145,6 +182,7 @@ def _list_notes(req):
"pinned": _int(r.get("pinned", 0)),
"archived": _int(r.get("archived", 0)),
"created_at": r.get("created_at", ""),
"tags": tags_map.get(nid, []),
})
return _resp(200, {"data": items})
@@ -173,7 +211,16 @@ def _get_note(note_id):
rows = db.query("notes", filters={"id": note_id}, limit=1)
if not rows:
return _resp(404, {"error": "note not found"})
return _resp(200, rows[0])
note = rows[0]
# include tags
tag_rows = db.query("tags", filters={"note_id": note_id}, limit=100)
tags = []
for t in (tag_rows or []):
tag = _str(t.get("tag", ""))
if tag:
tags.append(tag)
note["tags"] = tags
return _resp(200, note)
def _update_note(note_id, req):
@@ -209,6 +256,10 @@ def _delete_note(note_id, req):
hard = _str(q.get("hard", ""))
if hard == "1":
# cascade-delete tags for this note
note_tags = db.query("tags", filters={"note_id": note_id}, limit=100)
for t in (note_tags or []):
db.delete("tags", t["id"])
ok = db.delete("notes", note_id)
if not ok:
return _resp(404, {"error": "note not found"})
@@ -241,6 +292,64 @@ def _move_note(req):
return _resp(200, rows[0] if rows else {})
# ═══════════════════════════════════════════════
# Tags CRUD
# ═══════════════════════════════════════════════
def _list_tags():
"""Return sorted list of all unique tag strings."""
all_tags = db.query("tags", limit=5000)
seen = {}
for t in (all_tags or []):
tag = _str(t.get("tag", "")).strip()
if tag:
seen[tag] = True
tags = sorted(seen.keys())
return _resp(200, {"data": tags})
def _get_note_tags(note_id):
"""Return tags for a specific note."""
rows = db.query("tags", filters={"note_id": note_id}, limit=100)
tags = []
for t in (rows or []):
tag = _str(t.get("tag", ""))
if tag:
tags.append(tag)
return _resp(200, {"data": tags})
def _set_note_tags(note_id, req):
"""Replace all tags for a note (delete + reinsert)."""
body = json.decode(req.get("body", "{}"))
new_tags_raw = body.get("tags", [])
# validate note exists
existing = db.query("notes", filters={"id": note_id}, limit=1)
if not existing:
return _resp(404, {"error": "note not found"})
# normalize: lowercase, strip, deduplicate, skip empty
seen = {}
clean_tags = []
for raw in new_tags_raw:
tag = _str(raw).strip().lower()
if tag and tag not in seen:
seen[tag] = True
clean_tags.append(tag)
# delete all existing tags for this note
old_tags = db.query("tags", filters={"note_id": note_id}, limit=100)
for t in (old_tags or []):
db.delete("tags", t["id"])
# insert new tags
for tag in clean_tags:
db.insert("tags", {"note_id": note_id, "tag": tag})
return _resp(200, {"data": clean_tags})
# ═══════════════════════════════════════════════
# Folder CRUD
# ═══════════════════════════════════════════════
@@ -331,19 +440,36 @@ def _search_notes(req):
# (ext_data db.query doesn't support LIKE — filter client-side)
rows = db.query("notes", filters={"archived": 0}, order="updated_at", limit=1000)
term_lower = term.lower()
# batch-fetch tags for tag-based matching
tags_map = _tags_for_notes([r.get("id", "") for r in (rows or [])])
matches = []
for r in (rows or []):
title = _str(r.get("title", "")).lower()
body = _str(r.get("body", "")).lower()
if term_lower in title or term_lower in body:
nid = r.get("id", "")
# check title and body
found = term_lower in title or term_lower in body
# check tags
if not found:
note_tags = tags_map.get(nid, [])
for tg in note_tags:
if term_lower in tg.lower():
found = True
if found:
matches.append({
"id": r.get("id", ""),
"id": nid,
"title": r.get("title", ""),
"snippet": _snippet(r.get("body", "")),
"folder_id": r.get("folder_id", ""),
"updated_at": r.get("updated_at", ""),
"pinned": _int(r.get("pinned", 0)),
"created_at": r.get("created_at", ""),
"tags": tags_map.get(nid, []),
})
return _resp(200, {"data": matches})
@@ -370,4 +496,14 @@ def _get_stats():
unfiled = unfiled + 1
all_folders = db.query("folders", limit=10000)
folder_count = len(all_folders or [])
return _resp(200, {"total": total, "pinned": pinned, "archived": archived, "unfiled": unfiled, "folders": folder_count})
# count unique tags
all_tags = db.query("tags", limit=10000)
tag_seen = {}
for t in (all_tags or []):
tag = _str(t.get("tag", ""))
if tag:
tag_seen[tag] = True
tag_count = len(tag_seen)
return _resp(200, {"total": total, "pinned": pinned, "archived": archived, "unfiled": unfiled, "folders": folder_count, "tags": tag_count})