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
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:
39
CHANGELOG.md
39
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
|
||||
|
||||
19
ROADMAP.md
19
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)
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user