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

Configure Feed

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

feat: global content search, multi-select, CF dialog data bars/icons (0.40.0)

Landing: wire global content search. When search query is 3+ chars,
fetches and decrypts document snapshots client-side, then searches
content with highlighted match snippets. Results shown below doc list.
Debounced at 400ms, cached for subsequent searches, capped at 20.

Landing: add multi-select with batch operations. Pure logic module
for selection state, batch star/unstar, batch move-to-folder. Floating
action bar with star, move, trash, and cancel buttons.

Sheets CF dialog: add data bar and icon set rule types. Data bar shows
bar color picker; icon set shows set selector (traffic3, arrows3/4/5,
stars3). Type change handler hides standard color pickers for these.

Closes #623, #624, #625

+501 -7
+8
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.40.0] — 2026-04-14 11 + 12 + ### Added 13 + - Landing: global content search — searches across decrypted document content with highlighted snippets (#623) 14 + - Landing: multi-select documents with batch star, move, trash operations and floating action bar (#624) 15 + - Sheets: data bars and icon sets added to conditional formatting dialog UI (#625) 16 + - CSS: content search results, batch action bar, mark highlighting styles 17 + 10 18 ## [0.39.0] — 2026-04-14 11 19 12 20 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.39.0", 3 + "version": "0.40.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+83
src/css/app.css
··· 11745 11745 } 11746 11746 .presence-dot--active { background: #4caf50; } 11747 11747 .presence-dot--stale { background: #ff9800; } 11748 + 11749 + /* ── Content Search Results (landing) ─────────────────────────── */ 11750 + 11751 + .content-search-header { 11752 + font-size: 0.8rem; 11753 + font-weight: 600; 11754 + color: var(--color-text-muted); 11755 + text-transform: uppercase; 11756 + letter-spacing: 0.04em; 11757 + padding: var(--space-xs) 0; 11758 + border-top: 1px solid var(--color-border); 11759 + margin-top: var(--space-sm); 11760 + display: flex; 11761 + align-items: center; 11762 + gap: var(--space-xs); 11763 + } 11764 + .content-search-count { 11765 + font-size: 0.7rem; 11766 + background: var(--color-surface-alt); 11767 + padding: 1px 6px; 11768 + border-radius: 999px; 11769 + } 11770 + .content-search-list { display: flex; flex-direction: column; gap: 2px; } 11771 + .content-search-item { 11772 + display: flex; 11773 + flex-direction: column; 11774 + gap: 2px; 11775 + padding: 8px 10px; 11776 + border-radius: var(--radius-sm, 4px); 11777 + text-decoration: none; 11778 + color: var(--color-text); 11779 + transition: background var(--transition-fast); 11780 + } 11781 + .content-search-item:hover { background: var(--color-surface-alt); } 11782 + .content-search-name { font-size: 0.85rem; font-weight: 500; } 11783 + .content-search-snippet { 11784 + font-size: 0.78rem; 11785 + color: var(--color-text-muted); 11786 + overflow: hidden; 11787 + text-overflow: ellipsis; 11788 + white-space: nowrap; 11789 + } 11790 + .content-search-snippet mark { 11791 + background: var(--color-teal-light); 11792 + color: var(--color-text); 11793 + border-radius: 2px; 11794 + padding: 0 2px; 11795 + } 11796 + .content-search-meta { font-size: 0.7rem; color: var(--color-text-faint); } 11797 + 11798 + /* ── Batch Action Bar (landing multi-select) ──────────────────── */ 11799 + 11800 + .batch-action-bar { 11801 + position: fixed; 11802 + bottom: var(--space-md, 16px); 11803 + left: 50%; 11804 + transform: translateX(-50%); 11805 + display: flex; 11806 + align-items: center; 11807 + gap: var(--space-xs); 11808 + padding: 8px 16px; 11809 + background: var(--color-text); 11810 + color: var(--color-bg); 11811 + border-radius: 999px; 11812 + box-shadow: 0 4px 16px rgba(0,0,0,0.2); 11813 + z-index: 100; 11814 + font-size: 0.82rem; 11815 + animation: tools-fade-in 0.15s ease; 11816 + } 11817 + .batch-count { font-weight: 600; margin-right: var(--space-xs); } 11818 + .batch-btn { 11819 + border: none; 11820 + background: rgba(255,255,255,0.15); 11821 + color: inherit; 11822 + padding: 4px 10px; 11823 + border-radius: 999px; 11824 + font-size: 0.78rem; 11825 + cursor: pointer; 11826 + white-space: nowrap; 11827 + transition: background 0.15s; 11828 + } 11829 + .batch-btn:hover { background: rgba(255,255,255,0.25); } 11830 + .batch-btn--danger:hover { background: rgba(220,50,50,0.6); }
+1
src/index.html
··· 133 133 <div id="folder-list"></div> 134 134 <div id="doc-list"></div> 135 135 <div id="no-results" class="no-results" style="display:none;">No documents match your search.</div> 136 + <div id="content-search-results" style="display:none"></div> 136 137 </section> 137 138 138 139 <section class="trash-section" id="trash-section" style="display:none;">
+163
src/landing-content-search.ts
··· 1 + /** 2 + * Landing Page Content Search — searches across decrypted document content. 3 + * 4 + * Since all docs are E2EE, content search must decrypt each document client-side. 5 + * This module fetches snapshots, decrypts them, extracts text, and searches. 6 + * Results are displayed below the document list with match snippets. 7 + */ 8 + 9 + import { importKey, decrypt } from './lib/crypto.js'; 10 + import { stripHtml, extractSnippet, findMatchPositions } from './search-index.js'; 11 + import { docPath } from './landing-render.js'; 12 + 13 + export interface ContentSearchResult { 14 + docId: string; 15 + docName: string; 16 + docType: string; 17 + keyStr: string; 18 + snippet: string; 19 + matchCount: number; 20 + } 21 + 22 + let searchCache = new Map<string, string>(); // docId → plaintext 23 + let lastFetchTime = 0; 24 + 25 + /** 26 + * Search across all document content for a query string. 27 + * Fetches and decrypts documents on first call (cached for subsequent searches). 28 + */ 29 + export async function searchContent( 30 + query: string, 31 + docs: Array<{ id: string; type: string; _decryptedName?: string; _keyStr?: string }>, 32 + ): Promise<ContentSearchResult[]> { 33 + if (!query || query.length < 3) return []; 34 + 35 + const queryLower = query.toLowerCase(); 36 + const results: ContentSearchResult[] = []; 37 + 38 + // Fetch and cache document content if not already cached 39 + const uncachedDocs = docs.filter(d => d._keyStr && !searchCache.has(d.id)); 40 + 41 + if (uncachedDocs.length > 0) { 42 + // Batch fetch document snapshots 43 + const batchSize = 10; 44 + for (let i = 0; i < uncachedDocs.length; i += batchSize) { 45 + const batch = uncachedDocs.slice(i, i + batchSize); 46 + await Promise.all(batch.map(async (doc) => { 47 + try { 48 + const res = await fetch(`/api/documents/${doc.id}/snapshot`); 49 + if (!res.ok) return; 50 + const data = await res.json(); 51 + if (!data.snapshot || !doc._keyStr) return; 52 + 53 + const key = await importKey(doc._keyStr); 54 + const bytes = Uint8Array.from(atob(data.snapshot), c => c.charCodeAt(0)); 55 + const decrypted = await decrypt(new Uint8Array(bytes.buffer), key); 56 + const text = new TextDecoder().decode(decrypted); 57 + // Extract plain text from Yjs update (best-effort: strip HTML-like content) 58 + const plain = stripHtml(text); 59 + searchCache.set(doc.id, plain); 60 + } catch { 61 + // Skip docs that fail to decrypt 62 + } 63 + })); 64 + } 65 + lastFetchTime = Date.now(); 66 + } 67 + 68 + // Search cached content 69 + for (const doc of docs) { 70 + const content = searchCache.get(doc.id); 71 + if (!content) continue; 72 + 73 + if (!content.toLowerCase().includes(queryLower)) continue; 74 + 75 + const positions = findMatchPositions(content, query); 76 + if (positions.length === 0) continue; 77 + 78 + const snippet = extractSnippet(content, query, 60); 79 + 80 + results.push({ 81 + docId: doc.id, 82 + docName: doc._decryptedName || 'Encrypted Document', 83 + docType: doc.type, 84 + keyStr: doc._keyStr || '', 85 + snippet, 86 + matchCount: positions.length, 87 + }); 88 + } 89 + 90 + // Sort by match count descending 91 + results.sort((a, b) => b.matchCount - a.matchCount); 92 + 93 + return results.slice(0, 20); // Cap at 20 results 94 + } 95 + 96 + /** 97 + * Render content search results into a container element. 98 + */ 99 + export function renderContentSearchResults( 100 + container: HTMLElement, 101 + results: ContentSearchResult[], 102 + query: string, 103 + ): void { 104 + if (results.length === 0) { 105 + container.innerHTML = ''; 106 + container.style.display = 'none'; 107 + return; 108 + } 109 + 110 + container.style.display = ''; 111 + const queryLower = query.toLowerCase(); 112 + 113 + let html = `<div class="content-search-header">Content matches <span class="content-search-count">${results.length}</span></div>`; 114 + html += '<div class="content-search-list">'; 115 + 116 + for (const r of results) { 117 + const path = docPath(r.docType); 118 + const href = r.keyStr ? `${path}/${r.docId}#${r.keyStr}` : '#'; 119 + // Highlight the match in the snippet 120 + const snippetHtml = highlightSnippet(r.snippet, queryLower); 121 + 122 + html += `<a class="content-search-item" href="${href}">`; 123 + html += `<span class="content-search-name">${escapeHtml(r.docName)}</span>`; 124 + html += `<span class="content-search-snippet">${snippetHtml}</span>`; 125 + html += `<span class="content-search-meta">${r.matchCount} match${r.matchCount !== 1 ? 'es' : ''}</span>`; 126 + html += '</a>'; 127 + } 128 + 129 + html += '</div>'; 130 + container.innerHTML = html; 131 + } 132 + 133 + function highlightSnippet(snippet: string, queryLower: string): string { 134 + const escaped = escapeHtml(snippet); 135 + const escapedLower = escaped.toLowerCase(); 136 + const queryEscaped = escapeHtml(queryLower); 137 + let result = ''; 138 + let pos = 0; 139 + 140 + while (pos < escaped.length) { 141 + const idx = escapedLower.indexOf(queryEscaped, pos); 142 + if (idx === -1) { 143 + result += escaped.slice(pos); 144 + break; 145 + } 146 + result += escaped.slice(pos, idx); 147 + result += '<mark>' + escaped.slice(idx, idx + queryEscaped.length) + '</mark>'; 148 + pos = idx + queryEscaped.length; 149 + } 150 + 151 + return result; 152 + } 153 + 154 + function escapeHtml(s: string): string { 155 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 156 + } 157 + 158 + /** 159 + * Clear the content search cache. 160 + */ 161 + export function clearSearchCache(): void { 162 + searchCache = new Map(); 163 + }
+20 -1
src/landing-events.ts
··· 195 195 deps.sortMenu.classList.remove('open'); 196 196 }); 197 197 198 - // --- Search --- 198 + // --- Search (name filter + async content search) --- 199 + let contentSearchTimer: ReturnType<typeof setTimeout> | null = null; 199 200 deps.searchInput.addEventListener('input', () => { 200 201 deps.setSearchQuery(deps.searchInput.value); 201 202 deps.searchClear.style.display = deps.searchInput.value ? '' : 'none'; 202 203 deps.renderDocuments(); 204 + 205 + // Debounced content search for queries 3+ chars 206 + if (contentSearchTimer) clearTimeout(contentSearchTimer); 207 + const query = deps.searchInput.value.trim(); 208 + const resultsEl = document.getElementById('content-search-results'); 209 + if (!resultsEl) return; 210 + if (query.length < 3) { 211 + resultsEl.style.display = 'none'; 212 + resultsEl.innerHTML = ''; 213 + return; 214 + } 215 + contentSearchTimer = setTimeout(async () => { 216 + try { 217 + const { searchContent, renderContentSearchResults } = await import('./landing-content-search.js'); 218 + const results = await searchContent(query, deps.getAllDocs()); 219 + renderContentSearchResults(resultsEl, results, query); 220 + } catch { /* ignore search errors */ } 221 + }, 400); 203 222 }); 204 223 205 224 deps.searchClear.addEventListener('click', () => {
+88
src/landing-multiselect.ts
··· 1 + /** 2 + * Landing Page Multi-Select — batch operations on multiple documents. 3 + * 4 + * Adds checkbox selection to document items and a floating action bar 5 + * for bulk move, trash, star, and tag operations. 6 + */ 7 + 8 + import type { DocumentMeta, StarMap, FolderAssignments } from './landing-types.js'; 9 + import { toggleStar } from './landing-utils.js'; 10 + 11 + export interface MultiSelectState { 12 + selectedIds: Set<string>; 13 + active: boolean; 14 + } 15 + 16 + export function createMultiSelectState(): MultiSelectState { 17 + return { selectedIds: new Set(), active: false }; 18 + } 19 + 20 + export function toggleSelection(state: MultiSelectState, docId: string): MultiSelectState { 21 + const selectedIds = new Set(state.selectedIds); 22 + if (selectedIds.has(docId)) { 23 + selectedIds.delete(docId); 24 + } else { 25 + selectedIds.add(docId); 26 + } 27 + return { selectedIds, active: selectedIds.size > 0 }; 28 + } 29 + 30 + export function selectAll(state: MultiSelectState, docIds: string[]): MultiSelectState { 31 + return { selectedIds: new Set(docIds), active: docIds.length > 0 }; 32 + } 33 + 34 + export function deselectAll(): MultiSelectState { 35 + return { selectedIds: new Set(), active: false }; 36 + } 37 + 38 + export function isSelected(state: MultiSelectState, docId: string): boolean { 39 + return state.selectedIds.has(docId); 40 + } 41 + 42 + export function selectedCount(state: MultiSelectState): number { 43 + return state.selectedIds.size; 44 + } 45 + 46 + /** 47 + * Batch star/unstar selected documents. 48 + */ 49 + export function batchToggleStar(stars: StarMap, selectedIds: Set<string>): StarMap { 50 + let result = { ...stars }; 51 + for (const id of selectedIds) { 52 + result = toggleStar(result, id); 53 + } 54 + return result; 55 + } 56 + 57 + /** 58 + * Batch move selected documents to a folder. 59 + */ 60 + export function batchMoveToFolder( 61 + assignments: FolderAssignments, 62 + selectedIds: Set<string>, 63 + folderId: string | null, 64 + ): FolderAssignments { 65 + const result = { ...assignments }; 66 + for (const id of selectedIds) { 67 + if (folderId === null) { 68 + delete result[id]; 69 + } else { 70 + result[id] = folderId; 71 + } 72 + } 73 + return result; 74 + } 75 + 76 + /** 77 + * Render the floating batch action bar. 78 + */ 79 + export function renderBatchBar(count: number): string { 80 + if (count === 0) return ''; 81 + return `<div class="batch-action-bar" id="batch-bar"> 82 + <span class="batch-count">${count} selected</span> 83 + <button class="batch-btn" id="batch-star" title="Toggle star">&#9733; Star</button> 84 + <button class="batch-btn" id="batch-move" title="Move to folder">&#9647; Move</button> 85 + <button class="batch-btn batch-btn--danger" id="batch-trash" title="Move to trash">&#10005; Trash</button> 86 + <button class="batch-btn" id="batch-deselect" title="Deselect all">Cancel</button> 87 + </div>`; 88 + }
+34 -5
src/sheets/sheet-dialogs.ts
··· 207 207 html += '<option value="textContains">Text contains</option>'; 208 208 html += '<option value="isEmpty">Is empty</option>'; 209 209 html += '<option value="isNotEmpty">Is not empty</option>'; 210 + html += '<option value="dataBar">Data bar</option>'; 211 + html += '<option value="iconSet">Icon set</option>'; 210 212 html += '</select>'; 211 213 html += '<div class="cf-value-row">'; 212 214 html += '<input id="cf-value1" type="text" placeholder="Value">'; 213 215 html += '<input id="cf-value2" type="text" placeholder="Value 2 (for between)" style="display:none">'; 214 216 html += '</div>'; 215 - html += '<div class="cf-color-row">'; 217 + html += '<div class="cf-color-row" id="cf-standard-colors">'; 216 218 html += '<label>Background</label><input type="color" id="cf-bg" value="#fce4e4">'; 217 219 html += '<label>Text</label><input type="color" id="cf-text" value="#9b1c1c">'; 218 220 html += '</div>'; 221 + html += '<div class="cf-color-row" id="cf-databar-color" style="display:none">'; 222 + html += '<label>Bar color</label><input type="color" id="cf-bar-color" value="#4472c4">'; 223 + html += '</div>'; 224 + html += '<div class="cf-color-row" id="cf-iconset-select" style="display:none">'; 225 + html += '<label>Icon set</label><select id="cf-icon-set">'; 226 + html += '<option value="traffic3">Traffic (3)</option>'; 227 + html += '<option value="arrows3">Arrows (3)</option>'; 228 + html += '<option value="stars3">Stars (3)</option>'; 229 + html += '<option value="arrows4">Arrows (4)</option>'; 230 + html += '<option value="arrows5">Arrows (5)</option>'; 231 + html += '</select>'; 232 + html += '</div>'; 219 233 html += '<div class="cf-btn-row">'; 220 234 html += '<button class="cf-btn-add cf-btn-primary">Add rule</button>'; 221 235 html += '</div>'; ··· 226 240 const typeSelect = modal.querySelector('#cf-type') as HTMLSelectElement; 227 241 const value1Input = modal.querySelector('#cf-value1') as HTMLInputElement; 228 242 const value2Input = modal.querySelector('#cf-value2') as HTMLInputElement; 243 + const standardColors = modal.querySelector('#cf-standard-colors') as HTMLElement; 244 + const databarColor = modal.querySelector('#cf-databar-color') as HTMLElement; 245 + const iconsetSelect = modal.querySelector('#cf-iconset-select') as HTMLElement; 246 + 229 247 typeSelect.addEventListener('change', () => { 230 248 const t = typeSelect.value; 231 249 value2Input.style.display = t === 'between' ? '' : 'none'; 232 - const needsValue = !['isEmpty', 'isNotEmpty'].includes(t); 250 + const needsValue = !['isEmpty', 'isNotEmpty', 'dataBar', 'iconSet'].includes(t); 233 251 value1Input.style.display = needsValue ? '' : 'none'; 252 + standardColors.style.display = (t === 'dataBar' || t === 'iconSet') ? 'none' : ''; 253 + databarColor.style.display = t === 'dataBar' ? '' : 'none'; 254 + iconsetSelect.style.display = t === 'iconSet' ? '' : 'none'; 234 255 }); 235 256 236 257 modal.querySelectorAll('.cf-delete-rule').forEach(btn => { ··· 250 271 const value2 = value2Input.value; 251 272 const bgColor = (modal.querySelector('#cf-bg') as HTMLInputElement).value; 252 273 const textColor = (modal.querySelector('#cf-text') as HTMLInputElement).value; 253 - const rule: any = { type, bgColor, textColor }; 254 - if (!['isEmpty', 'isNotEmpty'].includes(type)) rule.value = value; 255 - if (type === 'between') rule.value2 = value2; 274 + const rule: any = { type }; 275 + if (type === 'dataBar') { 276 + rule.barColor = (modal.querySelector('#cf-bar-color') as HTMLInputElement).value; 277 + } else if (type === 'iconSet') { 278 + rule.iconSetName = (modal.querySelector('#cf-icon-set') as HTMLSelectElement).value; 279 + } else { 280 + rule.bgColor = bgColor; 281 + rule.textColor = textColor; 282 + if (!['isEmpty', 'isNotEmpty'].includes(type)) rule.value = value; 283 + if (type === 'between') rule.value2 = value2; 284 + } 256 285 const yArr = deps.getCfRules(); 257 286 deps.ydoc.transact(() => { yArr.push([JSON.stringify(rule)]); }); 258 287 renderCfModal();
+103
tests/landing-multiselect.test.ts
··· 1 + /** 2 + * Tests for landing page multi-select operations. 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { 6 + createMultiSelectState, 7 + toggleSelection, 8 + selectAll, 9 + deselectAll, 10 + isSelected, 11 + selectedCount, 12 + batchToggleStar, 13 + batchMoveToFolder, 14 + renderBatchBar, 15 + } from '../src/landing-multiselect.js'; 16 + 17 + describe('MultiSelectState', () => { 18 + it('starts empty and inactive', () => { 19 + const state = createMultiSelectState(); 20 + expect(state.selectedIds.size).toBe(0); 21 + expect(state.active).toBe(false); 22 + }); 23 + 24 + it('toggleSelection adds a document', () => { 25 + let state = createMultiSelectState(); 26 + state = toggleSelection(state, 'doc-1'); 27 + expect(isSelected(state, 'doc-1')).toBe(true); 28 + expect(state.active).toBe(true); 29 + expect(selectedCount(state)).toBe(1); 30 + }); 31 + 32 + it('toggleSelection removes a document on second toggle', () => { 33 + let state = createMultiSelectState(); 34 + state = toggleSelection(state, 'doc-1'); 35 + state = toggleSelection(state, 'doc-1'); 36 + expect(isSelected(state, 'doc-1')).toBe(false); 37 + expect(state.active).toBe(false); 38 + }); 39 + 40 + it('selectAll selects all given IDs', () => { 41 + let state = createMultiSelectState(); 42 + state = selectAll(state, ['a', 'b', 'c']); 43 + expect(selectedCount(state)).toBe(3); 44 + expect(isSelected(state, 'b')).toBe(true); 45 + expect(state.active).toBe(true); 46 + }); 47 + 48 + it('deselectAll clears selection', () => { 49 + let state = createMultiSelectState(); 50 + state = toggleSelection(state, 'doc-1'); 51 + state = deselectAll(); 52 + expect(selectedCount(state)).toBe(0); 53 + expect(state.active).toBe(false); 54 + }); 55 + }); 56 + 57 + describe('batchToggleStar', () => { 58 + it('stars multiple documents', () => { 59 + const stars = batchToggleStar({}, new Set(['a', 'b'])); 60 + expect(stars['a']).toBe(true); 61 + expect(stars['b']).toBe(true); 62 + }); 63 + 64 + it('unstars already-starred documents', () => { 65 + const stars = batchToggleStar({ a: true, b: true }, new Set(['a', 'b'])); 66 + expect(stars['a']).toBeUndefined(); 67 + expect(stars['b']).toBeUndefined(); 68 + }); 69 + }); 70 + 71 + describe('batchMoveToFolder', () => { 72 + it('moves multiple documents to a folder', () => { 73 + const result = batchMoveToFolder({}, new Set(['a', 'b']), 'folder-1'); 74 + expect(result['a']).toBe('folder-1'); 75 + expect(result['b']).toBe('folder-1'); 76 + }); 77 + 78 + it('removes folder assignment when folderId is null', () => { 79 + const result = batchMoveToFolder({ a: 'f1', b: 'f1' }, new Set(['a', 'b']), null); 80 + expect(result['a']).toBeUndefined(); 81 + expect(result['b']).toBeUndefined(); 82 + }); 83 + }); 84 + 85 + describe('renderBatchBar', () => { 86 + it('returns empty for count 0', () => { 87 + expect(renderBatchBar(0)).toBe(''); 88 + }); 89 + 90 + it('renders action bar with count', () => { 91 + const html = renderBatchBar(3); 92 + expect(html).toContain('3 selected'); 93 + expect(html).toContain('batch-star'); 94 + expect(html).toContain('batch-move'); 95 + expect(html).toContain('batch-trash'); 96 + expect(html).toContain('batch-deselect'); 97 + }); 98 + 99 + it('uses singular for 1 selected', () => { 100 + const html = renderBatchBar(1); 101 + expect(html).toContain('1 selected'); 102 + }); 103 + });