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` +