Feat v0.4.2 tags search (#24)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #24.
This commit is contained in:
39
CHANGELOG.md
39
CHANGELOG.md
@@ -2,6 +2,45 @@
|
|||||||
|
|
||||||
All notable changes to Switchboard Core are documented here.
|
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
|
## v0.4.1 — Folders + Navigation Tree
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
19
ROADMAP.md
19
ROADMAP.md
@@ -1,6 +1,6 @@
|
|||||||
# Switchboard Core — Roadmap
|
# 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
|
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
|
||||||
features removed from the kernel. What remains is the minimum viable
|
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. |
|
| Editor folder select | ✅ | Dropdown with nested hierarchy (`└` prefix). Moves notes between folders via move-note API. |
|
||||||
| Updated stats | ✅ | Stats include `unfiled` and `folders` counts. |
|
| 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)
|
| Step | Status | Description |
|
||||||
- Tag management inline with note save
|
|------|--------|-------------|
|
||||||
- Tag filter in sidebar, tag pills on cards
|
| Tags table | ✅ | `ext_notes_tags` — note_id, tag. Indexed on both columns for bidirectional lookup. |
|
||||||
- Drag-and-drop note-to-folder moves
|
| 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)
|
### v0.4.3 — Backlinks + Wikilinks (planned)
|
||||||
|
|
||||||
|
|||||||
@@ -373,6 +373,162 @@
|
|||||||
color: var(--warning);
|
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 ──────────────────────────── */
|
/* ── Responsive ──────────────────────────── */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
|
.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:
|
* Markdown notes surface using the SDK:
|
||||||
* sw.api.ext('notes') — scoped API client
|
* 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 }) {
|
function NoteCard({ note, active, onClick }) {
|
||||||
|
var [dragging, setDragging] = useState(false);
|
||||||
var dateStr = note.updated_at || note.created_at || '';
|
var dateStr = note.updated_at || note.created_at || '';
|
||||||
if (dateStr) {
|
if (dateStr) {
|
||||||
try { dateStr = new Date(dateStr).toLocaleDateString(); } catch(e) { /* keep raw */ }
|
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`
|
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">
|
<div class="note-card__title">
|
||||||
${note.pinned ? html`<span class="note-card__pin">📌</span>` : null}
|
${note.pinned ? html`<span class="note-card__pin">📌</span>` : null}
|
||||||
${note.title || 'Untitled'}
|
${note.title || 'Untitled'}
|
||||||
</div>
|
</div>
|
||||||
${note.snippet && html`<div class="note-card__snippet">${note.snippet}</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>`}
|
${dateStr && html`<div class="note-card__date">${dateStr}</div>`}
|
||||||
</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 [expanded, setExpanded] = useState(true);
|
||||||
var [editing, setEditing] = useState(false);
|
var [editing, setEditing] = useState(false);
|
||||||
var [editName, setEditName] = useState(node.folder.name);
|
var [editName, setEditName] = useState(node.folder.name);
|
||||||
|
var [dropOver, setDropOver] = useState(false);
|
||||||
|
var [menu, setMenu] = useState(null);
|
||||||
var hasChildren = node.children.length > 0;
|
var hasChildren = node.children.length > 0;
|
||||||
var isActive = activeFolderId === node.folder.id;
|
var isActive = activeFolderId === node.folder.id;
|
||||||
var indent = 20 + depth * 16;
|
var indent = 20 + depth * 16;
|
||||||
@@ -190,18 +354,32 @@
|
|||||||
|
|
||||||
function handleContext(e) {
|
function handleContext(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var action = prompt('Type "rename" to rename, "delete" to delete, "subfolder" to add subfolder');
|
e.stopPropagation();
|
||||||
if (action === 'rename') { setEditing(true); setEditName(node.folder.name); }
|
setMenu({ x: e.clientX, y: e.clientY });
|
||||||
else if (action === 'delete') { onDelete(node.folder.id); }
|
}
|
||||||
else if (action === 'subfolder') { onCreateSub(node.folder.id); }
|
|
||||||
|
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`
|
return html`
|
||||||
<div>
|
<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"
|
style="padding-left: ${indent}px"
|
||||||
onClick=${function() { onSelect(node.folder.id); }}
|
onClick=${function() { onSelect(node.folder.id); }}
|
||||||
onContextMenu=${handleContext}>
|
onContextMenu=${handleContext}
|
||||||
|
onDragOver=${handleDragOver}
|
||||||
|
onDragLeave=${handleDragLeave}
|
||||||
|
onDrop=${handleDrop}>
|
||||||
<span class="folder-tree__toggle" onClick=${handleToggle}>
|
<span class="folder-tree__toggle" onClick=${handleToggle}>
|
||||||
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
|
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
|
||||||
</span>
|
</span>
|
||||||
@@ -216,11 +394,17 @@
|
|||||||
: html`<span class="folder-tree__name">${node.folder.name}</span>`
|
: html`<span class="folder-tree__name">${node.folder.name}</span>`
|
||||||
}
|
}
|
||||||
</div>
|
</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) {
|
${expanded && node.children.map(function(child) {
|
||||||
return html`<${FolderNode} key=${child.folder.id} node=${child} depth=${depth + 1}
|
return html`<${FolderNode} key=${child.folder.id} node=${child} depth=${depth + 1}
|
||||||
activeFolderId=${activeFolderId}
|
activeFolderId=${activeFolderId}
|
||||||
onSelect=${onSelect} onRename=${onRename}
|
onSelect=${onSelect} onRename=${onRename}
|
||||||
onDelete=${onDelete} onCreateSub=${onCreateSub} />`;
|
onDelete=${onDelete} onCreateSub=${onCreateSub}
|
||||||
|
onDropNote=${onDropNote} />`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</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
|
// build tree from flat list
|
||||||
var tree = useMemo(function() {
|
var tree = useMemo(function() {
|
||||||
var byId = {};
|
var byId = {};
|
||||||
@@ -259,15 +446,32 @@
|
|||||||
return roots;
|
return roots;
|
||||||
}, [folders]);
|
}, [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`
|
return html`
|
||||||
<div class="folder-tree">
|
<div class="folder-tree">
|
||||||
<div class="folder-tree__item ${!activeFolderId && !showUnfiled ? 'folder-tree__item--active' : ''}"
|
<div class="folder-tree__item ${!activeFolderId && !showUnfiled ? 'folder-tree__item--active' : ''} ${dropAll ? 'folder-tree__item--drop-target' : ''}"
|
||||||
onClick=${onSelectAll}>
|
onClick=${onSelectAll}
|
||||||
|
onDragOver=${allDrop.onDragOver} onDragLeave=${allDrop.onDragLeave} onDrop=${allDrop.onDrop}>
|
||||||
<span class="folder-tree__icon">📋</span>
|
<span class="folder-tree__icon">📋</span>
|
||||||
<span class="folder-tree__name">All Notes</span>
|
<span class="folder-tree__name">All Notes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="folder-tree__item ${showUnfiled ? 'folder-tree__item--active' : ''}"
|
<div class="folder-tree__item ${showUnfiled ? 'folder-tree__item--active' : ''} ${dropUnfiled ? 'folder-tree__item--drop-target' : ''}"
|
||||||
onClick=${onSelectUnfiled}>
|
onClick=${onSelectUnfiled}
|
||||||
|
onDragOver=${unfiledDrop.onDragOver} onDragLeave=${unfiledDrop.onDragLeave} onDrop=${unfiledDrop.onDrop}>
|
||||||
<span class="folder-tree__icon">📄</span>
|
<span class="folder-tree__icon">📄</span>
|
||||||
<span class="folder-tree__name">Unfiled</span>
|
<span class="folder-tree__name">Unfiled</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,7 +479,8 @@
|
|||||||
return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0}
|
return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0}
|
||||||
activeFolderId=${activeFolderId}
|
activeFolderId=${activeFolderId}
|
||||||
onSelect=${onSelectFolder} onRename=${onRenameFolder}
|
onSelect=${onSelectFolder} onRename=${onRenameFolder}
|
||||||
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder} />`;
|
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder}
|
||||||
|
onDropNote=${onDropNote} />`;
|
||||||
})}
|
})}
|
||||||
<div class="folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
|
<div class="folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
|
||||||
+ New Folder
|
+ 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 [title, setTitle] = useState(note ? note.title : '');
|
||||||
var [body, setBody] = useState(note ? note.body : '');
|
var [body, setBody] = useState(note ? note.body : '');
|
||||||
|
var [noteTags, setNoteTags] = useState([]);
|
||||||
var [preview, setPreview] = useState(false);
|
var [preview, setPreview] = useState(false);
|
||||||
var [dirty, setDirty] = useState(false);
|
var [dirty, setDirty] = useState(false);
|
||||||
var [saving, setSaving] = useState(false);
|
var [saving, setSaving] = useState(false);
|
||||||
@@ -302,6 +508,7 @@
|
|||||||
if (note) {
|
if (note) {
|
||||||
setTitle(note.title || '');
|
setTitle(note.title || '');
|
||||||
setBody(note.body || '');
|
setBody(note.body || '');
|
||||||
|
setNoteTags(note.tags || []);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setPreview(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() {
|
async function handlePin() {
|
||||||
if (!note) return;
|
if (!note) return;
|
||||||
await api.put('/notes/' + note.id, { pinned: note.pinned ? 0 : 1 });
|
await api.put('/notes/' + note.id, { pinned: note.pinned ? 0 : 1 });
|
||||||
@@ -439,6 +657,7 @@
|
|||||||
title="Archive">🗑</button>
|
title="Archive">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
|
||||||
<div class="notes-editor__body">
|
<div class="notes-editor__body">
|
||||||
${preview
|
${preview
|
||||||
? html`<div class="notes-preview" dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
|
? html`<div class="notes-preview" dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
|
||||||
@@ -467,6 +686,8 @@
|
|||||||
var [folders, setFolders] = useState([]);
|
var [folders, setFolders] = useState([]);
|
||||||
var [activeFolderId, setActiveFolderId] = useState(null);
|
var [activeFolderId, setActiveFolderId] = useState(null);
|
||||||
var [showUnfiled, setShowUnfiled] = useState(false);
|
var [showUnfiled, setShowUnfiled] = useState(false);
|
||||||
|
var [allTags, setAllTags] = useState([]);
|
||||||
|
var [activeTag, setActiveTag] = useState(null);
|
||||||
|
|
||||||
// ── Load note list ────────────────────────
|
// ── Load note list ────────────────────────
|
||||||
var loadNotes = useCallback(async function() {
|
var loadNotes = useCallback(async function() {
|
||||||
@@ -485,6 +706,12 @@
|
|||||||
if (showUnfiled && !searchTerm.trim() && !activeFolderId) {
|
if (showUnfiled && !searchTerm.trim() && !activeFolderId) {
|
||||||
items = items.filter(function(n) { return !n.folder_id; });
|
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
|
// sort: pinned first, then by updated_at desc
|
||||||
items.sort(function(a, b) {
|
items.sort(function(a, b) {
|
||||||
if (a.pinned !== b.pinned) return b.pinned - a.pinned;
|
if (a.pinned !== b.pinned) return b.pinned - a.pinned;
|
||||||
@@ -496,7 +723,7 @@
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [searchTerm, activeFolderId, showUnfiled]);
|
}, [searchTerm, activeFolderId, showUnfiled, activeTag]);
|
||||||
|
|
||||||
// ── Load single note (full body) ──────────
|
// ── Load single note (full body) ──────────
|
||||||
var loadNote = useCallback(async function(id) {
|
var loadNote = useCallback(async function(id) {
|
||||||
@@ -527,7 +754,16 @@
|
|||||||
} catch (e) { /* ignore */ }
|
} 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
|
// select note
|
||||||
function handleSelect(id) {
|
function handleSelect(id) {
|
||||||
@@ -565,6 +801,29 @@
|
|||||||
setActiveNote(null);
|
setActiveNote(null);
|
||||||
loadNotes();
|
loadNotes();
|
||||||
loadStats();
|
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 ───────────────────────
|
// ── Folder actions ───────────────────────
|
||||||
@@ -630,7 +889,9 @@
|
|||||||
onSelectUnfiled=${handleSelectUnfiled}
|
onSelectUnfiled=${handleSelectUnfiled}
|
||||||
onCreateFolder=${handleCreateFolder}
|
onCreateFolder=${handleCreateFolder}
|
||||||
onRenameFolder=${handleRenameFolder}
|
onRenameFolder=${handleRenameFolder}
|
||||||
onDeleteFolder=${handleDeleteFolder} />
|
onDeleteFolder=${handleDeleteFolder}
|
||||||
|
onDropNote=${handleDropNote} />
|
||||||
|
<${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} />
|
||||||
<div class="notes-search">
|
<div class="notes-search">
|
||||||
<input type="text" placeholder="Search notes…"
|
<input type="text" placeholder="Search notes…"
|
||||||
value=${searchTerm} onInput=${handleSearch} />
|
value=${searchTerm} onInput=${handleSearch} />
|
||||||
@@ -639,7 +900,7 @@
|
|||||||
${loading && html`<div class="notes-list__loading"><${Spinner} size="md" /></div>`}
|
${loading && html`<div class="notes-list__loading"><${Spinner} size="md" /></div>`}
|
||||||
${!loading && notes.length === 0 && html`
|
${!loading && notes.length === 0 && html`
|
||||||
<div class="notes-list__empty">
|
<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>
|
</div>
|
||||||
`}
|
`}
|
||||||
${!loading && notes.map(function(n) {
|
${!loading && notes.map(function(n) {
|
||||||
@@ -651,10 +912,11 @@
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<${EditorPane} note=${activeNote} folders=${folders}
|
<${EditorPane} note=${activeNote} folders=${folders} allTags=${allTags}
|
||||||
onSave=${handleRefresh}
|
onSave=${handleRefresh}
|
||||||
onDelete=${handleDelete}
|
onDelete=${handleDelete}
|
||||||
onRefresh=${handleRefresh} />
|
onRefresh=${handleRefresh}
|
||||||
|
onTagsChanged=${handleTagsChanged} />
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"route": "/s/notes",
|
"route": "/s/notes",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
"layout": "single",
|
"layout": "single",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"icon": "📝",
|
"icon": "📝",
|
||||||
"description": "Markdown notes surface with folders, tags, and backlinks.",
|
"description": "Markdown notes surface with folders, tags, and backlinks.",
|
||||||
"author": "switchboard",
|
"author": "switchboard",
|
||||||
@@ -25,7 +25,10 @@
|
|||||||
{"method": "POST", "path": "/folders"},
|
{"method": "POST", "path": "/folders"},
|
||||||
{"method": "PUT", "path": "/folders/*"},
|
{"method": "PUT", "path": "/folders/*"},
|
||||||
{"method": "DELETE", "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": {
|
"db_tables": {
|
||||||
@@ -46,6 +49,16 @@
|
|||||||
["updated_at"]
|
["updated_at"]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"tags": {
|
||||||
|
"columns": {
|
||||||
|
"note_id": "text",
|
||||||
|
"tag": "text"
|
||||||
|
},
|
||||||
|
"indexes": [
|
||||||
|
["note_id"],
|
||||||
|
["tag"]
|
||||||
|
]
|
||||||
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"columns": {
|
"columns": {
|
||||||
"name": "text",
|
"name": "text",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Notes — Starlark Backend (v0.2.0)
|
# Notes — Starlark Backend (v0.3.0)
|
||||||
#
|
#
|
||||||
# Markdown notes surface using ext_data.
|
# Markdown notes surface using ext_data.
|
||||||
#
|
#
|
||||||
@@ -39,6 +39,21 @@ def _snippet(body, max_len = 120):
|
|||||||
return line[:max_len]
|
return line[:max_len]
|
||||||
return line
|
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/*)
|
# Surface API routes (/s/notes/api/*)
|
||||||
@@ -64,6 +79,24 @@ def on_request(req):
|
|||||||
if method == "GET" and path == "/search":
|
if method == "GET" and path == "/search":
|
||||||
return _search_notes(req)
|
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
|
# GET /notes/:id
|
||||||
if method == "GET" and path.startswith("/notes/"):
|
if method == "GET" and path.startswith("/notes/"):
|
||||||
return _get_note(path[len("/notes/"):])
|
return _get_note(path[len("/notes/"):])
|
||||||
@@ -76,11 +109,7 @@ def on_request(req):
|
|||||||
if method == "DELETE" and path.startswith("/notes/"):
|
if method == "DELETE" and path.startswith("/notes/"):
|
||||||
return _delete_note(path[len("/notes/"):], req)
|
return _delete_note(path[len("/notes/"):], req)
|
||||||
|
|
||||||
# POST /notes/move
|
# ── Folder routes ─────────────────────────
|
||||||
if method == "POST" and path == "/notes/move":
|
|
||||||
return _move_note(req)
|
|
||||||
|
|
||||||
# ── Folder routes ──────────────────────────
|
|
||||||
# GET /folders
|
# GET /folders
|
||||||
if method == "GET" and path == "/folders":
|
if method == "GET" and path == "/folders":
|
||||||
return _list_folders()
|
return _list_folders()
|
||||||
@@ -101,7 +130,7 @@ def on_request(req):
|
|||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
# CRUD
|
# Notes CRUD
|
||||||
# ═══════════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
|
|
||||||
def _list_notes(req):
|
def _list_notes(req):
|
||||||
@@ -132,11 +161,19 @@ def _list_notes(req):
|
|||||||
limit = int(limit_str) if limit_str else 200
|
limit = int(limit_str) if limit_str else 200
|
||||||
|
|
||||||
rows = db.query("notes", filters=filters, order=order, limit=limit)
|
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
|
# lightweight projection: strip body for list view
|
||||||
items = []
|
items = []
|
||||||
for r in (rows or []):
|
for r in (rows or []):
|
||||||
|
nid = r.get("id", "")
|
||||||
items.append({
|
items.append({
|
||||||
"id": r.get("id", ""),
|
"id": nid,
|
||||||
"title": r.get("title", ""),
|
"title": r.get("title", ""),
|
||||||
"snippet": _snippet(r.get("body", "")),
|
"snippet": _snippet(r.get("body", "")),
|
||||||
"folder_id": r.get("folder_id", ""),
|
"folder_id": r.get("folder_id", ""),
|
||||||
@@ -145,6 +182,7 @@ def _list_notes(req):
|
|||||||
"pinned": _int(r.get("pinned", 0)),
|
"pinned": _int(r.get("pinned", 0)),
|
||||||
"archived": _int(r.get("archived", 0)),
|
"archived": _int(r.get("archived", 0)),
|
||||||
"created_at": r.get("created_at", ""),
|
"created_at": r.get("created_at", ""),
|
||||||
|
"tags": tags_map.get(nid, []),
|
||||||
})
|
})
|
||||||
return _resp(200, {"data": items})
|
return _resp(200, {"data": items})
|
||||||
|
|
||||||
@@ -173,7 +211,16 @@ def _get_note(note_id):
|
|||||||
rows = db.query("notes", filters={"id": note_id}, limit=1)
|
rows = db.query("notes", filters={"id": note_id}, limit=1)
|
||||||
if not rows:
|
if not rows:
|
||||||
return _resp(404, {"error": "note not found"})
|
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):
|
def _update_note(note_id, req):
|
||||||
@@ -209,6 +256,10 @@ def _delete_note(note_id, req):
|
|||||||
hard = _str(q.get("hard", ""))
|
hard = _str(q.get("hard", ""))
|
||||||
|
|
||||||
if hard == "1":
|
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)
|
ok = db.delete("notes", note_id)
|
||||||
if not ok:
|
if not ok:
|
||||||
return _resp(404, {"error": "note not found"})
|
return _resp(404, {"error": "note not found"})
|
||||||
@@ -241,6 +292,64 @@ def _move_note(req):
|
|||||||
return _resp(200, rows[0] if rows else {})
|
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
|
# Folder CRUD
|
||||||
# ═══════════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
@@ -331,19 +440,36 @@ def _search_notes(req):
|
|||||||
# (ext_data db.query doesn't support LIKE — filter client-side)
|
# (ext_data db.query doesn't support LIKE — filter client-side)
|
||||||
rows = db.query("notes", filters={"archived": 0}, order="updated_at", limit=1000)
|
rows = db.query("notes", filters={"archived": 0}, order="updated_at", limit=1000)
|
||||||
term_lower = term.lower()
|
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 = []
|
matches = []
|
||||||
for r in (rows or []):
|
for r in (rows or []):
|
||||||
title = _str(r.get("title", "")).lower()
|
title = _str(r.get("title", "")).lower()
|
||||||
body = _str(r.get("body", "")).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({
|
matches.append({
|
||||||
"id": r.get("id", ""),
|
"id": nid,
|
||||||
"title": r.get("title", ""),
|
"title": r.get("title", ""),
|
||||||
"snippet": _snippet(r.get("body", "")),
|
"snippet": _snippet(r.get("body", "")),
|
||||||
"folder_id": r.get("folder_id", ""),
|
"folder_id": r.get("folder_id", ""),
|
||||||
"updated_at": r.get("updated_at", ""),
|
"updated_at": r.get("updated_at", ""),
|
||||||
"pinned": _int(r.get("pinned", 0)),
|
"pinned": _int(r.get("pinned", 0)),
|
||||||
"created_at": r.get("created_at", ""),
|
"created_at": r.get("created_at", ""),
|
||||||
|
"tags": tags_map.get(nid, []),
|
||||||
})
|
})
|
||||||
return _resp(200, {"data": matches})
|
return _resp(200, {"data": matches})
|
||||||
|
|
||||||
@@ -370,4 +496,14 @@ def _get_stats():
|
|||||||
unfiled = unfiled + 1
|
unfiled = unfiled + 1
|
||||||
all_folders = db.query("folders", limit=10000)
|
all_folders = db.query("folders", limit=10000)
|
||||||
folder_count = len(all_folders or [])
|
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