Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: document tags and label system (#133)' (#112) from feat/document-tags into main

scott 3467a4ab 1f5ca317

+377 -5
+18 -3
server/index.ts
··· 32 32 share_mode: 'edit' | 'view' | null; 33 33 expires_at: string | null; 34 34 deleted_at: string | null; 35 + tags: string | null; 35 36 created_at: string; 36 37 updated_at: string; 37 38 } ··· 91 92 getVersionSnapshot: Statement; 92 93 countVersions: Statement; 93 94 updateShare: Statement; 95 + putTags: Statement; 94 96 } 95 97 96 98 // --- Setup --- ··· 154 156 db.exec("ALTER TABLE documents ADD COLUMN deleted_at TEXT"); 155 157 console.log('Migrated: added deleted_at column'); 156 158 } 159 + try { 160 + db.prepare("SELECT tags FROM documents LIMIT 1").get(); 161 + } catch { 162 + db.exec("ALTER TABLE documents ADD COLUMN tags TEXT"); 163 + console.log('Migrated: added tags column'); 164 + } 157 165 db.exec(` 158 166 CREATE TABLE IF NOT EXISTS versions ( 159 167 id TEXT PRIMARY KEY, ··· 178 186 179 187 const stmts: PreparedStatements = { 180 188 insert: db.prepare('INSERT INTO documents (id, type, name_encrypted) VALUES (?, ?, ?)'), 181 - getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, created_at, updated_at FROM documents WHERE id = ?'), 182 - getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, created_at, updated_at FROM documents WHERE deleted_at IS NULL ORDER BY updated_at DESC'), 183 - getTrash: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, created_at, updated_at FROM documents WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC'), 189 + getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, created_at, updated_at FROM documents WHERE id = ?'), 190 + getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, created_at, updated_at FROM documents WHERE deleted_at IS NULL ORDER BY updated_at DESC'), 191 + getTrash: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, created_at, updated_at FROM documents WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC'), 184 192 getSnapshot: db.prepare('SELECT snapshot, expires_at FROM documents WHERE id = ?'), 185 193 putSnapshot: db.prepare("UPDATE documents SET snapshot = ?, updated_at = datetime('now') WHERE id = ?"), 186 194 putName: db.prepare("UPDATE documents SET name_encrypted = ?, updated_at = datetime('now') WHERE id = ?"), ··· 194 202 countVersions: db.prepare('SELECT COUNT(*) as count FROM versions WHERE document_id = ?'), 195 203 // Sharing 196 204 updateShare: db.prepare("UPDATE documents SET share_mode = ?, expires_at = ?, updated_at = datetime('now') WHERE id = ?"), 205 + putTags: db.prepare("UPDATE documents SET tags = ?, updated_at = datetime('now') WHERE id = ?"), 197 206 }; 198 207 199 208 // --- Express --- ··· 250 259 app.put('/api/documents/:id/name', (req: Request<{ id: string }, unknown, UpdateNameBody>, res: Response) => { 251 260 const { name_encrypted } = req.body; 252 261 stmts.putName.run(name_encrypted, req.params.id); 262 + res.json({ ok: true }); 263 + }); 264 + 265 + app.put('/api/documents/:id/tags', (req: Request<{ id: string }, unknown, { tags: string }>, res: Response) => { 266 + const { tags } = req.body; 267 + stmts.putTags.run(tags ?? null, req.params.id); 253 268 res.json({ ok: true }); 254 269 }); 255 270
+56 -2
src/css/app.css
··· 555 555 color: var(--color-text-muted); 556 556 } 557 557 558 + /* --- Document Tags (#133) --- */ 559 + .doc-item-tags { 560 + display: inline-flex; 561 + gap: 4px; 562 + flex-shrink: 0; 563 + } 564 + 565 + .doc-tag-pill { 566 + display: inline-block; 567 + padding: 1px 6px; 568 + font-size: 0.7rem; 569 + border-radius: 9px; 570 + background: var(--color-surface-raised, #e8e8e8); 571 + color: var(--color-text-muted, #666); 572 + white-space: nowrap; 573 + } 574 + 575 + .tag-filter-bar { 576 + display: flex; 577 + align-items: center; 578 + gap: 6px; 579 + padding: 8px 0; 580 + flex-wrap: wrap; 581 + } 582 + 583 + .tag-filter-label { 584 + font-size: 0.8rem; 585 + color: var(--color-text-muted); 586 + margin-right: 2px; 587 + } 588 + 589 + .tag-filter-pill { 590 + padding: 2px 10px; 591 + font-size: 0.75rem; 592 + border: 1px solid var(--color-border); 593 + border-radius: 12px; 594 + background: transparent; 595 + color: var(--color-text); 596 + cursor: pointer; 597 + transition: background var(--transition-fast), border-color var(--transition-fast); 598 + } 599 + 600 + .tag-filter-pill:hover { 601 + background: var(--color-surface-raised, #f0f0f0); 602 + } 603 + 604 + .tag-filter-pill.active { 605 + background: var(--color-accent, #0563C1); 606 + color: #fff; 607 + border-color: var(--color-accent, #0563C1); 608 + } 609 + 558 610 .doc-item-delete, 559 611 .doc-item-duplicate, 560 - .doc-item-move { 612 + .doc-item-move, 613 + .doc-item-tag-edit { 561 614 opacity: 0; 562 615 transition: opacity var(--transition-fast); 563 616 } 564 617 .doc-item:hover .doc-item-delete, 565 618 .doc-item:hover .doc-item-duplicate, 566 - .doc-item:hover .doc-item-move { opacity: 1; } 619 + .doc-item:hover .doc-item-move, 620 + .doc-item:hover .doc-item-tag-edit { opacity: 1; } 567 621 568 622 /* Star button */ 569 623 .doc-star {
+1
src/landing-types.ts
··· 7 7 type: 'doc' | 'sheet'; 8 8 name_encrypted: string | null; 9 9 deleted_at: string | null; 10 + tags: string | null; 10 11 created_at: string; 11 12 updated_at: string; 12 13 _decryptedName?: string;
+64
src/landing.ts
··· 1 1 import type { DocumentMeta, Folder, FolderAssignments, StarMap, SortLabels } from './landing-types.js'; 2 2 import { generateKey, exportKey, importKey, decryptString } from './lib/crypto.js'; 3 3 import { createCommandPalette, type PaletteAction } from './command-palette.js'; 4 + import { parseTags, addTag, removeTag, saveDocumentTags, collectAllTags, filterByTag } from './tags.js'; 4 5 import { 5 6 sortDocuments, 6 7 toggleStar, ··· 69 70 let currentFolderId: string | null = null; // null = root / All Documents 70 71 let recentIds: string[] = JSON.parse(localStorage.getItem('tools-recent') || '[]'); 71 72 let searchQuery = ''; 73 + let activeTagFilter: string | null = null; 72 74 let trashExpanded = false; 73 75 74 76 // Folder modal state ··· 368 370 }); 369 371 } 370 372 373 + function renderTagFilter(docs: DocumentMeta[]) { 374 + let tagBarEl = document.getElementById('tag-filter-bar'); 375 + const allTags = collectAllTags(docs); 376 + if (allTags.length === 0) { 377 + if (tagBarEl) tagBarEl.innerHTML = ''; 378 + return; 379 + } 380 + if (!tagBarEl) { 381 + tagBarEl = document.createElement('div'); 382 + tagBarEl.id = 'tag-filter-bar'; 383 + tagBarEl.className = 'tag-filter-bar'; 384 + const parent = docListEl.parentElement; 385 + if (parent) parent.insertBefore(tagBarEl, docListEl); 386 + } 387 + let html = '<span class="tag-filter-label">Tags:</span>'; 388 + html += `<button class="tag-filter-pill${!activeTagFilter ? ' active' : ''}" data-tag="">All</button>`; 389 + for (const tag of allTags) { 390 + const isActive = activeTagFilter === tag; 391 + html += `<button class="tag-filter-pill${isActive ? ' active' : ''}" data-tag="${escapeHtml(tag)}">${escapeHtml(tag)}</button>`; 392 + } 393 + tagBarEl.innerHTML = html; 394 + 395 + tagBarEl.querySelectorAll('.tag-filter-pill').forEach(btn => { 396 + btn.addEventListener('click', () => { 397 + const tag = (btn as HTMLElement).dataset.tag || null; 398 + activeTagFilter = tag || null; 399 + renderDocuments(); 400 + }); 401 + }); 402 + } 403 + 371 404 function renderDocuments() { 372 405 const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 373 406 const starSet = starredIdsSet(stars); ··· 387 420 // Apply search filter 388 421 visibleDocs = filterBySearch(visibleDocs, searchQuery); 389 422 423 + // Apply tag filter 424 + if (activeTagFilter) { 425 + visibleDocs = filterByTag(visibleDocs, activeTagFilter) as DocumentMeta[]; 426 + } 427 + 428 + // Render tag filter bar 429 + renderTagFilter(active); 430 + 390 431 // Apply sort 391 432 const sorted = sortDocuments(visibleDocs, currentSort, starSet); 392 433 ··· 421 462 month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', 422 463 }); 423 464 465 + const docTags = parseTags(doc.tags); 466 + const tagsHtml = docTags.map(t => `<span class="doc-tag-pill">${escapeHtml(t)}</span>`).join(''); 467 + 424 468 html += ` 425 469 <a class="doc-item" href="${href}" ${!keyStr ? 'title="Key not available — you need the original link to decrypt"' : ''} data-doc-id="${doc.id}"> 426 470 <button class="btn-icon doc-star" data-id="${doc.id}" title="${isStarred ? 'Remove from favorites' : 'Add to favorites'}">${isStarred ? '\u2605' : '\u2606'}</button> 427 471 <span class="doc-item-icon">${icon}</span> 428 472 <span class="doc-item-name">${escapeHtml(name)}</span> 473 + ${tagsHtml ? `<span class="doc-item-tags">${tagsHtml}</span>` : ''} 429 474 <span class="doc-item-type">${doc.type}</span> 430 475 <span class="doc-item-date">${date}</span> 476 + <button class="btn-icon doc-item-tag-edit" data-id="${doc.id}" title="Edit tags">&#127991;</button> 431 477 <button class="btn-icon doc-item-move" data-id="${doc.id}" title="Move to folder">&#128193;</button> 432 478 <button class="btn-icon doc-item-duplicate" data-id="${doc.id}" title="Duplicate">&#10697;</button> 433 479 <button class="btn-icon doc-item-delete" data-id="${doc.id}" title="Move to trash">&#10005;</button> ··· 530 576 } catch { 531 577 showToast('Failed to duplicate document', 4000, true); 532 578 } 579 + }); 580 + }); 581 + 582 + // Tag edit handlers 583 + docListEl.querySelectorAll('.doc-item-tag-edit').forEach(btn => { 584 + btn.addEventListener('click', (e) => { 585 + e.preventDefault(); 586 + e.stopPropagation(); 587 + const id = (btn as HTMLElement).dataset.id; 588 + const doc = allDocs.find(d => d.id === id); 589 + if (!doc) return; 590 + const current = parseTags(doc.tags); 591 + const input = prompt('Tags (comma-separated):', current.join(', ')); 592 + if (input === null) return; 593 + const newTags = input.split(',').map(t => t.trim()).filter(Boolean); 594 + doc.tags = JSON.stringify(newTags); 595 + if (id) saveDocumentTags(id, newTags); 596 + renderDocuments(); 533 597 }); 534 598 }); 535 599
+89
src/tags.ts
··· 1 + /** 2 + * Document Tags (#133) 3 + * 4 + * Client-side tag management: parsing, rendering, and API calls. 5 + * Tags are stored as encrypted JSON strings on the server. 6 + * On the client, they're decrypted to string arrays. 7 + */ 8 + 9 + /** 10 + * Parse a tags string (JSON array or null) into a string array. 11 + */ 12 + export function parseTags(raw: string | null | undefined): string[] { 13 + if (!raw) return []; 14 + try { 15 + const parsed = JSON.parse(raw); 16 + if (Array.isArray(parsed)) return parsed.filter((t: unknown) => typeof t === 'string' && t.length > 0); 17 + } catch { 18 + // Not valid JSON — ignore 19 + } 20 + return []; 21 + } 22 + 23 + /** 24 + * Serialize a tags array to JSON string. 25 + */ 26 + export function serializeTags(tags: string[]): string { 27 + return JSON.stringify(tags); 28 + } 29 + 30 + /** 31 + * Normalize a tag: lowercase, trim, collapse whitespace. 32 + */ 33 + export function normalizeTag(tag: string): string { 34 + return tag.trim().toLowerCase().replace(/\s+/g, ' '); 35 + } 36 + 37 + /** 38 + * Add a tag to an array (deduplicating, normalized). 39 + */ 40 + export function addTag(tags: string[], newTag: string): string[] { 41 + const normalized = normalizeTag(newTag); 42 + if (!normalized) return tags; 43 + if (tags.some(t => normalizeTag(t) === normalized)) return tags; 44 + return [...tags, normalized]; 45 + } 46 + 47 + /** 48 + * Remove a tag from an array. 49 + */ 50 + export function removeTag(tags: string[], tagToRemove: string): string[] { 51 + const normalized = normalizeTag(tagToRemove); 52 + return tags.filter(t => normalizeTag(t) !== normalized); 53 + } 54 + 55 + /** 56 + * Save tags to the server for a document. 57 + */ 58 + export async function saveDocumentTags(docId: string, tags: string[]): Promise<void> { 59 + await fetch(`/api/documents/${docId}/tags`, { 60 + method: 'PUT', 61 + headers: { 'Content-Type': 'application/json' }, 62 + body: JSON.stringify({ tags: serializeTags(tags) }), 63 + }); 64 + } 65 + 66 + /** 67 + * Collect all unique tags across documents. 68 + */ 69 + export function collectAllTags(docs: Array<{ tags?: string | null }>): string[] { 70 + const tagSet = new Set<string>(); 71 + for (const doc of docs) { 72 + for (const tag of parseTags(doc.tags)) { 73 + tagSet.add(normalizeTag(tag)); 74 + } 75 + } 76 + return Array.from(tagSet).sort(); 77 + } 78 + 79 + /** 80 + * Filter documents by a selected tag. 81 + */ 82 + export function filterByTag( 83 + docs: Array<{ tags?: string | null }>, 84 + tag: string | null, 85 + ): Array<{ tags?: string | null }> { 86 + if (!tag) return docs; 87 + const normalized = normalizeTag(tag); 88 + return docs.filter(doc => parseTags(doc.tags).some(t => normalizeTag(t) === normalized)); 89 + }
+149
tests/tags.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + parseTags, 4 + serializeTags, 5 + normalizeTag, 6 + addTag, 7 + removeTag, 8 + collectAllTags, 9 + filterByTag, 10 + } from '../src/tags.js'; 11 + 12 + describe('parseTags', () => { 13 + it('parses valid JSON array', () => { 14 + expect(parseTags('["work","urgent"]')).toEqual(['work', 'urgent']); 15 + }); 16 + 17 + it('returns empty array for null', () => { 18 + expect(parseTags(null)).toEqual([]); 19 + }); 20 + 21 + it('returns empty array for undefined', () => { 22 + expect(parseTags(undefined)).toEqual([]); 23 + }); 24 + 25 + it('returns empty array for empty string', () => { 26 + expect(parseTags('')).toEqual([]); 27 + }); 28 + 29 + it('returns empty array for invalid JSON', () => { 30 + expect(parseTags('not json')).toEqual([]); 31 + }); 32 + 33 + it('filters out non-string entries', () => { 34 + expect(parseTags('[1, "valid", null, "also"]')).toEqual(['valid', 'also']); 35 + }); 36 + 37 + it('filters out empty strings', () => { 38 + expect(parseTags('["", "real"]')).toEqual(['real']); 39 + }); 40 + }); 41 + 42 + describe('serializeTags', () => { 43 + it('serializes to JSON', () => { 44 + expect(serializeTags(['a', 'b'])).toBe('["a","b"]'); 45 + }); 46 + 47 + it('serializes empty array', () => { 48 + expect(serializeTags([])).toBe('[]'); 49 + }); 50 + }); 51 + 52 + describe('normalizeTag', () => { 53 + it('lowercases', () => { 54 + expect(normalizeTag('WORK')).toBe('work'); 55 + }); 56 + 57 + it('trims whitespace', () => { 58 + expect(normalizeTag(' draft ')).toBe('draft'); 59 + }); 60 + 61 + it('collapses internal whitespace', () => { 62 + expect(normalizeTag('to do')).toBe('to do'); 63 + }); 64 + 65 + it('handles empty string', () => { 66 + expect(normalizeTag('')).toBe(''); 67 + }); 68 + }); 69 + 70 + describe('addTag', () => { 71 + it('adds a new tag', () => { 72 + expect(addTag(['work'], 'urgent')).toEqual(['work', 'urgent']); 73 + }); 74 + 75 + it('deduplicates case-insensitively', () => { 76 + expect(addTag(['work'], 'WORK')).toEqual(['work']); 77 + }); 78 + 79 + it('ignores empty tag', () => { 80 + expect(addTag(['work'], '')).toEqual(['work']); 81 + }); 82 + 83 + it('ignores whitespace-only tag', () => { 84 + expect(addTag(['work'], ' ')).toEqual(['work']); 85 + }); 86 + 87 + it('normalizes before adding', () => { 88 + expect(addTag([], ' My Tag ')).toEqual(['my tag']); 89 + }); 90 + }); 91 + 92 + describe('removeTag', () => { 93 + it('removes a tag', () => { 94 + expect(removeTag(['work', 'urgent'], 'work')).toEqual(['urgent']); 95 + }); 96 + 97 + it('removes case-insensitively', () => { 98 + expect(removeTag(['Work', 'urgent'], 'work')).toEqual(['urgent']); 99 + }); 100 + 101 + it('returns unchanged array if tag not found', () => { 102 + expect(removeTag(['work'], 'missing')).toEqual(['work']); 103 + }); 104 + }); 105 + 106 + describe('collectAllTags', () => { 107 + it('collects unique tags across documents', () => { 108 + const docs = [ 109 + { tags: '["work","draft"]' }, 110 + { tags: '["work","final"]' }, 111 + { tags: null }, 112 + ]; 113 + expect(collectAllTags(docs)).toEqual(['draft', 'final', 'work']); 114 + }); 115 + 116 + it('returns empty array for no documents', () => { 117 + expect(collectAllTags([])).toEqual([]); 118 + }); 119 + 120 + it('handles all null tags', () => { 121 + expect(collectAllTags([{ tags: null }, { tags: null }])).toEqual([]); 122 + }); 123 + }); 124 + 125 + describe('filterByTag', () => { 126 + const docs = [ 127 + { id: '1', tags: '["work","draft"]' }, 128 + { id: '2', tags: '["personal"]' }, 129 + { id: '3', tags: null }, 130 + ]; 131 + 132 + it('filters by matching tag', () => { 133 + const result = filterByTag(docs, 'work'); 134 + expect(result).toHaveLength(1); 135 + expect(result[0]).toBe(docs[0]); 136 + }); 137 + 138 + it('returns all docs when tag is null', () => { 139 + expect(filterByTag(docs, null)).toEqual(docs); 140 + }); 141 + 142 + it('is case-insensitive', () => { 143 + expect(filterByTag(docs, 'PERSONAL')).toHaveLength(1); 144 + }); 145 + 146 + it('returns empty for non-existent tag', () => { 147 + expect(filterByTag(docs, 'nonexistent')).toHaveLength(0); 148 + }); 149 + });