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

Configure Feed

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

Merge pull request 'feat: server-side trash with auto-purge and migration' (#100) from feat/server-side-trash into main

scott e37dc2ba 7b1d3880

+140 -62
+17
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.10.0] — 2026-03-24 11 + 12 + ### Added 13 + - **Server-side trash**: Trash state moved from localStorage to SQLite `deleted_at` column — trash persists across browsers and devices, with automatic 30-day purge (#153) 14 + - **Toast with undo on delete**: Deleting a document shows a 5-second toast with an accessible Undo button (#154) 15 + - **Formula bar color coding**: Cell references in the formula bar now match the colored range highlight borders on the grid (#112) 16 + 17 + ### Changed 18 + - One-time migration of localStorage trash entries to server on first load 19 + - `GET /api/documents` now returns only active (non-trashed) documents 20 + - New API endpoints: `GET /api/documents/trash`, `PUT /api/documents/:id/trash`, `PUT /api/documents/:id/restore` 21 + 10 22 ## [0.9.7] — 2026-03-23 11 23 12 24 ### Fixed 25 + - Fix hidden rows at grid start unreachable + improve hide/unhide UX (#207) 26 + - Fix import data loss on refresh — force save after import (#206) 13 27 - **CRDT race on snapshot load**: `ensureSheet(0)` and TipTap editor initialization were writing to the Y.Doc before the async `_loadSnapshot()` completed, creating CRDT conflicts that overwrote loaded server data ~50% of the time. Added `whenReady` promise to EncryptedProvider; both sheets and docs now await it before touching the doc (#205) 14 28 15 29 ### Added ··· 64 78 ## [0.9.1] — 2026-03-22 65 79 66 80 ### Changed 81 + - Add confirmation dialog on document soft-delete (#154) 82 + - Sheets: cell reference color coding in formula bar (#112) 83 + - Sheets: multi-sheet .xlsx import (all worksheets) (#108) 67 84 - **Toolbar redesigned to match Google Sheets**: inline alignment buttons (left/center/right), inline $/%/.0/.00 format shortcuts, freeze and insert moved to overflow menu (#198) 68 85 69 86 ### Fixed
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.9.7", 3 + "version": "0.10.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+45 -2
server/index.ts
··· 20 20 snapshot: Buffer | null; 21 21 share_mode: 'edit' | 'view' | null; 22 22 expires_at: string | null; 23 + deleted_at: string | null; 23 24 created_at: string; 24 25 updated_at: string; 25 26 } ··· 30 31 name_encrypted: string | null; 31 32 share_mode: 'edit' | 'view' | null; 32 33 expires_at: string | null; 34 + deleted_at: string | null; 33 35 created_at: string; 34 36 updated_at: string; 35 37 } ··· 77 79 insert: Statement; 78 80 getOne: Statement; 79 81 getAll: Statement; 82 + getTrash: Statement; 80 83 getSnapshot: Statement; 81 84 putSnapshot: Statement; 82 85 putName: Statement; 86 + trashDoc: Statement; 87 + restoreDoc: Statement; 83 88 deleteDoc: Statement; 84 89 insertVersion: Statement; 85 90 getVersions: Statement; ··· 143 148 db.exec("ALTER TABLE documents ADD COLUMN expires_at TEXT"); 144 149 console.log('Migrated: added expires_at column'); 145 150 } 151 + try { 152 + db.prepare("SELECT deleted_at FROM documents LIMIT 1").get(); 153 + } catch { 154 + db.exec("ALTER TABLE documents ADD COLUMN deleted_at TEXT"); 155 + console.log('Migrated: added deleted_at column'); 156 + } 146 157 db.exec(` 147 158 CREATE TABLE IF NOT EXISTS versions ( 148 159 id TEXT PRIMARY KEY, ··· 155 166 156 167 const MAX_VERSIONS_PER_DOC = 50; 157 168 169 + // Auto-purge trash older than 30 days on startup and every 24 hours 170 + function purgeExpiredTrash(): void { 171 + const result = db.prepare("DELETE FROM documents WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', '-30 days')").run(); 172 + if (result.changes > 0) { 173 + console.log(`Purged ${result.changes} expired trashed document(s)`); 174 + } 175 + } 176 + purgeExpiredTrash(); 177 + setInterval(purgeExpiredTrash, 24 * 60 * 60 * 1000); 178 + 158 179 const stmts: PreparedStatements = { 159 180 insert: db.prepare('INSERT INTO documents (id, type, name_encrypted) VALUES (?, ?, ?)'), 160 - getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, created_at, updated_at FROM documents WHERE id = ?'), 161 - getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, created_at, updated_at FROM documents ORDER BY updated_at DESC'), 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'), 162 184 getSnapshot: db.prepare('SELECT snapshot, expires_at FROM documents WHERE id = ?'), 163 185 putSnapshot: db.prepare("UPDATE documents SET snapshot = ?, updated_at = datetime('now') WHERE id = ?"), 164 186 putName: db.prepare("UPDATE documents SET name_encrypted = ?, updated_at = datetime('now') WHERE id = ?"), 187 + trashDoc: db.prepare("UPDATE documents SET deleted_at = datetime('now') WHERE id = ? AND deleted_at IS NULL"), 188 + restoreDoc: db.prepare("UPDATE documents SET deleted_at = NULL WHERE id = ?"), 165 189 deleteDoc: db.prepare('DELETE FROM documents WHERE id = ?'), 166 190 // Version history 167 191 insertVersion: db.prepare('INSERT INTO versions (id, document_id, snapshot, metadata) VALUES (?, ?, ?, ?)'), ··· 201 225 202 226 app.delete('/api/documents/:id', (req: Request<{ id: string }>, res: Response) => { 203 227 stmts.deleteDoc.run(req.params.id); 228 + res.json({ ok: true }); 229 + }); 230 + 231 + // --- Trash --- 232 + app.get('/api/documents/trash', (_req: Request, res: Response) => { 233 + res.json(stmts.getTrash.all() as DocumentListRow[]); 234 + }); 235 + 236 + app.put('/api/documents/:id/trash', (req: Request<{ id: string }>, res: Response) => { 237 + const doc = stmts.getOne.get(req.params.id) as DocumentRow | undefined; 238 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 239 + stmts.trashDoc.run(req.params.id); 240 + res.json({ ok: true }); 241 + }); 242 + 243 + app.put('/api/documents/:id/restore', (req: Request<{ id: string }>, res: Response) => { 244 + const doc = stmts.getOne.get(req.params.id) as DocumentRow | undefined; 245 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 246 + stmts.restoreDoc.run(req.params.id); 204 247 res.json({ ok: true }); 205 248 }); 206 249
+1
src/landing-types.ts
··· 6 6 id: string; 7 7 type: 'doc' | 'sheet'; 8 8 name_encrypted: string | null; 9 + deleted_at: string | null; 9 10 created_at: string; 10 11 updated_at: string; 11 12 _decryptedName?: string;
+76 -59
src/landing.ts
··· 1 - import type { DocumentMeta, TrashEntry, Folder, FolderAssignments, StarMap, SortLabels } from './landing-types.js'; 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 4 import { 5 5 sortDocuments, 6 6 toggleStar, 7 7 starredIdsSet, 8 - addToTrash, 9 - restoreFromTrash, 10 - purgeExpiredTrash, 11 - removeFromTrash, 12 - partitionDocuments, 13 8 filterBySearch, 14 9 createFolder, 15 10 renameFolder, ··· 64 59 65 60 // --- State --- 66 61 let allDocs: DocumentMeta[] = []; // raw from server, with _decryptedName populated 62 + let trashedDocs: DocumentMeta[] = []; // trashed docs from server 67 63 let currentSort: string = localStorage.getItem('tools-sort') || DEFAULT_SORT; 68 64 let stars: StarMap = JSON.parse(localStorage.getItem('tools-stars') || '{}'); 69 - let trash: TrashEntry[] = JSON.parse(localStorage.getItem('tools-trash') || '[]'); 70 65 let folders: Folder[] = JSON.parse(localStorage.getItem('tools-folders') || '[]'); 71 66 let folderAssignments: FolderAssignments = JSON.parse(localStorage.getItem('tools-folder-assignments') || '{}'); 72 67 let currentFolderId: string | null = null; // null = root / All Documents ··· 272 267 renderDocuments(); // Re-render so renderTrash generates the HTML when expanded 273 268 }); 274 269 275 - // --- Auto-purge expired trash on load --- 276 - function purgeTrash() { 277 - const { kept, expired } = purgeExpiredTrash(trash); 278 - if (expired.length > 0) { 279 - // Permanently delete expired docs from server 280 - for (const id of expired) { 281 - fetch(`/api/documents/${id}`, { method: 'DELETE' }).catch(() => showToast('Failed to delete document from server', 4000, true)); 282 - // Also clean up keys 283 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 284 - delete keys[id]; 285 - localStorage.setItem('tools-keys', JSON.stringify(keys)); 270 + // --- Migrate localStorage trash to server (one-time) --- 271 + async function migrateLocalStorageTrash(): Promise<void> { 272 + const raw = localStorage.getItem('tools-trash'); 273 + if (!raw) return; 274 + try { 275 + const entries: Array<{ id: string }> = JSON.parse(raw); 276 + if (!Array.isArray(entries) || entries.length === 0) { 277 + localStorage.removeItem('tools-trash'); 278 + return; 286 279 } 287 - trash = kept; 288 - localStorage.setItem('tools-trash', JSON.stringify(trash)); 280 + await Promise.all( 281 + entries.map(e => 282 + fetch(`/api/documents/${e.id}/trash`, { method: 'PUT' }).catch(() => {/* ignore — may already be trashed or deleted */}) 283 + ) 284 + ); 285 + localStorage.removeItem('tools-trash'); 286 + } catch { 287 + // Corrupted data — just remove it 288 + localStorage.removeItem('tools-trash'); 289 289 } 290 290 } 291 291 292 - // --- Load & render --- 293 - async function loadDocuments() { 294 - purgeTrash(); 295 - 296 - const res = await fetch('/api/documents'); 297 - const docs = await res.json(); 298 - 292 + async function decryptDocNames(docs: DocumentMeta[]): Promise<void> { 299 293 const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 300 - 301 - // Decrypt names where possible 302 294 for (const doc of docs) { 303 295 const keyStr = keys[doc.id]; 304 296 doc._decryptedName = undefined; ··· 313 305 } 314 306 } 315 307 } 308 + } 309 + 310 + // --- Load & render --- 311 + async function loadDocuments() { 312 + await migrateLocalStorageTrash(); 313 + 314 + const [activeRes, trashRes] = await Promise.all([ 315 + fetch('/api/documents'), 316 + fetch('/api/documents/trash'), 317 + ]); 318 + const docs = await activeRes.json(); 319 + const trashed = await trashRes.json(); 320 + 321 + await Promise.all([decryptDocNames(docs), decryptDocNames(trashed)]); 316 322 317 323 allDocs = docs; 324 + trashedDocs = trashed; 318 325 renderDocuments(); 319 326 } 320 327 ··· 322 329 const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 323 330 const starSet = starredIdsSet(stars); 324 331 325 - // Partition into active and trashed 326 - const { active, trashed } = partitionDocuments(allDocs, trash); 332 + // allDocs already contains only active docs (server filters out trashed) 333 + const active = allDocs; 327 334 328 335 // Apply folder filter 329 336 let visibleDocs; ··· 347 354 renderFolders(active); 348 355 349 356 // Render document list 350 - if (sorted.length === 0 && active.length === 0 && trashed.length === 0) { 357 + if (sorted.length === 0 && active.length === 0 && trashedDocs.length === 0) { 351 358 docListEl.innerHTML = ` 352 359 <div class="empty-state"> 353 360 <strong>No documents yet</strong> ··· 398 405 399 406 // Delete (soft) handlers 400 407 docListEl.querySelectorAll('.doc-item-delete').forEach(btn => { 401 - btn.addEventListener('click', (e) => { 408 + btn.addEventListener('click', async (e) => { 402 409 e.preventDefault(); 403 410 e.stopPropagation(); 404 411 const id = btn.dataset.id; 405 - trash = addToTrash(trash, id); 406 - localStorage.setItem('tools-trash', JSON.stringify(trash)); 412 + await fetch(`/api/documents/${id}/trash`, { method: 'PUT' }); 413 + // Move from active to trashed client-side for instant UI 414 + const doc = allDocs.find(d => d.id === id); 415 + if (doc) { 416 + doc.deleted_at = new Date().toISOString(); 417 + allDocs = allDocs.filter(d => d.id !== id); 418 + trashedDocs = [doc, ...trashedDocs]; 419 + } 407 420 renderDocuments(); 408 - showToast('Document moved to trash', 5000, false, () => { 409 - trash = restoreFromTrash(trash, id); 410 - localStorage.setItem('tools-trash', JSON.stringify(trash)); 421 + showToast('Document moved to trash', 5000, false, async () => { 422 + await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 423 + if (doc) { 424 + doc.deleted_at = null; 425 + trashedDocs = trashedDocs.filter(d => d.id !== id); 426 + allDocs = [...allDocs, doc]; 427 + } 411 428 renderDocuments(); 412 429 }); 413 430 }); ··· 424 441 } 425 442 426 443 // Render trash section 427 - renderTrash(trashed, keys); 444 + renderTrash(trashedDocs, keys); 428 445 } 429 446 430 447 function renderBreadcrumbs() { ··· 519 536 }); 520 537 } 521 538 522 - function renderTrash(trashedDocs: DocumentMeta[], keys: Record<string, string>): void { 523 - if (trashedDocs.length === 0) { 539 + function renderTrash(docs: DocumentMeta[], keys: Record<string, string>): void { 540 + if (docs.length === 0) { 524 541 trashSection.style.display = 'none'; 525 542 return; 526 543 } 527 544 528 545 trashSection.style.display = ''; 529 - trashCount.textContent = `(${trashedDocs.length})`; 546 + trashCount.textContent = `(${docs.length})`; 530 547 531 548 if (!trashExpanded) { 532 549 trashListEl.style.display = 'none'; ··· 536 553 trashListEl.style.display = ''; 537 554 let html = '<div class="trash-actions"><button class="btn-danger btn-sm trash-empty-all">Empty Trash</button></div>'; 538 555 html += '<div class="doc-list">'; 539 - for (const doc of trashedDocs) { 556 + for (const doc of docs) { 540 557 const name = doc._decryptedName || 'Encrypted Document'; 541 558 const icon = doc.type === 'doc' ? '&#9998;' : '&#9638;'; 542 - const trashEntry = trash.find(t => t.id === doc.id); 543 - const deletedDate = trashEntry 544 - ? new Date(trashEntry.deletedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) 559 + const deletedDate = doc.deleted_at 560 + ? new Date(doc.deleted_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) 545 561 : ''; 546 562 547 563 html += ` ··· 558 574 559 575 // Restore handlers 560 576 trashListEl.querySelectorAll('.trash-restore').forEach(btn => { 561 - btn.addEventListener('click', () => { 562 - trash = restoreFromTrash(trash, btn.dataset.id); 563 - localStorage.setItem('tools-trash', JSON.stringify(trash)); 577 + btn.addEventListener('click', async () => { 578 + const id = btn.dataset.id; 579 + await fetch(`/api/documents/${id}/restore`, { method: 'PUT' }); 580 + const doc = trashedDocs.find(d => d.id === id); 581 + if (doc) { 582 + doc.deleted_at = null; 583 + trashedDocs = trashedDocs.filter(d => d.id !== id); 584 + allDocs = [...allDocs, doc]; 585 + } 564 586 renderDocuments(); 565 587 }); 566 588 }); ··· 568 590 // Permanent delete handlers 569 591 trashListEl.querySelectorAll('.trash-permanent').forEach(btn => { 570 592 btn.addEventListener('click', async () => { 571 - const doc = allDocs.find(d => d.id === btn.dataset.id); 593 + const doc = trashedDocs.find(d => d.id === btn.dataset.id); 572 594 const name = doc?._decryptedName || 'this document'; 573 595 if (!confirm(`Permanently delete "${name}"? This cannot be undone.`)) return; 574 596 const id = btn.dataset.id; 575 597 await fetch(`/api/documents/${id}`, { method: 'DELETE' }); 576 - trash = removeFromTrash(trash, id); 577 - localStorage.setItem('tools-trash', JSON.stringify(trash)); 578 598 const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 579 599 delete k[id]; 580 600 localStorage.setItem('tools-keys', JSON.stringify(k)); 581 - allDocs = allDocs.filter(d => d.id !== id); 601 + trashedDocs = trashedDocs.filter(d => d.id !== id); 582 602 renderDocuments(); 583 603 }); 584 604 }); ··· 587 607 const emptyAllBtn = trashListEl.querySelector('.trash-empty-all'); 588 608 if (emptyAllBtn) { 589 609 emptyAllBtn.addEventListener('click', async () => { 590 - if (!confirm(`Permanently delete all ${trashedDocs.length} trashed documents? This cannot be undone.`)) return; 610 + if (!confirm(`Permanently delete all ${docs.length} trashed documents? This cannot be undone.`)) return; 591 611 const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 592 - for (const doc of trashedDocs) { 612 + for (const doc of docs) { 593 613 await fetch(`/api/documents/${doc.id}`, { method: 'DELETE' }).catch(() => showToast('Failed to delete document from server', 4000, true)); 594 614 delete k[doc.id]; 595 615 } 596 616 localStorage.setItem('tools-keys', JSON.stringify(k)); 597 - allDocs = allDocs.filter(d => !trashedDocs.some(t => t.id === d.id)); 598 - trash = []; 599 - localStorage.setItem('tools-trash', JSON.stringify(trash)); 617 + trashedDocs = []; 600 618 renderDocuments(); 601 619 }); 602 620 } ··· 691 709 } 692 710 }); 693 711 toast.appendChild(undoBtn); 694 - duration = 5000; 695 712 } else { 696 713 toast.textContent = message; 697 714 }