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

Configure Feed

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

fix: event delegation, IFERROR correctness, diagram marker init

Landing page: replace all render-cycle addEventListener calls with
event delegation on parent containers. Prevents listener accumulation
that caused exponential performance degradation after repeated
interactions (sort, search, star, folder navigation).

Formulas: IFERROR now catches error strings starting with # (like
#DIV/0!, #REF!, #VALUE!, #NAME?) matching standard spreadsheet
behavior, instead of only catching thrown JS exceptions.

Diagrams: move arrowhead marker creation from render() to init(),
eliminating redundant DOM checks on every frame.

Closes #362

+323 -278
+10
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.23.1] — 2026-04-06 9 + 10 + ### Fixed 11 + - Landing page event listener accumulation: convert all render-cycle listeners to event delegation (#362) 12 + - IFERROR now correctly catches error strings (#DIV/0!, #REF!, #VALUE!, #NAME?) instead of only JS exceptions (#362) 13 + - Diagram arrowhead marker creation moved from render loop to initialization (#362) 14 + 8 15 ## [0.23.0] — 2026-04-04 9 16 10 17 ### Added 18 + - Fit and finish polish pass — fix rough edges, improve tests and QA (#359) 11 19 - AI companion chat panel in diagrams mode with shape/arrow actions (#355) 12 20 - AI companion chat panel in slides mode with slide/text/shape actions (#356) 13 21 - AI companion chat panel in forms mode with question add/modify/remove actions (#357) ··· 21 29 ## [0.22.4] — 2026-04-04 22 30 23 31 ### Fixed 32 + - Fix XSS vulnerabilities and server/formula bugs found in audit (#360) 24 33 - Fix diagrams saving as spreadsheet when reopened (#353) 25 34 - Fix inline text editing not working in diagram mode (#352) 26 35 - Inline text editing in diagrams survives re-renders (textarea no longer destroyed) (#352) ··· 216 225 - Fix E2E test flakiness: replace page reload with addInitScript, add waitForURL before waitForSelector (#305) 217 226 218 227 ### Changed 228 + - Fit and finish round 3: provider listener leak, fetch error handling, test gaps (#361) 219 229 - Add E2E tests for database views (kanban, gallery, calendar, timeline, pivot) (#302) 220 230 - Add E2E tests for forms builder and submission (#301) 221 231 - Add E2E tests for diagrams whiteboard (#300)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.23.0", 3 + "version": "0.23.1", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+19 -17
src/diagrams/main.ts
··· 455 455 layer.appendChild(rect); 456 456 } 457 457 458 - // Ensure arrowhead marker exists 459 - let defs = canvas.querySelector('defs'); 460 - if (defs && !defs.querySelector('#arrowhead')) { 461 - const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); 462 - marker.setAttribute('id', 'arrowhead'); 463 - marker.setAttribute('markerWidth', '10'); 464 - marker.setAttribute('markerHeight', '7'); 465 - marker.setAttribute('refX', '10'); 466 - marker.setAttribute('refY', '3.5'); 467 - marker.setAttribute('orient', 'auto'); 468 - const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 469 - polygon.setAttribute('points', '0 0, 10 3.5, 0 7'); 470 - polygon.setAttribute('fill', 'var(--color-text)'); 471 - marker.appendChild(polygon); 472 - defs.appendChild(marker); 473 - } 474 - 475 458 // Re-attach inline text editing overlay if it was active 476 459 if (editOverlay) { 477 460 layer.appendChild(editOverlay); ··· 2109 2092 } 2110 2093 2111 2094 // --- Initialize --- 2095 + function ensureArrowheadMarker() { 2096 + const defs = canvas.querySelector('defs'); 2097 + if (defs && !defs.querySelector('#arrowhead')) { 2098 + const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); 2099 + marker.setAttribute('id', 'arrowhead'); 2100 + marker.setAttribute('markerWidth', '10'); 2101 + marker.setAttribute('markerHeight', '7'); 2102 + marker.setAttribute('refX', '10'); 2103 + marker.setAttribute('refY', '3.5'); 2104 + marker.setAttribute('orient', 'auto'); 2105 + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 2106 + polygon.setAttribute('points', '0 0, 10 3.5, 0 7'); 2107 + polygon.setAttribute('fill', 'var(--color-text)'); 2108 + marker.appendChild(polygon); 2109 + defs.appendChild(marker); 2110 + } 2111 + } 2112 + 2112 2113 async function init() { 2114 + ensureArrowheadMarker(); 2113 2115 await initCrypto(); 2114 2116 2115 2117 // Push initial state to history
+270 -251
src/landing.ts
··· 99 99 // Move modal state 100 100 let moveDocId: string | null = null; 101 101 102 + // --- Event delegation for render-cycle elements --- 103 + // These listeners are attached ONCE and use event delegation to handle 104 + // dynamically-rendered elements, preventing listener accumulation. 105 + function setupDelegatedListeners() { 106 + // --- docListEl: star, delete, move, duplicate, tag-edit, recent tracking --- 107 + docListEl.addEventListener('click', async (e) => { 108 + const target = e.target as HTMLElement; 109 + 110 + // Star toggle 111 + const starBtn = target.closest('.doc-star') as HTMLElement | null; 112 + if (starBtn) { 113 + e.preventDefault(); 114 + e.stopPropagation(); 115 + stars = toggleStar(stars, starBtn.dataset.id); 116 + localStorage.setItem('tools-stars', JSON.stringify(stars)); 117 + renderDocuments(); 118 + return; 119 + } 120 + 121 + // Delete (trash) 122 + const deleteBtn = target.closest('.doc-item-delete') as HTMLElement | null; 123 + if (deleteBtn) { 124 + e.preventDefault(); 125 + e.stopPropagation(); 126 + const id = deleteBtn.dataset.id; 127 + const trashRes = await fetch(`/api/documents/${id}/trash`, { method: 'PUT' }); 128 + if (!trashRes.ok) { showToast('Failed to trash document', 4000, true); return; } 129 + const doc = allDocs.find(d => d.id === id); 130 + if (doc) { 131 + doc.deleted_at = new Date().toISOString(); 132 + allDocs = allDocs.filter(d => d.id !== id); 133 + trashedDocs = [doc, ...trashedDocs]; 134 + } 135 + renderDocuments(); 136 + showToast('Document moved to trash', 5000, false, async () => { 137 + await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 138 + if (doc) { 139 + doc.deleted_at = null; 140 + trashedDocs = trashedDocs.filter(d => d.id !== id); 141 + allDocs = [...allDocs, doc]; 142 + } 143 + renderDocuments(); 144 + }); 145 + return; 146 + } 147 + 148 + // Move to folder 149 + const moveBtn = target.closest('.doc-item-move') as HTMLElement | null; 150 + if (moveBtn) { 151 + e.preventDefault(); 152 + e.stopPropagation(); 153 + showMoveModal(moveBtn.dataset.id); 154 + return; 155 + } 156 + 157 + // Duplicate 158 + const dupBtn = target.closest('.doc-item-duplicate') as HTMLElement | null; 159 + if (dupBtn) { 160 + e.preventDefault(); 161 + e.stopPropagation(); 162 + const id = dupBtn.dataset.id; 163 + const originalDoc = allDocs.find(d => d.id === id); 164 + if (!originalDoc) return; 165 + try { 166 + const res = await fetch('/api/documents', { 167 + method: 'POST', 168 + headers: { 'Content-Type': 'application/json' }, 169 + body: JSON.stringify({ type: originalDoc.type, name_encrypted: originalDoc.name_encrypted }), 170 + }); 171 + if (!res.ok) throw new Error('Create failed'); 172 + const { id: newId } = await res.json(); 173 + const snapRes = await fetch(`/api/documents/${id}/snapshot`); 174 + if (snapRes.ok) { 175 + const blob = await snapRes.blob(); 176 + await fetch(`/api/documents/${newId}/snapshot`, { method: 'PUT', body: blob }); 177 + } 178 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 179 + if (keys[id]) { 180 + keys[newId] = keys[id]; 181 + localStorage.setItem('tools-keys', JSON.stringify(keys)); 182 + } 183 + if (currentFolderId) { 184 + folderAssignments = moveToFolder(folderAssignments, newId, currentFolderId); 185 + localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 186 + } 187 + await loadDocuments(); 188 + showToast('Document duplicated'); 189 + } catch { 190 + showToast('Failed to duplicate document', 4000, true); 191 + } 192 + return; 193 + } 194 + 195 + // Tag edit 196 + const tagBtn = target.closest('.doc-item-tag-edit') as HTMLElement | null; 197 + if (tagBtn) { 198 + e.preventDefault(); 199 + e.stopPropagation(); 200 + const id = tagBtn.dataset.id; 201 + const doc = allDocs.find(d => d.id === id); 202 + if (!doc) return; 203 + const current = parseTags(doc.tags); 204 + const input = prompt('Tags (comma-separated):', current.join(', ')); 205 + if (input === null) return; 206 + const newTags = input.split(',').map(t => t.trim()).filter(Boolean); 207 + doc.tags = JSON.stringify(newTags); 208 + if (id) saveDocumentTags(id, newTags); 209 + renderDocuments(); 210 + return; 211 + } 212 + 213 + // Track recent docs on click (links that navigate away) 214 + const docLink = target.closest('a[data-doc-id]') as HTMLElement | null; 215 + if (docLink) { 216 + const docId = docLink.dataset.docId; 217 + if (docId) { 218 + recentIds = trackRecentDoc(recentIds, docId); 219 + localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 220 + } 221 + } 222 + }); 223 + 224 + // --- folderListEl: navigate, rename, delete --- 225 + folderListEl.addEventListener('click', (e) => { 226 + const target = e.target as HTMLElement; 227 + 228 + // Rename folder 229 + const renameBtn = target.closest('.folder-rename') as HTMLElement | null; 230 + if (renameBtn) { 231 + e.stopPropagation(); 232 + const folder = folders.find(f => f.id === renameBtn.dataset.id); 233 + if (!folder) return; 234 + folderModalMode = 'rename'; 235 + folderModalTargetId = folder.id; 236 + folderModalTitle.textContent = 'Rename Folder'; 237 + folderNameInput.value = folder.name; 238 + folderConfirm.textContent = 'Rename'; 239 + folderModal.style.display = ''; 240 + folderNameInput.focus(); 241 + folderNameInput.select(); 242 + return; 243 + } 244 + 245 + // Delete folder 246 + const deleteBtn = target.closest('.folder-delete') as HTMLElement | null; 247 + if (deleteBtn) { 248 + e.stopPropagation(); 249 + const folder = folders.find(f => f.id === deleteBtn.dataset.id); 250 + if (!folder) return; 251 + if (!confirm(`Delete folder "${folder.name}"? Documents inside will be moved to the root.`)) return; 252 + folderAssignments = clearFolderAssignments(folderAssignments, folder.id); 253 + folders = deleteFolder(folders, folder.id); 254 + localStorage.setItem('tools-folders', JSON.stringify(folders)); 255 + localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 256 + renderDocuments(); 257 + return; 258 + } 259 + 260 + // Navigate into folder (but not if clicking actions) 261 + const card = target.closest('.folder-card') as HTMLElement | null; 262 + if (card && !target.closest('.folder-card-actions')) { 263 + currentFolderId = card.dataset.folderId; 264 + renderDocuments(); 265 + } 266 + }); 267 + 268 + // --- trashListEl: restore, permanent delete, empty all --- 269 + trashListEl.addEventListener('click', async (e) => { 270 + const target = e.target as HTMLElement; 271 + 272 + // Restore 273 + const restoreBtn = target.closest('.trash-restore') as HTMLElement | null; 274 + if (restoreBtn) { 275 + const id = restoreBtn.dataset.id; 276 + await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 277 + const doc = trashedDocs.find(d => d.id === id); 278 + if (doc) { 279 + doc.deleted_at = null; 280 + trashedDocs = trashedDocs.filter(d => d.id !== id); 281 + allDocs = [...allDocs, doc]; 282 + } 283 + renderDocuments(); 284 + return; 285 + } 286 + 287 + // Permanent delete 288 + const permBtn = target.closest('.trash-permanent') as HTMLElement | null; 289 + if (permBtn) { 290 + const doc = trashedDocs.find(d => d.id === permBtn.dataset.id); 291 + const name = doc?._decryptedName || 'this document'; 292 + if (!confirm(`Permanently delete "${name}"? This cannot be undone.`)) return; 293 + const id = permBtn.dataset.id; 294 + await fetch(`/api/documents/${id}`, { method: 'DELETE' }); 295 + const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 296 + delete k[id]; 297 + localStorage.setItem('tools-keys', JSON.stringify(k)); 298 + trashedDocs = trashedDocs.filter(d => d.id !== id); 299 + renderDocuments(); 300 + return; 301 + } 302 + 303 + // Empty all trash 304 + const emptyBtn = target.closest('.trash-empty-all') as HTMLElement | null; 305 + if (emptyBtn) { 306 + const docs = trashedDocs; 307 + if (!confirm(`Permanently delete all ${docs.length} trashed documents? This cannot be undone.`)) return; 308 + const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 309 + for (const doc of docs) { 310 + await fetch(`/api/documents/${doc.id}`, { method: 'DELETE' }).catch(() => showToast('Failed to delete document from server', 4000, true)); 311 + delete k[doc.id]; 312 + } 313 + localStorage.setItem('tools-keys', JSON.stringify(k)); 314 + trashedDocs = []; 315 + renderDocuments(); 316 + } 317 + }); 318 + 319 + // --- breadcrumbsEl: navigate --- 320 + breadcrumbsEl.addEventListener('click', (e) => { 321 + const btn = (e.target as HTMLElement).closest('.breadcrumb-link') as HTMLElement | null; 322 + if (btn) { 323 + currentFolderId = btn.dataset.folderId || null; 324 + renderDocuments(); 325 + } 326 + }); 327 + 328 + // --- moveFolderList: select folder --- 329 + moveFolderList.addEventListener('click', (e) => { 330 + const btn = (e.target as HTMLElement).closest('.move-option') as HTMLElement | null; 331 + if (btn) { 332 + const fid = btn.dataset.folderId || null; 333 + folderAssignments = moveToFolder(folderAssignments, moveDocId, fid); 334 + localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 335 + moveModal.style.display = 'none'; 336 + moveDocId = null; 337 + renderDocuments(); 338 + } 339 + }); 340 + 341 + // --- Tag filter bar (delegated on parent since tagBarEl may not exist yet) --- 342 + const tagParent = docListEl.parentElement; 343 + if (tagParent) { 344 + tagParent.addEventListener('click', (e) => { 345 + const btn = (e.target as HTMLElement).closest('.tag-filter-pill') as HTMLElement | null; 346 + if (btn) { 347 + const tag = btn.dataset.tag || null; 348 + activeTagFilter = tag || null; 349 + renderDocuments(); 350 + } 351 + }); 352 + } 353 + 354 + // --- Recent section (delegated on parent) --- 355 + const recentEl = document.getElementById('recent-section'); 356 + if (recentEl) { 357 + recentEl.addEventListener('click', (e) => { 358 + const link = (e.target as HTMLElement).closest('a.recent-card[data-doc-id]') as HTMLElement | null; 359 + if (link) { 360 + const docId = link.dataset.docId; 361 + if (docId) { 362 + recentIds = trackRecentDoc(recentIds, docId); 363 + localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 364 + } 365 + } 366 + }); 367 + } 368 + } 369 + 370 + setupDelegatedListeners(); 371 + 102 372 // --- Migrate legacy localStorage keys --- 103 373 if (localStorage.getItem('crypt-keys') && !localStorage.getItem('tools-keys')) { 104 374 localStorage.setItem('tools-keys', localStorage.getItem('crypt-keys')!); ··· 550 820 html += '</div>'; 551 821 recentEl.innerHTML = html; 552 822 553 - // Track clicks on recent cards too 554 - recentEl.querySelectorAll('a.recent-card[data-doc-id]').forEach(link => { 555 - link.addEventListener('click', () => { 556 - const docId = (link as HTMLElement).dataset.docId; 557 - if (docId) { 558 - recentIds = trackRecentDoc(recentIds, docId); 559 - localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 560 - } 561 - }); 562 - }); 563 823 } 564 824 565 825 function renderPinnedSection(keys: Record<string, string>) { ··· 616 876 } 617 877 tagBarEl.innerHTML = html; 618 878 619 - tagBarEl.querySelectorAll('.tag-filter-pill').forEach(btn => { 620 - btn.addEventListener('click', () => { 621 - const tag = (btn as HTMLElement).dataset.tag || null; 622 - activeTagFilter = tag || null; 623 - renderDocuments(); 624 - }); 625 - }); 626 879 } 627 880 628 881 function renderDocuments() { ··· 735 988 } 736 989 html += '</div>'; 737 990 docListEl.innerHTML = html; 738 - 739 - // Star handlers 740 - docListEl.querySelectorAll('.doc-star').forEach(btn => { 741 - btn.addEventListener('click', (e) => { 742 - e.preventDefault(); 743 - e.stopPropagation(); 744 - stars = toggleStar(stars, btn.dataset.id); 745 - localStorage.setItem('tools-stars', JSON.stringify(stars)); 746 - renderDocuments(); 747 - }); 748 - }); 749 - 750 - // Delete (soft) handlers 751 - docListEl.querySelectorAll('.doc-item-delete').forEach(btn => { 752 - btn.addEventListener('click', async (e) => { 753 - e.preventDefault(); 754 - e.stopPropagation(); 755 - const id = btn.dataset.id; 756 - const trashRes = await fetch(`/api/documents/${id}/trash`, { method: 'PUT' }); 757 - if (!trashRes.ok) { showToast('Failed to trash document', 4000, true); return; } 758 - // Move from active to trashed client-side for instant UI 759 - const doc = allDocs.find(d => d.id === id); 760 - if (doc) { 761 - doc.deleted_at = new Date().toISOString(); 762 - allDocs = allDocs.filter(d => d.id !== id); 763 - trashedDocs = [doc, ...trashedDocs]; 764 - } 765 - renderDocuments(); 766 - showToast('Document moved to trash', 5000, false, async () => { 767 - await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 768 - if (doc) { 769 - doc.deleted_at = null; 770 - trashedDocs = trashedDocs.filter(d => d.id !== id); 771 - allDocs = [...allDocs, doc]; 772 - } 773 - renderDocuments(); 774 - }); 775 - }); 776 - }); 777 - 778 - // Move to folder handlers 779 - docListEl.querySelectorAll('.doc-item-move').forEach(btn => { 780 - btn.addEventListener('click', (e) => { 781 - e.preventDefault(); 782 - e.stopPropagation(); 783 - showMoveModal(btn.dataset.id); 784 - }); 785 - }); 786 - 787 - // Duplicate handlers 788 - docListEl.querySelectorAll('.doc-item-duplicate').forEach(btn => { 789 - btn.addEventListener('click', async (e) => { 790 - e.preventDefault(); 791 - e.stopPropagation(); 792 - const id = btn.dataset.id; 793 - const originalDoc = allDocs.find(d => d.id === id); 794 - if (!originalDoc) return; 795 - 796 - try { 797 - // Create new document of same type (copies encrypted name so duplicate has same title) 798 - const res = await fetch('/api/documents', { 799 - method: 'POST', 800 - headers: { 'Content-Type': 'application/json' }, 801 - body: JSON.stringify({ type: originalDoc.type, name_encrypted: originalDoc.name_encrypted }), 802 - }); 803 - if (!res.ok) throw new Error('Create failed'); 804 - const { id: newId } = await res.json(); 805 - 806 - // Copy the snapshot (encrypted blob — no need to decrypt/re-encrypt) 807 - const snapRes = await fetch(`/api/documents/${id}/snapshot`); 808 - if (snapRes.ok) { 809 - const blob = await snapRes.blob(); 810 - await fetch(`/api/documents/${newId}/snapshot`, { 811 - method: 'PUT', 812 - body: blob, 813 - }); 814 - } 815 - 816 - // Copy the encryption key so the user can decrypt the copy 817 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 818 - if (keys[id]) { 819 - keys[newId] = keys[id]; 820 - localStorage.setItem('tools-keys', JSON.stringify(keys)); 821 - } 822 - 823 - // If we're inside a folder, assign the duplicate to the same folder 824 - if (currentFolderId) { 825 - folderAssignments = moveToFolder(folderAssignments, newId, currentFolderId); 826 - localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 827 - } 828 - 829 - // Reload document list from server to pick up the new doc 830 - await loadDocuments(); 831 - showToast('Document duplicated'); 832 - } catch { 833 - showToast('Failed to duplicate document', 4000, true); 834 - } 835 - }); 836 - }); 837 - 838 - // Tag edit handlers 839 - docListEl.querySelectorAll('.doc-item-tag-edit').forEach(btn => { 840 - btn.addEventListener('click', (e) => { 841 - e.preventDefault(); 842 - e.stopPropagation(); 843 - const id = (btn as HTMLElement).dataset.id; 844 - const doc = allDocs.find(d => d.id === id); 845 - if (!doc) return; 846 - const current = parseTags(doc.tags); 847 - const input = prompt('Tags (comma-separated):', current.join(', ')); 848 - if (input === null) return; 849 - const newTags = input.split(',').map(t => t.trim()).filter(Boolean); 850 - doc.tags = JSON.stringify(newTags); 851 - if (id) saveDocumentTags(id, newTags); 852 - renderDocuments(); 853 - }); 854 - }); 855 - 856 - // Track recent docs on click 857 - docListEl.querySelectorAll('a.doc-item[data-doc-id]').forEach(link => { 858 - link.addEventListener('click', () => { 859 - const docId = (link as HTMLElement).dataset.docId; 860 - if (docId) { 861 - recentIds = trackRecentDoc(recentIds, docId); 862 - localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 863 - } 864 - }); 865 - }); 866 991 } 867 992 868 993 // Render recent documents section ··· 884 1009 } 885 1010 }); 886 1011 breadcrumbsEl.innerHTML = html; 887 - 888 - breadcrumbsEl.querySelectorAll('.breadcrumb-link').forEach(btn => { 889 - btn.addEventListener('click', () => { 890 - currentFolderId = btn.dataset.folderId || null; 891 - renderDocuments(); 892 - }); 893 - }); 894 1012 } 895 1013 896 1014 function renderFolders(activeDocs: DocumentMeta[]): void { ··· 921 1039 } 922 1040 html += '</div>'; 923 1041 folderListEl.innerHTML = html; 924 - 925 - // Click folder to navigate into it 926 - folderListEl.querySelectorAll('.folder-card').forEach(card => { 927 - card.addEventListener('click', (e) => { 928 - if (e.target.closest('.folder-card-actions')) return; 929 - currentFolderId = card.dataset.folderId; 930 - renderDocuments(); 931 - }); 932 - }); 933 - 934 - // Rename folder 935 - folderListEl.querySelectorAll('.folder-rename').forEach(btn => { 936 - btn.addEventListener('click', (e) => { 937 - e.stopPropagation(); 938 - const folder = folders.find(f => f.id === btn.dataset.id); 939 - if (!folder) return; 940 - folderModalMode = 'rename'; 941 - folderModalTargetId = folder.id; 942 - folderModalTitle.textContent = 'Rename Folder'; 943 - folderNameInput.value = folder.name; 944 - folderConfirm.textContent = 'Rename'; 945 - folderModal.style.display = ''; 946 - folderNameInput.focus(); 947 - folderNameInput.select(); 948 - }); 949 - }); 950 - 951 - // Delete folder 952 - folderListEl.querySelectorAll('.folder-delete').forEach(btn => { 953 - btn.addEventListener('click', (e) => { 954 - e.stopPropagation(); 955 - const folder = folders.find(f => f.id === btn.dataset.id); 956 - if (!folder) return; 957 - if (!confirm(`Delete folder "${folder.name}"? Documents inside will be moved to the root.`)) return; 958 - folderAssignments = clearFolderAssignments(folderAssignments, folder.id); 959 - folders = deleteFolder(folders, folder.id); 960 - localStorage.setItem('tools-folders', JSON.stringify(folders)); 961 - localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 962 - renderDocuments(); 963 - }); 964 - }); 965 1042 } 966 1043 967 1044 function renderTrash(docs: DocumentMeta[], keys: Record<string, string>): void { ··· 999 1076 } 1000 1077 html += '</div>'; 1001 1078 trashListEl.innerHTML = html; 1002 - 1003 - // Restore handlers 1004 - trashListEl.querySelectorAll('.trash-restore').forEach(btn => { 1005 - btn.addEventListener('click', async () => { 1006 - const id = btn.dataset.id; 1007 - await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 1008 - const doc = trashedDocs.find(d => d.id === id); 1009 - if (doc) { 1010 - doc.deleted_at = null; 1011 - trashedDocs = trashedDocs.filter(d => d.id !== id); 1012 - allDocs = [...allDocs, doc]; 1013 - } 1014 - renderDocuments(); 1015 - }); 1016 - }); 1017 - 1018 - // Permanent delete handlers 1019 - trashListEl.querySelectorAll('.trash-permanent').forEach(btn => { 1020 - btn.addEventListener('click', async () => { 1021 - const doc = trashedDocs.find(d => d.id === btn.dataset.id); 1022 - const name = doc?._decryptedName || 'this document'; 1023 - if (!confirm(`Permanently delete "${name}"? This cannot be undone.`)) return; 1024 - const id = btn.dataset.id; 1025 - await fetch(`/api/documents/${id}`, { method: 'DELETE' }); 1026 - const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 1027 - delete k[id]; 1028 - localStorage.setItem('tools-keys', JSON.stringify(k)); 1029 - trashedDocs = trashedDocs.filter(d => d.id !== id); 1030 - renderDocuments(); 1031 - }); 1032 - }); 1033 - 1034 - // Empty all trash 1035 - const emptyAllBtn = trashListEl.querySelector('.trash-empty-all'); 1036 - if (emptyAllBtn) { 1037 - emptyAllBtn.addEventListener('click', async () => { 1038 - if (!confirm(`Permanently delete all ${docs.length} trashed documents? This cannot be undone.`)) return; 1039 - const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 1040 - for (const doc of docs) { 1041 - await fetch(`/api/documents/${doc.id}`, { method: 'DELETE' }).catch(() => showToast('Failed to delete document from server', 4000, true)); 1042 - delete k[doc.id]; 1043 - } 1044 - localStorage.setItem('tools-keys', JSON.stringify(k)); 1045 - trashedDocs = []; 1046 - renderDocuments(); 1047 - }); 1048 - } 1049 1079 } 1050 1080 1051 1081 function showMoveModal(docId: string): void { ··· 1070 1100 1071 1101 moveFolderList.innerHTML = html; 1072 1102 moveModal.style.display = ''; 1073 - 1074 - moveFolderList.querySelectorAll('.move-option').forEach(btn => { 1075 - btn.addEventListener('click', () => { 1076 - const fid = btn.dataset.folderId || null; 1077 - folderAssignments = moveToFolder(folderAssignments, moveDocId, fid); 1078 - localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 1079 - moveModal.style.display = 'none'; 1080 - moveDocId = null; 1081 - renderDocuments(); 1082 - }); 1083 - }); 1084 1103 } 1085 1104 1086 1105 function escapeHtml(text: string): string {
+5 -1
src/sheets/formulas.ts
··· 791 791 case 'AND': return flat(args).every(Boolean); 792 792 case 'OR': return flat(args).some(Boolean); 793 793 case 'NOT': return !args[0]; 794 - case 'IFERROR': { try { return args[0]; } catch { return args[1] ?? ''; } } 794 + case 'IFERROR': { 795 + const val = args[0]; 796 + if (typeof val === 'string' && val.startsWith('#')) return args[1] ?? ''; 797 + return val; 798 + } 795 799 796 800 case 'CONCATENATE': return flat(args).map(String).join(''); 797 801 case 'LEN': return String(args[0]).length;
+15 -5
tests/formulas-edge-cases.test.ts
··· 595 595 expect(evalWith('IFERROR("hello","err")')).toBe('hello'); 596 596 }); 597 597 598 - it('IFERROR wrapping unknown function still returns the error', () => { 599 - // FAKEFN produces #NAME? but IFERROR may not catch it since it's a returned value, not an exception 598 + it('IFERROR wrapping unknown function catches the error', () => { 599 + // FAKEFN produces #NAME? which starts with # — IFERROR catches it 600 600 const result = evalWith('IFERROR(FAKEFN(1),"fallback")'); 601 - // IFERROR only catches thrown errors; #NAME? is a returned string value 602 - // So it may return the #NAME? string directly 603 - expect(typeof result).toBe('string'); 601 + expect(result).toBe('fallback'); 602 + }); 603 + 604 + it('IFERROR catches #REF! errors', () => { 605 + expect(evalWith('IFERROR(#REF!,"fixed")')).toBe('fixed'); 606 + }); 607 + 608 + it('IFERROR catches #VALUE! errors', () => { 609 + expect(evalWith('IFERROR(#VALUE!,"fixed")')).toBe('fixed'); 610 + }); 611 + 612 + it('IFERROR does not catch strings that happen to contain #', () => { 613 + expect(evalWith('IFERROR("hello #world","err")')).toBe('hello #world'); 604 614 }); 605 615 }); 606 616
+3 -3
tests/formulas-security.test.ts
··· 185 185 expect(result).toBe('#DIV/0!'); 186 186 }); 187 187 188 - it('IFERROR with division by zero returns the error string', () => { 188 + it('IFERROR with division by zero catches error string', () => { 189 189 const result = evalWith('IFERROR(1/0,"safe")'); 190 - // 1/0 = '#DIV/0!' string; IFERROR only catches thrown exceptions, not error strings 191 - expect(result).toBe('#DIV/0!'); 190 + // 1/0 = '#DIV/0!' string; IFERROR now catches error strings starting with # 191 + expect(result).toBe('safe'); 192 192 }); 193 193 }); 194 194