From f8b42187e7dca68019940b29c1b180853de148eb Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Sun, 29 Mar 2026 13:42:03 +0000 Subject: [PATCH] Feat v0.4.2 tags search 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) --- CHANGELOG.md | 39 +++++ ROADMAP.md | 19 ++- VERSION | 2 +- packages/notes/css/main.css | 156 +++++++++++++++++ packages/notes/js/main.js | 316 ++++++++++++++++++++++++++++++++--- packages/notes/manifest.json | 17 +- packages/notes/script.star | 160 ++++++++++++++++-- 7 files changed, 661 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28375f6..28560e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ All notable changes to Switchboard Core are documented here. +## v0.4.2 — Tags + Search + +### Added + +- **Tags table** (`ext_notes_tags`): note_id, tag columns with indexes on + both for bidirectional lookup. Many-to-many relationship via join table. +- **Tag CRUD API**: 3 new Starlark handlers — list all unique tags, get tags + for a note, replace tags for a note (delete-all + reinsert). Tags are + normalized to lowercase, trimmed, and deduplicated on save. +- **Tags in list/get/search**: `_list_notes` batch-fetches all tags in a + single query and attaches a `tags` array to each note item. `_get_note` + includes tags. `_search_notes` now matches search terms against tags. +- **Tag cascade delete**: Hard-deleting a note also removes its tag rows. +- **Tag input in editor**: `TagInput` component with removable pills, text + input (comma or Enter to add), and autocomplete dropdown populated from + all existing tags. +- **Tag pills on note cards**: `NoteCard` renders up to 3 tag pills with + "+N" overflow indicator for notes with many tags. +- **Tag filter in sidebar**: `TagFilter` component displays all unique tags + as clickable pills. Clicking toggles client-side filtering of the note + list by that tag. +- **Drag-and-drop note moves**: `NoteCard` is draggable (HTML5 drag/drop). + `FolderNode`, "All Notes", and "Unfiled" items are drop targets. On drop, + calls the existing move-note API. +- **Folder context menu**: Replaced `prompt()` hack with a proper right-click + popup menu showing "Add subfolder", "Rename", and "Delete" actions. Positioned + at click coordinates, dismissed on outside click. +- **Updated stats**: `/stats` response now includes `tags` count (unique). + +### Data model + +| Table | Columns | +|-------|---------| +| `ext_notes_tags` | note_id, tag | + +### Notes package version + +Bumped from 0.2.0 → 0.3.0. 15 API routes (12 existing + 3 new). + ## v0.4.1 — Folders + Navigation Tree ### Added diff --git a/ROADMAP.md b/ROADMAP.md index c00ba26..f23c7b4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Switchboard Core — Roadmap -## Current: v0.4.1 — Folders + Navigation Tree +## Current: v0.4.2 — Tags + Search Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat features removed from the kernel. What remains is the minimum viable @@ -253,12 +253,19 @@ Zero platform special-casing. Proves the full extension stack E2E. | Editor folder select | ✅ | Dropdown with nested hierarchy (`└` prefix). Moves notes between folders via move-note API. | | Updated stats | ✅ | Stats include `unfiled` and `folders` counts. | -### v0.4.2 — Tags + Search (planned) +### v0.4.2 — Tags + Search (complete) -- `note_tags` table (note_id, tag) -- Tag management inline with note save -- Tag filter in sidebar, tag pills on cards -- Drag-and-drop note-to-folder moves +| Step | Status | Description | +|------|--------|-------------| +| Tags table | ✅ | `ext_notes_tags` — note_id, tag. Indexed on both columns for bidirectional lookup. | +| Tag CRUD API | ✅ | 3 Starlark handlers: list all unique tags, get tags for note, replace tags for note (delete+reinsert). Tags normalized: lowercase, trimmed, deduped. | +| Tags in list/get/search | ✅ | `_list_notes` batch-fetches all tags in one query, attaches `tags` array to each item. `_get_note` includes tags. `_search_notes` matches against tags. | +| Tag cascade delete | ✅ | Hard-deleting a note also deletes its tag rows. Stats include `tags` count. | +| Tag input in editor | ✅ | `TagInput` component: removable pills + text field, comma/Enter to add, autocomplete dropdown from all tags. | +| Tag pills on cards | ✅ | `NoteCard` renders up to 3 tag pills with "+N" overflow. | +| Tag filter in sidebar | ✅ | `TagFilter` component: clickable pills, toggle active tag, client-side note filtering. | +| Drag-and-drop | ✅ | `NoteCard` is draggable. `FolderNode`, "All Notes", and "Unfiled" are drop targets. Uses existing move-note API. | +| Folder context menu | ✅ | Replaced `prompt()` hack with proper right-click popup menu: Add subfolder, Rename, Delete. Positioned at click coordinates, dismissed on outside click. | ### v0.4.3 — Backlinks + Wikilinks (planned) diff --git a/VERSION b/VERSION index 267577d..2b7c5ae 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.1 +0.4.2 diff --git a/packages/notes/css/main.css b/packages/notes/css/main.css index 3c88db6..b301282 100644 --- a/packages/notes/css/main.css +++ b/packages/notes/css/main.css @@ -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; } diff --git a/packages/notes/js/main.js b/packages/notes/js/main.js index f7f0cd7..7d82c5e 100644 --- a/packages/notes/js/main.js +++ b/packages/notes/js/main.js @@ -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` +
+ ${(tags || []).map(function(tag) { + return html` + + ${tag} + × + + `; + })} + + ${showAC && suggestions.length > 0 && html` +
+ ${suggestions.map(function(s) { + return html`
${s}
`; + })} +
+ `} +
+ `; + } + + + // ═══════════════════════════════════════════ + // 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}
`}
`; @@ -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` +
+
Add subfolder
+
Rename
+
Delete
+
+ `; + } + + + // ═══════════════════════════════════════════ + // 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`
-
+ onContextMenu=${handleContext} + onDragOver=${handleDragOver} + onDragLeave=${handleDragLeave} + onDrop=${handleDrop}> ${hasChildren ? (expanded ? '▼' : '▶') : '·'} @@ -216,11 +394,17 @@ : html`${node.folder.name}` }
+ ${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} />`; })}
`; @@ -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`
-
+
📋 All Notes
-
+
📄 Unfiled
@@ -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} />`; })}
+ 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">🗑
+ <${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
${preview ? html`
` @@ -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} />
- <${EditorPane} note=${activeNote} folders=${folders} + <${EditorPane} note=${activeNote} folders=${folders} allTags=${allTags} onSave=${handleRefresh} onDelete=${handleDelete} - onRefresh=${handleRefresh} /> + onRefresh=${handleRefresh} + onTagsChanged=${handleTagsChanged} />
`; } diff --git a/packages/notes/manifest.json b/packages/notes/manifest.json index 8056d9d..531f5af 100644 --- a/packages/notes/manifest.json +++ b/packages/notes/manifest.json @@ -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", diff --git a/packages/notes/script.star b/packages/notes/script.star index 6aaf53e..72c1caf 100644 --- a/packages/notes/script.star +++ b/packages/notes/script.star @@ -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}) -- 2.49.1