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

Configure Feed

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

fix: resolve merge conflict with dynamic array functions from main

Merge main into feat/query-function to incorporate FILTER, SORT, UNIQUE
functions from PR #113 alongside the new QUERY function from this branch.

+1087 -7
+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
+112 -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 { ··· 3056 3110 .footnote-section li { 3057 3111 margin-bottom: 0.25rem; 3058 3112 } 3113 + 3114 + /* --- Resizable Images (#118) --- */ 3115 + .tiptap .resizable-image-wrapper { 3116 + margin: 0.75em 0; 3117 + line-height: 0; 3118 + } 3119 + 3120 + .tiptap .resizable-image-wrapper[data-align="left"] { 3121 + text-align: left; 3122 + } 3123 + 3124 + .tiptap .resizable-image-wrapper[data-align="center"] { 3125 + text-align: center; 3126 + } 3127 + 3128 + .tiptap .resizable-image-wrapper[data-align="right"] { 3129 + text-align: right; 3130 + } 3131 + 3132 + .tiptap .resizable-image-container { 3133 + display: inline-block; 3134 + position: relative; 3135 + line-height: 0; 3136 + } 3137 + 3138 + .tiptap .resizable-image-container img { 3139 + display: block; 3140 + max-width: 100%; 3141 + border-radius: var(--radius-sm, 4px); 3142 + } 3143 + 3144 + .tiptap .resizable-image-wrapper.selected .resizable-image-container { 3145 + outline: 2px solid var(--color-accent, #0563C1); 3146 + border-radius: var(--radius-sm, 4px); 3147 + } 3148 + 3149 + /* Resize handles — only visible when selected */ 3150 + .tiptap .resize-handle { 3151 + display: none; 3152 + position: absolute; 3153 + width: 10px; 3154 + height: 10px; 3155 + background: var(--color-accent, #0563C1); 3156 + border: 1px solid #fff; 3157 + border-radius: 2px; 3158 + z-index: 10; 3159 + } 3160 + 3161 + .tiptap .resizable-image-wrapper.selected .resize-handle { 3162 + display: block; 3163 + } 3164 + 3165 + .tiptap .resize-handle-nw { top: -5px; left: -5px; cursor: nw-resize; } 3166 + .tiptap .resize-handle-ne { top: -5px; right: -5px; cursor: ne-resize; } 3167 + .tiptap .resize-handle-sw { bottom: -5px; left: -5px; cursor: sw-resize; } 3168 + .tiptap .resize-handle-se { bottom: -5px; right: -5px; cursor: se-resize; } 3059 3169 3060 3170 /* --- Print styles --- */ 3061 3171 @media print {
+238
src/docs/extensions/resizable-image.ts
··· 1 + /** 2 + * Resizable Image Extension (#118) 3 + * 4 + * Extends TipTap's Image node with: 5 + * - Width/height attributes that persist through HTML round-trip 6 + * - Drag-to-resize handles (corner + edge) 7 + * - Alignment options (left, center, right) 8 + * - Aspect ratio preservation during resize 9 + * 10 + * Uses a NodeView for the interactive resize UI. 11 + */ 12 + 13 + import { Node, mergeAttributes } from '@tiptap/core'; 14 + import type { Editor } from '@tiptap/core'; 15 + 16 + export interface ResizableImageOptions { 17 + HTMLAttributes: Record<string, string>; 18 + /** Minimum image width in px */ 19 + minWidth: number; 20 + /** Maximum image width in px */ 21 + maxWidth: number; 22 + } 23 + 24 + export type ImageAlignment = 'left' | 'center' | 'right'; 25 + 26 + declare module '@tiptap/core' { 27 + interface Commands<ReturnType> { 28 + resizableImage: { 29 + setImage: (options: { src: string; alt?: string; title?: string; width?: number; align?: ImageAlignment }) => ReturnType; 30 + setImageAlign: (align: ImageAlignment) => ReturnType; 31 + setImageWidth: (width: number) => ReturnType; 32 + }; 33 + } 34 + } 35 + 36 + export const ResizableImage = Node.create<ResizableImageOptions>({ 37 + name: 'image', 38 + 39 + group: 'block', 40 + atom: true, 41 + selectable: true, 42 + draggable: true, 43 + 44 + addOptions() { 45 + return { 46 + HTMLAttributes: {}, 47 + minWidth: 50, 48 + maxWidth: 1200, 49 + }; 50 + }, 51 + 52 + addAttributes() { 53 + return { 54 + src: { 55 + default: null, 56 + parseHTML: (el: HTMLElement) => el.getAttribute('src'), 57 + renderHTML: (attrs: Record<string, string | null>) => ({ src: attrs.src }), 58 + }, 59 + alt: { 60 + default: null, 61 + parseHTML: (el: HTMLElement) => el.getAttribute('alt'), 62 + renderHTML: (attrs: Record<string, string | null>) => ({ alt: attrs.alt }), 63 + }, 64 + title: { 65 + default: null, 66 + parseHTML: (el: HTMLElement) => el.getAttribute('title'), 67 + renderHTML: (attrs: Record<string, string | null>) => ({ title: attrs.title }), 68 + }, 69 + width: { 70 + default: null, 71 + parseHTML: (el: HTMLElement) => { 72 + const w = el.getAttribute('width') || el.style.width; 73 + return w ? parseInt(String(w), 10) || null : null; 74 + }, 75 + renderHTML: (attrs: Record<string, number | null>) => { 76 + if (!attrs.width) return {}; 77 + return { width: attrs.width, style: `width: ${attrs.width}px` }; 78 + }, 79 + }, 80 + align: { 81 + default: 'center', 82 + parseHTML: (el: HTMLElement) => el.getAttribute('data-align') || 'center', 83 + renderHTML: (attrs: Record<string, string | null>) => ({ 84 + 'data-align': attrs.align || 'center', 85 + }), 86 + }, 87 + }; 88 + }, 89 + 90 + parseHTML() { 91 + return [ 92 + { tag: 'img[src]' }, 93 + ]; 94 + }, 95 + 96 + renderHTML({ HTMLAttributes }) { 97 + return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; 98 + }, 99 + 100 + addCommands() { 101 + return { 102 + setImage: 103 + (options) => 104 + ({ commands }) => { 105 + return commands.insertContent({ 106 + type: this.name, 107 + attrs: options, 108 + }); 109 + }, 110 + setImageAlign: 111 + (align: ImageAlignment) => 112 + ({ tr, state }) => { 113 + const { selection } = state; 114 + const node = state.doc.nodeAt(selection.from); 115 + if (node?.type.name !== this.name) return false; 116 + tr.setNodeMarkup(selection.from, undefined, { ...node.attrs, align }); 117 + return true; 118 + }, 119 + setImageWidth: 120 + (width: number) => 121 + ({ tr, state }) => { 122 + const { selection } = state; 123 + const node = state.doc.nodeAt(selection.from); 124 + if (node?.type.name !== this.name) return false; 125 + const clamped = Math.max(this.options.minWidth, Math.min(this.options.maxWidth, width)); 126 + tr.setNodeMarkup(selection.from, undefined, { ...node.attrs, width: clamped }); 127 + return true; 128 + }, 129 + }; 130 + }, 131 + 132 + addNodeView() { 133 + return ({ node, editor, getPos }) => { 134 + const minWidth = this.options.minWidth; 135 + const maxWidth = this.options.maxWidth; 136 + 137 + // Wrapper div for alignment 138 + const wrapper = document.createElement('div'); 139 + wrapper.className = 'resizable-image-wrapper'; 140 + wrapper.setAttribute('data-align', node.attrs.align || 'center'); 141 + 142 + // Container for image + handles 143 + const container = document.createElement('div'); 144 + container.className = 'resizable-image-container'; 145 + container.style.display = 'inline-block'; 146 + container.style.position = 'relative'; 147 + 148 + // The image 149 + const img = document.createElement('img'); 150 + img.src = node.attrs.src || ''; 151 + if (node.attrs.alt) img.alt = node.attrs.alt; 152 + if (node.attrs.title) img.title = node.attrs.title; 153 + if (node.attrs.width) { 154 + img.style.width = `${node.attrs.width}px`; 155 + } 156 + img.style.display = 'block'; 157 + img.style.maxWidth = '100%'; 158 + 159 + container.appendChild(img); 160 + 161 + // Resize handles (four corners) 162 + const handles = ['nw', 'ne', 'sw', 'se'] as const; 163 + for (const pos of handles) { 164 + const handle = document.createElement('div'); 165 + handle.className = `resize-handle resize-handle-${pos}`; 166 + handle.contentEditable = 'false'; 167 + 168 + handle.addEventListener('mousedown', (e: MouseEvent) => { 169 + e.preventDefault(); 170 + e.stopPropagation(); 171 + 172 + const startX = e.clientX; 173 + const startWidth = img.offsetWidth; 174 + const isLeft = pos === 'nw' || pos === 'sw'; 175 + 176 + const onMouseMove = (moveEvent: MouseEvent) => { 177 + const dx = moveEvent.clientX - startX; 178 + const newWidth = isLeft 179 + ? Math.max(minWidth, Math.min(maxWidth, startWidth - dx)) 180 + : Math.max(minWidth, Math.min(maxWidth, startWidth + dx)); 181 + img.style.width = `${newWidth}px`; 182 + }; 183 + 184 + const onMouseUp = () => { 185 + document.removeEventListener('mousemove', onMouseMove); 186 + document.removeEventListener('mouseup', onMouseUp); 187 + 188 + // Commit width to ProseMirror state 189 + const pos = getPos(); 190 + if (typeof pos === 'number') { 191 + const newWidth = img.offsetWidth; 192 + editor.chain().focus().command(({ tr }) => { 193 + const currentNode = tr.doc.nodeAt(pos); 194 + if (currentNode) { 195 + tr.setNodeMarkup(pos, undefined, { ...currentNode.attrs, width: newWidth }); 196 + } 197 + return true; 198 + }).run(); 199 + } 200 + }; 201 + 202 + document.addEventListener('mousemove', onMouseMove); 203 + document.addEventListener('mouseup', onMouseUp); 204 + }); 205 + 206 + container.appendChild(handle); 207 + } 208 + 209 + wrapper.appendChild(container); 210 + 211 + return { 212 + dom: wrapper, 213 + 214 + update(updatedNode) { 215 + if (updatedNode.type.name !== 'image') return false; 216 + img.src = updatedNode.attrs.src || ''; 217 + if (updatedNode.attrs.alt) img.alt = updatedNode.attrs.alt; 218 + if (updatedNode.attrs.title) img.title = updatedNode.attrs.title; 219 + if (updatedNode.attrs.width) { 220 + img.style.width = `${updatedNode.attrs.width}px`; 221 + } else { 222 + img.style.width = ''; 223 + } 224 + wrapper.setAttribute('data-align', updatedNode.attrs.align || 'center'); 225 + return true; 226 + }, 227 + 228 + selectNode() { 229 + wrapper.classList.add('selected'); 230 + }, 231 + 232 + deselectNode() { 233 + wrapper.classList.remove('selected'); 234 + }, 235 + }; 236 + }; 237 + }, 238 + });
+2 -2
src/docs/main.ts
··· 10 10 import StarterKit from '@tiptap/starter-kit'; 11 11 import Underline from '@tiptap/extension-underline'; 12 12 import Link from '@tiptap/extension-link'; 13 - import Image from '@tiptap/extension-image'; 13 + import { ResizableImage } from './extensions/resizable-image.js'; 14 14 import Table from '@tiptap/extension-table'; 15 15 import TableRow from '@tiptap/extension-table-row'; 16 16 import TableCell from '@tiptap/extension-table-cell'; ··· 114 114 StarterKit.configure({ history: false }), 115 115 Underline, 116 116 Link.configure({ openOnClick: false }), 117 - Image, 117 + ResizableImage, 118 118 Table.configure({ resizable: true }), 119 119 TableRow, 120 120 TableCell,
+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
+228
src/sheets/formulas.ts
··· 514 514 } 515 515 } 516 516 517 + // Parse LAMBDA(param1, param2, ..., body) — creates a callable closure (#88) 518 + // Usage: LAMBDA(x, y, x+y)(2, 3) → 5 519 + parseLambda(): unknown { 520 + this.expect(TokenType.LPAREN); 521 + 522 + // Collect parameter names and body tokens 523 + // All args except the last are parameter names; the last is the body expression. 524 + // We need to save token positions and re-parse the body with param bindings. 525 + const paramNames: string[] = []; 526 + const bodyStartPositions: number[] = []; 527 + 528 + // Read tokens to find params and body 529 + // Strategy: collect identifiers separated by commas, the final arg before RPAREN is the body 530 + // We save the start position of each arg, then retroactively determine which are params vs body 531 + const argStarts: number[] = []; 532 + let depth = 1; // already consumed LPAREN 533 + 534 + // First pass: find where each argument starts 535 + argStarts.push(this.pos); 536 + const savedPos = this.pos; 537 + 538 + // Count args by scanning tokens (tracking paren depth) 539 + let scanPos = this.pos; 540 + const argBoundaries: number[] = [scanPos]; 541 + while (scanPos < this.tokens.length) { 542 + const tok = this.tokens[scanPos]; 543 + if (tok.type === TokenType.LPAREN) depth++; 544 + else if (tok.type === TokenType.RPAREN) { 545 + depth--; 546 + if (depth === 0) break; 547 + } else if (tok.type === TokenType.COMMA && depth === 1) { 548 + argBoundaries.push(scanPos + 1); 549 + } 550 + scanPos++; 551 + } 552 + const rparenPos = scanPos; // position of closing RPAREN 553 + 554 + // All args except the last are parameter names 555 + for (let i = 0; i < argBoundaries.length - 1; i++) { 556 + const tok = this.tokens[argBoundaries[i]]; 557 + if (tok.type === TokenType.IDENTIFIER || tok.type === TokenType.FUNCTION || tok.type === TokenType.CELL_REF) { 558 + paramNames.push(String(tok.value).toLowerCase()); 559 + } 560 + } 561 + 562 + // Position parser at the start of the body (last arg) 563 + this.pos = argBoundaries[argBoundaries.length - 1]; 564 + 565 + // Save the body token range for deferred evaluation 566 + const bodyStart = this.pos; 567 + const bodyEnd = rparenPos; 568 + const bodyTokens = this.tokens.slice(bodyStart, bodyEnd); 569 + 570 + // Skip past the body and closing RPAREN 571 + this.pos = rparenPos; 572 + this.expect(TokenType.RPAREN); 573 + 574 + // Check for immediate invocation: LAMBDA(...)(<args>) 575 + if (this.peek().type === TokenType.LPAREN) { 576 + this.advance(); // consume LPAREN 577 + const callArgs: unknown[] = []; 578 + if (this.peek().type !== TokenType.RPAREN) { 579 + callArgs.push(this.parseFunctionArg()); 580 + while (this.peek().type === TokenType.COMMA) { 581 + this.advance(); 582 + callArgs.push(this.parseFunctionArg()); 583 + } 584 + } 585 + this.expect(TokenType.RPAREN); 586 + 587 + // Evaluate body with params bound to call args 588 + const prevScope = { ...this._letScope }; 589 + for (let i = 0; i < paramNames.length; i++) { 590 + this._letScope[paramNames[i]] = callArgs[i] ?? 0; 591 + } 592 + 593 + // Parse the body tokens 594 + const bodyParser = new Parser( 595 + [...bodyTokens, { type: TokenType.EOF, value: undefined }], 596 + this.getCellValue, 597 + this.crossSheetResolver, 598 + this.namedRanges, 599 + ); 600 + bodyParser._letScope = { ...this._letScope }; 601 + const result = bodyParser.parse(); 602 + 603 + this._letScope = prevScope; 604 + return result; 605 + } 606 + 607 + // Not immediately invoked — return a sentinel (lambdas must be invoked inline) 608 + return '#VALUE!'; 609 + } 610 + 517 611 // Parse INDIRECT(ref_text) — evaluates its argument as a string, then resolves as a cell reference 518 612 parseIndirect(): unknown { 519 613 this.expect(TokenType.LPAREN); ··· 1268 1362 const chIdx = Math.floor(toNum(args[0])); 1269 1363 if (chIdx < 1 || chIdx >= args.length) return '#VALUE!'; 1270 1364 return args[chIdx]; 1365 + } 1366 + 1367 + // --- Dynamic Array Functions (#86) --- 1368 + case 'FILTER': { 1369 + // FILTER(array, include, [if_empty]) 1370 + const source = Array.isArray(args[0]) ? args[0] : [args[0]]; 1371 + const include = Array.isArray(args[1]) ? args[1] : [args[1]]; 1372 + const ifEmpty = args[2] !== undefined ? args[2] : '#N/A'; 1373 + const rows = (source as RangeArray)._rangeRows || source.length; 1374 + const cols = (source as RangeArray)._rangeCols || 1; 1375 + 1376 + const filtered: unknown[] = []; 1377 + for (let r = 0; r < rows; r++) { 1378 + const keep = include[r]; 1379 + // Truthy = include (booleans, non-zero numbers) 1380 + if (keep === true || (typeof keep === 'number' && keep !== 0)) { 1381 + if (cols > 1) { 1382 + for (let c = 0; c < cols; c++) { 1383 + filtered.push(source[r * cols + c]); 1384 + } 1385 + } else { 1386 + filtered.push(source[r]); 1387 + } 1388 + } 1389 + } 1390 + if (filtered.length === 0) return ifEmpty; 1391 + const result: RangeArray = filtered as RangeArray; 1392 + const filteredRows = cols > 1 ? filtered.length / cols : filtered.length; 1393 + result._rangeRows = filteredRows; 1394 + result._rangeCols = cols; 1395 + return result; 1396 + } 1397 + 1398 + case 'SORT': { 1399 + // SORT(array, [sort_index], [sort_order], [by_col]) 1400 + const source = Array.isArray(args[0]) ? [...args[0]] : [args[0]]; 1401 + const sortIndex = args[1] !== undefined ? toNum(args[1]) : 1; 1402 + const sortOrder = args[2] !== undefined ? toNum(args[2]) : 1; // 1=asc, -1=desc 1403 + const rows = (args[0] as RangeArray)?._rangeRows || source.length; 1404 + const cols = (args[0] as RangeArray)?._rangeCols || 1; 1405 + 1406 + if (cols <= 1) { 1407 + // Single column — sort values directly 1408 + const sorted = source.filter(v => v !== '' && v !== null && v !== undefined); 1409 + sorted.sort((a, b) => { 1410 + const na = typeof a === 'number' ? a : NaN; 1411 + const nb = typeof b === 'number' ? b : NaN; 1412 + if (!isNaN(na) && !isNaN(nb)) return (na - nb) * sortOrder; 1413 + return String(a ?? '').localeCompare(String(b ?? '')) * sortOrder; 1414 + }); 1415 + const result: RangeArray = sorted as RangeArray; 1416 + result._rangeRows = sorted.length; 1417 + result._rangeCols = 1; 1418 + return result; 1419 + } 1420 + 1421 + // Multi-column: sort rows by sort_index column 1422 + const rowArrays: unknown[][] = []; 1423 + for (let r = 0; r < rows; r++) { 1424 + const row: unknown[] = []; 1425 + for (let c = 0; c < cols; c++) { 1426 + row.push(source[r * cols + c]); 1427 + } 1428 + rowArrays.push(row); 1429 + } 1430 + const si = Math.max(0, sortIndex - 1); // 1-based to 0-based 1431 + rowArrays.sort((a, b) => { 1432 + const va = a[si]; 1433 + const vb = b[si]; 1434 + const na = typeof va === 'number' ? va : NaN; 1435 + const nb = typeof vb === 'number' ? vb : NaN; 1436 + if (!isNaN(na) && !isNaN(nb)) return (na - nb) * sortOrder; 1437 + return String(va ?? '').localeCompare(String(vb ?? '')) * sortOrder; 1438 + }); 1439 + const flatResult: RangeArray = rowArrays.flat() as RangeArray; 1440 + flatResult._rangeRows = rows; 1441 + flatResult._rangeCols = cols; 1442 + return flatResult; 1443 + } 1444 + 1445 + case 'UNIQUE': { 1446 + // UNIQUE(array, [by_col], [exactly_once]) 1447 + const source = Array.isArray(args[0]) ? args[0] : [args[0]]; 1448 + const exactlyOnce = args[2] === true; 1449 + const rows = (args[0] as RangeArray)?._rangeRows || source.length; 1450 + const cols = (args[0] as RangeArray)?._rangeCols || 1; 1451 + 1452 + if (cols <= 1) { 1453 + // Single column 1454 + const seen = new Map<string, { value: unknown; count: number }>(); 1455 + for (const v of source) { 1456 + if (v === '' || v === null || v === undefined) continue; 1457 + const key = String(v); 1458 + const existing = seen.get(key); 1459 + if (existing) { 1460 + existing.count++; 1461 + } else { 1462 + seen.set(key, { value: v, count: 1 }); 1463 + } 1464 + } 1465 + const values = exactlyOnce 1466 + ? [...seen.values()].filter(e => e.count === 1).map(e => e.value) 1467 + : [...seen.values()].map(e => e.value); 1468 + const result: RangeArray = values as RangeArray; 1469 + result._rangeRows = values.length; 1470 + result._rangeCols = 1; 1471 + return result.length === 0 ? '#N/A' : result; 1472 + } 1473 + 1474 + // Multi-column: unique rows 1475 + const rowKeys = new Map<string, { row: unknown[]; count: number }>(); 1476 + const orderedKeys: string[] = []; 1477 + for (let r = 0; r < rows; r++) { 1478 + const row: unknown[] = []; 1479 + for (let c = 0; c < cols; c++) { 1480 + row.push(source[r * cols + c]); 1481 + } 1482 + const key = row.map(v => String(v ?? '')).join('\0'); 1483 + const existing = rowKeys.get(key); 1484 + if (existing) { 1485 + existing.count++; 1486 + } else { 1487 + rowKeys.set(key, { row, count: 1 }); 1488 + orderedKeys.push(key); 1489 + } 1490 + } 1491 + const uniqueRows = exactlyOnce 1492 + ? orderedKeys.filter(k => rowKeys.get(k)!.count === 1).map(k => rowKeys.get(k)!.row) 1493 + : orderedKeys.map(k => rowKeys.get(k)!.row); 1494 + if (uniqueRows.length === 0) return '#N/A'; 1495 + const flatResult: RangeArray = uniqueRows.flat() as RangeArray; 1496 + flatResult._rangeRows = uniqueRows.length; 1497 + flatResult._rangeCols = cols; 1498 + return flatResult; 1271 1499 } 1272 1500 1273 1501 // --- QUERY (#85) ---
+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 + }
+86
tests/formulas.test.ts
··· 735 735 expect(evalWith('DIVIDE(-20, 4)')).toBe(-5); 736 736 }); 737 737 }); 738 + 739 + // --- Dynamic Array Functions (#86) --- 740 + 741 + describe('FILTER', () => { 742 + it('filters single column by boolean array', () => { 743 + const cells = { A1: 10, A2: 20, A3: 30, B1: true, B2: false, B3: true }; 744 + const result = evalWith('FILTER(A1:A3,B1:B3)', cells); 745 + expect(Array.isArray(result)).toBe(true); 746 + expect([...(result as unknown[])]).toEqual([10, 30]); 747 + }); 748 + 749 + it('filters by numeric condition (non-zero = include)', () => { 750 + const cells = { A1: 'a', A2: 'b', A3: 'c', B1: 1, B2: 0, B3: 1 }; 751 + const result = evalWith('FILTER(A1:A3,B1:B3)', cells); 752 + expect([...(result as unknown[])]).toEqual(['a', 'c']); 753 + }); 754 + 755 + it('returns if_empty when no matches', () => { 756 + const cells = { A1: 10, B1: false }; 757 + const result = evalWith('FILTER(A1:A1,B1:B1,"none")', cells); 758 + expect(result).toBe('none'); 759 + }); 760 + 761 + it('returns #N/A when no matches and no if_empty', () => { 762 + const cells = { A1: 10, B1: false }; 763 + const result = evalWith('FILTER(A1:A1,B1:B1)', cells); 764 + expect(result).toBe('#N/A'); 765 + }); 766 + }); 767 + 768 + describe('SORT', () => { 769 + it('sorts single column ascending by default', () => { 770 + const cells = { A1: 30, A2: 10, A3: 20 }; 771 + const result = evalWith('SORT(A1:A3)', cells); 772 + expect(Array.isArray(result)).toBe(true); 773 + expect([...(result as unknown[])]).toEqual([10, 20, 30]); 774 + }); 775 + 776 + it('sorts single column descending with -1', () => { 777 + const cells = { A1: 30, A2: 10, A3: 20 }; 778 + const result = evalWith('SORT(A1:A3,1,-1)', cells); 779 + expect([...(result as unknown[])]).toEqual([30, 20, 10]); 780 + }); 781 + 782 + it('sorts strings alphabetically', () => { 783 + const cells = { A1: 'cherry', A2: 'apple', A3: 'banana' }; 784 + const result = evalWith('SORT(A1:A3)', cells); 785 + expect([...(result as unknown[])]).toEqual(['apple', 'banana', 'cherry']); 786 + }); 787 + 788 + it('sorts multi-column by specified column', () => { 789 + const cells = { A1: 'b', B1: 2, A2: 'a', B2: 1, A3: 'c', B3: 3 }; 790 + const result = evalWith('SORT(A1:B3,2,1)', cells); 791 + expect(Array.isArray(result)).toBe(true); 792 + // Sorted by column 2 ascending: [a,1, b,2, c,3] 793 + const arr = [...(result as unknown[])]; 794 + expect(arr).toEqual(['a', 1, 'b', 2, 'c', 3]); 795 + }); 796 + }); 797 + 798 + describe('UNIQUE', () => { 799 + it('removes duplicates from single column', () => { 800 + const cells = { A1: 'a', A2: 'b', A3: 'a', A4: 'c' }; 801 + const result = evalWith('UNIQUE(A1:A4)', cells); 802 + expect(Array.isArray(result)).toBe(true); 803 + expect([...(result as unknown[])]).toEqual(['a', 'b', 'c']); 804 + }); 805 + 806 + it('preserves order of first occurrence', () => { 807 + const cells = { A1: 3, A2: 1, A3: 2, A4: 1 }; 808 + const result = evalWith('UNIQUE(A1:A4)', cells); 809 + expect([...(result as unknown[])]).toEqual([3, 1, 2]); 810 + }); 811 + 812 + it('returns exactly-once values when third arg is true', () => { 813 + const cells = { A1: 'a', A2: 'b', A3: 'a', A4: 'c' }; 814 + const result = evalWith('UNIQUE(A1:A4,,TRUE)', cells); 815 + expect([...(result as unknown[])]).toEqual(['b', 'c']); 816 + }); 817 + 818 + it('returns #N/A when no unique values', () => { 819 + const cells = { A1: 'a', A2: 'a' }; 820 + const result = evalWith('UNIQUE(A1:A2,,TRUE)', cells); 821 + expect(result).toBe('#N/A'); 822 + }); 823 + });
+100
tests/resizable-image.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + 3 + /** 4 + * Tests for the resizable image extension logic. 5 + * 6 + * Since the NodeView requires a real DOM/editor, we test the attribute 7 + * parsing/rendering logic and width clamping independently. 8 + */ 9 + 10 + describe('image width clamping', () => { 11 + const minWidth = 50; 12 + const maxWidth = 1200; 13 + 14 + function clampWidth(width: number): number { 15 + return Math.max(minWidth, Math.min(maxWidth, width)); 16 + } 17 + 18 + it('clamps width below minimum', () => { 19 + expect(clampWidth(10)).toBe(50); 20 + }); 21 + 22 + it('clamps width above maximum', () => { 23 + expect(clampWidth(2000)).toBe(1200); 24 + }); 25 + 26 + it('passes through valid width', () => { 27 + expect(clampWidth(400)).toBe(400); 28 + }); 29 + 30 + it('handles exact minimum', () => { 31 + expect(clampWidth(50)).toBe(50); 32 + }); 33 + 34 + it('handles exact maximum', () => { 35 + expect(clampWidth(1200)).toBe(1200); 36 + }); 37 + }); 38 + 39 + describe('image alignment', () => { 40 + const validAlignments = ['left', 'center', 'right']; 41 + 42 + it('accepts valid alignment values', () => { 43 + for (const align of validAlignments) { 44 + expect(validAlignments).toContain(align); 45 + } 46 + }); 47 + 48 + it('defaults to center when not specified', () => { 49 + const defaultAlign = null || 'center'; 50 + expect(defaultAlign).toBe('center'); 51 + }); 52 + }); 53 + 54 + describe('width attribute parsing', () => { 55 + function parseWidth(value: string | null): number | null { 56 + if (!value) return null; 57 + const parsed = parseInt(value, 10); 58 + return isNaN(parsed) ? null : parsed; 59 + } 60 + 61 + it('parses numeric string', () => { 62 + expect(parseWidth('400')).toBe(400); 63 + }); 64 + 65 + it('parses pixel value string', () => { 66 + expect(parseWidth('400px')).toBe(400); 67 + }); 68 + 69 + it('returns null for empty string', () => { 70 + expect(parseWidth('')).toBe(null); 71 + }); 72 + 73 + it('returns null for null', () => { 74 + expect(parseWidth(null)).toBe(null); 75 + }); 76 + 77 + it('returns null for non-numeric string', () => { 78 + expect(parseWidth('auto')).toBe(null); 79 + }); 80 + }); 81 + 82 + describe('resize delta calculation', () => { 83 + it('computes new width for right-side drag', () => { 84 + const startWidth = 300; 85 + const dx = 50; // dragged right 86 + expect(startWidth + dx).toBe(350); 87 + }); 88 + 89 + it('computes new width for left-side drag', () => { 90 + const startWidth = 300; 91 + const dx = 50; // dragged right = shrink for left handle 92 + expect(startWidth - dx).toBe(250); 93 + }); 94 + 95 + it('negative delta shrinks right-side drag', () => { 96 + const startWidth = 300; 97 + const dx = -100; 98 + expect(startWidth + dx).toBe(200); 99 + }); 100 + });
+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 + });