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: version history panel + bulletproof save pipeline (v0.3.0)' (#57) from feat/version-history-save-pipeline into main

scott 91eb9b88 4df96578

+1548 -36
+13 -2
package-lock.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.1.0", 3 + "version": "0.2.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "tools", 9 - "version": "0.1.0", 9 + "version": "0.2.0", 10 10 "dependencies": { 11 11 "@tiptap/core": "^2.11.0", 12 12 "@tiptap/extension-collaboration": "^2.11.0", ··· 53 53 "@types/node": "^25.5.0", 54 54 "@types/ws": "^8.18.1", 55 55 "concurrently": "^9.1.0", 56 + "fake-indexeddb": "^6.2.5", 56 57 "jsdom": "^29.0.0", 57 58 "jszip": "^3.10.1", 58 59 "tsx": "^4.21.0", ··· 3174 3175 "funding": { 3175 3176 "type": "opencollective", 3176 3177 "url": "https://opencollective.com/express" 3178 + } 3179 + }, 3180 + "node_modules/fake-indexeddb": { 3181 + "version": "6.2.5", 3182 + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", 3183 + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", 3184 + "dev": true, 3185 + "license": "Apache-2.0", 3186 + "engines": { 3187 + "node": ">=18" 3177 3188 } 3178 3189 }, 3179 3190 "node_modules/fast-csv": {
+1
package.json
··· 57 57 "@types/node": "^25.5.0", 58 58 "@types/ws": "^8.18.1", 59 59 "concurrently": "^9.1.0", 60 + "fake-indexeddb": "^6.2.5", 60 61 "jsdom": "^29.0.0", 61 62 "jszip": "^3.10.1", 62 63 "tsx": "^4.21.0",
+25
server/index.ts
··· 302 302 res.type('application/octet-stream').send(row.snapshot); 303 303 }); 304 304 305 + app.put('/api/documents/:id/versions/:versionId/metadata', express.json(), (req: Request<{ id: string; versionId: string }>, res: Response) => { 306 + const { id: docId, versionId } = req.params; 307 + 308 + // Fetch existing version metadata 309 + const version = (stmts.getVersions.all(docId) as VersionRow[]).find(v => v.id === versionId); 310 + if (!version) { 311 + res.status(404).json({ error: 'Version not found' }); 312 + return; 313 + } 314 + 315 + // Merge incoming body into existing metadata 316 + let existing: Record<string, unknown> = {}; 317 + if (version.metadata) { 318 + try { 319 + existing = JSON.parse(version.metadata) as Record<string, unknown>; 320 + } catch { /* ignore parse errors */ } 321 + } 322 + const merged = { ...existing, ...req.body }; 323 + 324 + db.prepare('UPDATE versions SET metadata = ? WHERE id = ? AND document_id = ?') 325 + .run(JSON.stringify(merged), versionId, docId); 326 + 327 + res.json({ ok: true, metadata: merged }); 328 + }); 329 + 305 330 // Health check 306 331 app.get('/health', (_req: Request, res: Response) => { 307 332 try {
+213
src/css/app.css
··· 5019 5019 display: none !important; 5020 5020 } 5021 5021 } 5022 + 5023 + /* ======================================================== 5024 + Save Dot Indicator 5025 + ======================================================== */ 5026 + 5027 + .save-dot { 5028 + display: inline-block; 5029 + width: 8px; 5030 + height: 8px; 5031 + border-radius: 50%; 5032 + flex-shrink: 0; 5033 + transition: background-color var(--transition-fast); 5034 + } 5035 + 5036 + .save-dot--saved { 5037 + background-color: var(--color-success); 5038 + } 5039 + 5040 + .save-dot--saving { 5041 + background-color: var(--color-warning); 5042 + animation: save-dot-pulse 1s ease-in-out infinite; 5043 + } 5044 + 5045 + .save-dot--error { 5046 + background-color: var(--color-danger); 5047 + animation: save-dot-pulse 0.6s ease-in-out infinite; 5048 + } 5049 + 5050 + @keyframes save-dot-pulse { 5051 + 0%, 100% { opacity: 1; } 5052 + 50% { opacity: 0.4; } 5053 + } 5054 + 5055 + /* ======================================================== 5056 + Version Panel (slide-in from right) 5057 + ======================================================== */ 5058 + 5059 + .version-panel { 5060 + position: fixed; 5061 + top: 0; 5062 + right: -320px; 5063 + width: 320px; 5064 + height: 100vh; 5065 + background: var(--color-surface); 5066 + border-left: 1px solid var(--color-border); 5067 + box-shadow: var(--shadow-lg); 5068 + display: flex; 5069 + flex-direction: column; 5070 + z-index: 900; 5071 + transition: right var(--transition-med); 5072 + overflow: hidden; 5073 + } 5074 + 5075 + .version-panel.open { 5076 + right: 0; 5077 + } 5078 + 5079 + .version-panel-header { 5080 + display: flex; 5081 + align-items: center; 5082 + justify-content: space-between; 5083 + padding: var(--space-sm) var(--space-md); 5084 + border-bottom: 1px solid var(--color-border); 5085 + flex-shrink: 0; 5086 + } 5087 + 5088 + .version-panel-header h3 { 5089 + margin: 0; 5090 + font-size: 0.875rem; 5091 + font-weight: 600; 5092 + font-family: var(--font-body); 5093 + color: var(--color-text); 5094 + } 5095 + 5096 + .version-panel-close { 5097 + font-size: 1.25rem; 5098 + color: var(--color-text-muted); 5099 + } 5100 + 5101 + .version-panel-list { 5102 + flex: 1; 5103 + overflow-y: auto; 5104 + padding: var(--space-xs) 0; 5105 + } 5106 + 5107 + .version-panel-item { 5108 + display: flex; 5109 + flex-direction: column; 5110 + gap: 2px; 5111 + width: 100%; 5112 + padding: var(--space-sm) var(--space-md); 5113 + border-bottom: 1px solid var(--color-border); 5114 + cursor: pointer; 5115 + transition: background var(--transition-fast); 5116 + font-family: var(--font-body); 5117 + position: relative; 5118 + } 5119 + 5120 + .version-panel-item:hover { 5121 + background: var(--color-hover); 5122 + } 5123 + 5124 + .version-panel-item:focus-visible { 5125 + outline: 2px solid var(--color-teal); 5126 + outline-offset: -2px; 5127 + } 5128 + 5129 + .version-panel-item-top { 5130 + display: flex; 5131 + align-items: center; 5132 + gap: var(--space-sm); 5133 + } 5134 + 5135 + .version-panel-time { 5136 + font-size: 0.75rem; 5137 + color: var(--color-text); 5138 + font-weight: 500; 5139 + } 5140 + 5141 + .version-panel-named-badge { 5142 + display: inline-block; 5143 + padding: 1px 6px; 5144 + font-size: 0.625rem; 5145 + font-weight: 600; 5146 + color: var(--color-teal); 5147 + background: var(--color-teal-light); 5148 + border-radius: var(--radius-sm); 5149 + text-transform: uppercase; 5150 + letter-spacing: 0.03em; 5151 + } 5152 + 5153 + .version-panel-item-meta { 5154 + display: flex; 5155 + gap: var(--space-sm); 5156 + font-size: 0.6875rem; 5157 + color: var(--color-text-muted); 5158 + } 5159 + 5160 + .version-panel-author { 5161 + color: var(--color-text-faint); 5162 + } 5163 + 5164 + .version-panel-count { 5165 + color: var(--color-text-faint); 5166 + } 5167 + 5168 + .version-panel-delta { 5169 + font-weight: 600; 5170 + } 5171 + 5172 + .version-panel-delta.positive { 5173 + color: var(--color-success); 5174 + } 5175 + 5176 + .version-panel-delta.negative { 5177 + color: var(--color-danger); 5178 + } 5179 + 5180 + .version-panel-name-btn { 5181 + position: absolute; 5182 + right: var(--space-sm); 5183 + top: 50%; 5184 + transform: translateY(-50%); 5185 + background: none; 5186 + border: none; 5187 + color: var(--color-text-faint); 5188 + cursor: pointer; 5189 + font-size: 0.875rem; 5190 + opacity: 0; 5191 + transition: opacity var(--transition-fast); 5192 + padding: 2px 4px; 5193 + border-radius: var(--radius-sm); 5194 + } 5195 + 5196 + .version-panel-item:hover .version-panel-name-btn { 5197 + opacity: 1; 5198 + } 5199 + 5200 + .version-panel-name-btn:hover { 5201 + color: var(--color-text); 5202 + background: var(--color-hover); 5203 + } 5204 + 5205 + .version-panel-preview { 5206 + display: flex; 5207 + flex-direction: column; 5208 + position: absolute; 5209 + inset: 0; 5210 + background: var(--color-surface); 5211 + z-index: 2; 5212 + } 5213 + 5214 + .version-panel-preview-header { 5215 + display: flex; 5216 + align-items: center; 5217 + justify-content: space-between; 5218 + padding: var(--space-sm) var(--space-md); 5219 + border-bottom: 1px solid var(--color-border); 5220 + gap: var(--space-sm); 5221 + flex-shrink: 0; 5222 + } 5223 + 5224 + .version-panel-preview-content { 5225 + flex: 1; 5226 + overflow-y: auto; 5227 + padding: var(--space-md); 5228 + } 5229 + 5230 + @media print { 5231 + .version-panel { 5232 + display: none !important; 5233 + } 5234 + }
+1
src/docs/index.html
··· 49 49 <!-- Share button --> 50 50 <button class="btn-icon" id="btn-share" title="Share document">&#128279;</button> 51 51 <div class="save-indicator saved" id="save-indicator"> 52 + <span class="save-dot save-dot--saved"></span> 52 53 <span id="save-text">Saved</span> 53 54 </div> 54 55 <div class="status-indicator" id="status">
+37 -6
src/docs/main.ts
··· 47 47 import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js'; 48 48 import { createMarkdownToggle, TOGGLE_MODE } from './markdown-toggle.js'; 49 49 import { VersionManager, computeWordCount } from '../lib/version-history.js'; 50 + import { createVersionPanel } from '../version-panel.js'; 50 51 import { SuggestionManager, createSuggestionAttrs } from '../lib/suggesting.js'; 51 52 import { OfflineManager } from '../lib/offline.js'; 52 53 import { extractHeadings, OutlineState } from './outline.js'; ··· 1110 1111 1111 1112 setInterval(updateSaveTimestamp, 30_000); 1112 1113 1113 - const origSaveSnapshot = provider._saveSnapshot.bind(provider); 1114 - provider._saveSnapshot = async function() { 1115 - setSaveState('saving'); 1116 - await origSaveSnapshot(); 1117 - setSaveState('saved', Date.now()); 1118 - }; 1114 + // Listen for save-status events from the provider (replaces monkey-patching) 1115 + provider.on('save-status', (payload) => { 1116 + if (payload.status === 'saving') setSaveState('saving'); 1117 + else if (payload.status === 'saved') setSaveState('saved', Date.now()); 1118 + else if (payload.status === 'error') setSaveState('unsaved'); 1119 + }); 1120 + 1121 + // Update save dot color based on save-status events 1122 + const saveDot = document.querySelector('.save-dot') as HTMLElement | null; 1123 + if (saveDot) { 1124 + provider.on('save-status', (payload) => { 1125 + saveDot.classList.remove('save-dot--saved', 'save-dot--saving', 'save-dot--error'); 1126 + if (payload.status === 'saving') saveDot.classList.add('save-dot--saving'); 1127 + else if (payload.status === 'saved') saveDot.classList.add('save-dot--saved'); 1128 + else if (payload.status === 'error') saveDot.classList.add('save-dot--error'); 1129 + }); 1130 + } 1119 1131 1120 1132 editor.on('update', () => { 1121 1133 if (saveState === 'saved') setSaveState('unsaved'); ··· 1611 1623 selectedVersionId = null; 1612 1624 } catch (err) { 1613 1625 alert('Failed to restore version'); 1626 + } 1627 + }); 1628 + 1629 + // --- Version Panel (new slide-in, Cmd+Shift+H) --- 1630 + const docsVersionPanel = createVersionPanel({ 1631 + docId, 1632 + cryptoKey, 1633 + docType: 'doc', 1634 + onRestore: async (_versionId: string, decryptedData: Uint8Array) => { 1635 + Y.applyUpdate(ydoc, decryptedData); 1636 + await provider._saveSnapshot(); 1637 + }, 1638 + }); 1639 + 1640 + // Wire Cmd+Shift+H to toggle version panel 1641 + document.addEventListener('keydown', (e) => { 1642 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'h') { 1643 + e.preventDefault(); 1644 + docsVersionPanel.toggle(); 1614 1645 } 1615 1646 }); 1616 1647
+231
src/lib/local-backup.ts
··· 1 + /** 2 + * Local IndexedDB backup module for encrypted document snapshots. 3 + * 4 + * Provides a safety net: if the server save fails, the latest snapshot 5 + * is still recoverable from the browser's IndexedDB. 6 + * 7 + * DB: "tools-backups", store: "snapshots" 8 + * Each record: { docId, encrypted (ArrayBuffer), hash (FNV-1a), timestamp } 9 + * Keeps last 3 snapshots per document (FIFO pruning). 10 + */ 11 + 12 + const DB_NAME = 'tools-backups'; 13 + const STORE_NAME = 'snapshots'; 14 + const DB_VERSION = 1; 15 + const MAX_BACKUPS_PER_DOC = 3; 16 + 17 + // Monotonic counter to ensure unique IDs when Date.now() returns the same value 18 + let _idCounter = 0; 19 + 20 + export interface BackupEntry { 21 + id: string; // auto-generated: `${docId}-${timestamp}-${counter}` 22 + docId: string; 23 + encrypted: ArrayBuffer | Uint8Array; 24 + hash: number; 25 + timestamp: number; 26 + } 27 + 28 + /** 29 + * FNV-1a 32-bit hash. 30 + * Fast, non-cryptographic hash used to detect duplicate snapshots. 31 + */ 32 + export function fnv1aHash(data: Uint8Array): number { 33 + let hash = 0x811c9dc5; // FNV offset basis 34 + for (let i = 0; i < data.length; i++) { 35 + hash ^= data[i]!; 36 + // FNV prime: multiply by 16777619 37 + // Use Math.imul for correct 32-bit multiplication 38 + hash = Math.imul(hash, 0x01000193); 39 + } 40 + return hash >>> 0; // Ensure unsigned 32-bit 41 + } 42 + 43 + /** 44 + * Open (or create) the IndexedDB database. 45 + * Returns null if IndexedDB is unavailable. 46 + */ 47 + function openDB(): Promise<IDBDatabase | null> { 48 + return new Promise((resolve) => { 49 + try { 50 + if (typeof indexedDB === 'undefined') { 51 + resolve(null); 52 + return; 53 + } 54 + const request = indexedDB.open(DB_NAME, DB_VERSION); 55 + request.onupgradeneeded = () => { 56 + const db = request.result; 57 + if (!db.objectStoreNames.contains(STORE_NAME)) { 58 + const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); 59 + store.createIndex('byDocId', 'docId', { unique: false }); 60 + } 61 + }; 62 + request.onsuccess = () => resolve(request.result); 63 + request.onerror = () => resolve(null); 64 + } catch { 65 + resolve(null); 66 + } 67 + }); 68 + } 69 + 70 + /** 71 + * Save a local backup of an encrypted document snapshot. 72 + * 73 + * Keeps at most MAX_BACKUPS_PER_DOC per document (prunes oldest). 74 + * Skips save if the hash matches the most recent backup (duplicate detection). 75 + */ 76 + export async function saveLocalBackup( 77 + docId: string, 78 + encrypted: ArrayBuffer | Uint8Array, 79 + hash?: number, 80 + ): Promise<void> { 81 + const db = await openDB(); 82 + if (!db) return; 83 + 84 + try { 85 + const computedHash = hash ?? fnv1aHash(new Uint8Array(encrypted)); 86 + const timestamp = Date.now(); 87 + 88 + // Read existing backups for this doc to check for duplicates and prune 89 + const existing = await new Promise<BackupEntry[]>((resolve) => { 90 + const tx = db.transaction(STORE_NAME, 'readonly'); 91 + const index = tx.objectStore(STORE_NAME).index('byDocId'); 92 + const request = index.getAll(docId); 93 + request.onsuccess = () => resolve(request.result as BackupEntry[]); 94 + request.onerror = () => resolve([]); 95 + }); 96 + 97 + // Sort by timestamp descending (newest first), id as tiebreaker 98 + existing.sort((a, b) => b.timestamp - a.timestamp || b.id.localeCompare(a.id)); 99 + 100 + // Skip if the most recent backup has the same hash (no change) 101 + if (existing.length > 0 && existing[0]!.hash === computedHash) { 102 + db.close(); 103 + return; 104 + } 105 + 106 + const entry: BackupEntry = { 107 + id: `${docId}-${timestamp}-${_idCounter++}`, 108 + docId, 109 + encrypted, 110 + hash: computedHash, 111 + timestamp, 112 + }; 113 + 114 + // Write new entry and prune old ones in a single transaction 115 + const tx = db.transaction(STORE_NAME, 'readwrite'); 116 + const store = tx.objectStore(STORE_NAME); 117 + store.put(entry); 118 + 119 + // Prune: keep only MAX_BACKUPS_PER_DOC - 1 old entries (since we just added one) 120 + const toDelete = existing.slice(MAX_BACKUPS_PER_DOC - 1); 121 + for (const old of toDelete) { 122 + store.delete(old.id); 123 + } 124 + 125 + await new Promise<void>((resolve, reject) => { 126 + tx.oncomplete = () => resolve(); 127 + tx.onerror = () => reject(tx.error); 128 + }); 129 + 130 + db.close(); 131 + } catch { 132 + // Graceful degradation: silently fail 133 + try { db.close(); } catch { /* ignore */ } 134 + } 135 + } 136 + 137 + /** 138 + * Load the most recent local backup for a document. 139 + * Returns null if no backup exists or IndexedDB is unavailable. 140 + */ 141 + export async function loadLocalBackup(docId: string): Promise<BackupEntry | null> { 142 + const db = await openDB(); 143 + if (!db) return null; 144 + 145 + try { 146 + const entries = await new Promise<BackupEntry[]>((resolve) => { 147 + const tx = db.transaction(STORE_NAME, 'readonly'); 148 + const index = tx.objectStore(STORE_NAME).index('byDocId'); 149 + const request = index.getAll(docId); 150 + request.onsuccess = () => resolve(request.result as BackupEntry[]); 151 + request.onerror = () => resolve([]); 152 + }); 153 + 154 + db.close(); 155 + 156 + if (entries.length === 0) return null; 157 + 158 + // Return the newest entry (id as tiebreaker for same-ms saves) 159 + entries.sort((a, b) => b.timestamp - a.timestamp || b.id.localeCompare(a.id)); 160 + return entries[0]!; 161 + } catch { 162 + try { db.close(); } catch { /* ignore */ } 163 + return null; 164 + } 165 + } 166 + 167 + /** 168 + * Delete all local backups for a document. 169 + */ 170 + export async function clearLocalBackup(docId: string): Promise<void> { 171 + const db = await openDB(); 172 + if (!db) return; 173 + 174 + try { 175 + const entries = await new Promise<BackupEntry[]>((resolve) => { 176 + const tx = db.transaction(STORE_NAME, 'readonly'); 177 + const index = tx.objectStore(STORE_NAME).index('byDocId'); 178 + const request = index.getAll(docId); 179 + request.onsuccess = () => resolve(request.result as BackupEntry[]); 180 + request.onerror = () => resolve([]); 181 + }); 182 + 183 + if (entries.length === 0) { 184 + db.close(); 185 + return; 186 + } 187 + 188 + const tx = db.transaction(STORE_NAME, 'readwrite'); 189 + const store = tx.objectStore(STORE_NAME); 190 + for (const entry of entries) { 191 + store.delete(entry.id); 192 + } 193 + 194 + await new Promise<void>((resolve, reject) => { 195 + tx.oncomplete = () => resolve(); 196 + tx.onerror = () => reject(tx.error); 197 + }); 198 + 199 + db.close(); 200 + } catch { 201 + try { db.close(); } catch { /* ignore */ } 202 + } 203 + } 204 + 205 + /** 206 + * List all backup entries for a document (without the encrypted data). 207 + * Useful for diagnostics / UI. 208 + */ 209 + export async function listLocalBackups(docId: string): Promise<Omit<BackupEntry, 'encrypted'>[]> { 210 + const db = await openDB(); 211 + if (!db) return []; 212 + 213 + try { 214 + const entries = await new Promise<BackupEntry[]>((resolve) => { 215 + const tx = db.transaction(STORE_NAME, 'readonly'); 216 + const index = tx.objectStore(STORE_NAME).index('byDocId'); 217 + const request = index.getAll(docId); 218 + request.onsuccess = () => resolve(request.result as BackupEntry[]); 219 + request.onerror = () => resolve([]); 220 + }); 221 + 222 + db.close(); 223 + 224 + return entries 225 + .sort((a, b) => b.timestamp - a.timestamp || b.id.localeCompare(a.id)) 226 + .map(({ id, docId, hash, timestamp }) => ({ id, docId, hash, timestamp })); 227 + } catch { 228 + try { db.close(); } catch { /* ignore */ } 229 + return []; 230 + } 231 + }
+113 -22
src/lib/provider.ts
··· 14 14 import * as encoding from 'lib0/encoding'; 15 15 import * as decoding from 'lib0/decoding'; 16 16 import { encrypt, decrypt } from './crypto.js'; 17 + import { saveLocalBackup, loadLocalBackup } from './local-backup.js'; 17 18 18 19 // Message types (first byte after decryption) 19 20 const MSG_SYNC_STEP1 = 0 as const; ··· 24 25 const SNAPSHOT_INTERVAL = 10_000; // Save snapshot every 10s 25 26 const SAVE_DEBOUNCE = 500; // Debounce save after changes 26 27 const MIN_SNAPSHOT_BYTES = 10; // Minimum plausible Yjs state size 28 + const MAX_SAVE_RETRIES = 3; // Retry count for server save failures 29 + const RETRY_BASE_MS = 1000; // Base delay for exponential backoff (1s, 2s, 4s) 30 + 31 + export type SaveStatus = 'saving' | 'saved' | 'error'; 32 + 33 + export interface SaveStatusPayload { 34 + status: SaveStatus; 35 + } 27 36 28 - type ProviderEvent = 'sync' | 'status' | 'awareness'; 37 + type ProviderEvent = 'sync' | 'status' | 'awareness' | 'save-status'; 29 38 30 39 interface StatusPayload { 31 40 connected: boolean; ··· 47 56 apiUrl?: string; 48 57 } 49 58 50 - type ProviderEventCallback = ((...args: [boolean] | [StatusPayload]) => void); 59 + type ProviderEventCallback = ((...args: [boolean] | [StatusPayload] | [SaveStatusPayload]) => void); 60 + 61 + type EventCallbackMap = { 62 + 'sync': (synced: boolean) => void; 63 + 'status': (payload: StatusPayload) => void; 64 + 'awareness': (payload: StatusPayload) => void; 65 + 'save-status': (payload: SaveStatusPayload) => void; 66 + }; 51 67 52 68 export class EncryptedProvider { 53 69 doc: Y.Doc; ··· 59 75 awareness: Awareness; 60 76 connected: boolean; 61 77 synced: boolean; 78 + saveStatus: SaveStatus; 62 79 _listeners: Record<ProviderEvent, ProviderEventCallback[]>; 63 80 _snapshotTimer: ReturnType<typeof setInterval> | null; 64 81 _saveDebounce: ReturnType<typeof setTimeout> | null; 65 82 _destroyed: boolean; 66 83 _hadSnapshot: boolean; 84 + _hasUnsavedChanges: boolean; 67 85 _lastSaveTime: number | undefined; 68 86 _lastVersionTime: number | undefined; 69 87 70 88 _onDocUpdate: (update: Uint8Array, origin: unknown) => void; 71 89 _onAwarenessUpdate: (payload: AwarenessUpdatePayload) => void; 72 - _onBeforeUnload: () => void; 90 + _onBeforeUnload: (event: BeforeUnloadEvent) => void; 73 91 74 92 constructor(doc: Y.Doc, roomId: string, cryptoKey: CryptoKey, opts: ProviderOptions = {}) { 75 93 this.doc = doc; ··· 81 99 this.awareness = new Awareness(doc); 82 100 this.connected = false; 83 101 this.synced = false; 84 - this._listeners = { sync: [], status: [], awareness: [] }; 102 + this.saveStatus = 'saved'; 103 + this._listeners = { sync: [], status: [], awareness: [], 'save-status': [] }; 85 104 this._snapshotTimer = null; 86 105 this._saveDebounce = null; 87 106 this._destroyed = false; 88 107 this._hadSnapshot = false; // Track whether a snapshot was loaded 108 + this._hasUnsavedChanges = false; 89 109 90 110 // Bind handlers 91 111 this._onDocUpdate = this._handleDocUpdate.bind(this); ··· 106 126 this.connect(); 107 127 } 108 128 109 - on(event: ProviderEvent, fn: ProviderEventCallback): void { 110 - (this._listeners[event] || []).push(fn); 129 + on<E extends keyof EventCallbackMap>(event: E, fn: EventCallbackMap[E]): void { 130 + (this._listeners[event] || []).push(fn as ProviderEventCallback); 111 131 } 112 132 113 - _emit(event: ProviderEvent, ...args: [boolean] | [StatusPayload]): void { 114 - for (const fn of this._listeners[event] || []) fn(...args as [boolean & StatusPayload]); 133 + _emit(event: ProviderEvent, ...args: [boolean] | [StatusPayload] | [SaveStatusPayload]): void { 134 + for (const fn of this._listeners[event] || []) fn(...args as [boolean & StatusPayload & SaveStatusPayload]); 135 + } 136 + 137 + _setSaveStatus(status: SaveStatus): void { 138 + this.saveStatus = status; 139 + this._emit('save-status', { status }); 115 140 } 116 141 117 142 async connect(): Promise<void> { ··· 244 269 245 270 _handleDocUpdate(update: Uint8Array, origin: unknown): void { 246 271 if (origin === this) return; // Don't echo back updates we received from peers 272 + this._hasUnsavedChanges = true; 247 273 this._sendMessage(MSG_UPDATE, (enc: encoding.Encoder) => { 248 274 encoding.writeVarUint8Array(enc, update); 249 275 }); ··· 257 283 this._saveDebounce = setTimeout(() => this._saveSnapshot(), SAVE_DEBOUNCE); 258 284 } 259 285 260 - _handleBeforeUnload(): void { 261 - // Fire the save immediately (async — browser gives us a brief window) 286 + _handleBeforeUnload(event: BeforeUnloadEvent): void { 287 + if (this._hasUnsavedChanges) { 288 + // Warn user about unsaved changes 289 + event.preventDefault(); 290 + // Emergency IDB backup (fire-and-forget — browser gives brief window) 291 + try { 292 + const state = Y.encodeStateAsUpdate(this.doc); 293 + encrypt(state, this.cryptoKey).then(encrypted => { 294 + saveLocalBackup(this.roomId, encrypted).catch(() => { /* best effort */ }); 295 + }).catch(() => { /* best effort */ }); 296 + } catch { /* best effort */ } 297 + } 298 + // Also try the normal server save 262 299 this._saveSnapshot(); 263 300 } 264 301 ··· 273 310 async _loadSnapshot(): Promise<void> { 274 311 try { 275 312 const res = await fetch(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`); 276 - if (!res.ok) return; // No snapshot yet 313 + if (!res.ok) { 314 + // Server failed — try local IDB backup as fallback 315 + await this._loadFromLocalBackup(); 316 + return; 317 + } 277 318 const encrypted = new Uint8Array(await res.arrayBuffer()); 278 319 const plain = await decrypt(encrypted, this.cryptoKey); 279 320 ··· 286 327 Y.applyUpdate(this.doc, plain); 287 328 this._hadSnapshot = true; 288 329 } catch (err: unknown) { 289 - // No snapshot or wrong key — start fresh 290 - console.log('No existing snapshot or decryption failed, starting fresh'); 330 + // Server load failed — try local backup 331 + console.log('Server snapshot load failed, trying local backup'); 332 + await this._loadFromLocalBackup(); 333 + } 334 + } 335 + 336 + /** Fallback: load from IndexedDB local backup. */ 337 + async _loadFromLocalBackup(): Promise<void> { 338 + try { 339 + const backup = await loadLocalBackup(this.roomId); 340 + if (!backup) return; 341 + 342 + const plain = await decrypt(new Uint8Array(backup.encrypted), this.cryptoKey); 343 + if (plain.byteLength < MIN_SNAPSHOT_BYTES) return; 344 + 345 + Y.applyUpdate(this.doc, plain); 346 + this._hadSnapshot = true; 347 + console.log('Loaded snapshot from local backup'); 348 + } catch { 349 + console.log('No local backup available, starting fresh'); 291 350 } 292 351 } 293 352 ··· 298 357 // Don't save while an import is in progress — the doc may be partially populated 299 358 if (typeof window !== 'undefined' && (window as Window & { __importInProgress?: boolean }).__importInProgress) return; 300 359 360 + this._setSaveStatus('saving'); 361 + 301 362 try { 302 363 const state = Y.encodeStateAsUpdate(this.doc); 303 364 ··· 324 385 } 325 386 326 387 const encrypted = await encrypt(state, this.cryptoKey); 327 - await fetch(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`, { 328 - method: 'PUT', 329 - headers: { 'Content-Type': 'application/octet-stream' }, 330 - body: encrypted, 331 - }); 332 - this._lastSaveTime = Date.now(); 388 + 389 + // Save to server with retry logic 390 + let saved = false; 391 + for (let attempt = 0; attempt < MAX_SAVE_RETRIES; attempt++) { 392 + try { 393 + const res = await fetch(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`, { 394 + method: 'PUT', 395 + headers: { 'Content-Type': 'application/octet-stream' }, 396 + body: encrypted, 397 + }); 398 + if (res.ok) { 399 + saved = true; 400 + break; 401 + } 402 + } catch { /* retry */ } 403 + 404 + // Exponential backoff: 1s, 2s, 4s 405 + if (attempt < MAX_SAVE_RETRIES - 1) { 406 + await new Promise(resolve => setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))); 407 + } 408 + } 409 + 410 + // Always save to local IDB backup (regardless of server success) 411 + try { 412 + await saveLocalBackup(this.roomId, encrypted); 413 + } catch { /* IDB backup is best-effort */ } 414 + 415 + if (saved) { 416 + this._lastSaveTime = Date.now(); 417 + this._hasUnsavedChanges = false; 418 + this._setSaveStatus('saved'); 333 419 334 - // Once we've saved meaningful data, protect against future empty saves 335 - if (state.byteLength >= MIN_SNAPSHOT_BYTES) { 336 - this._hadSnapshot = true; 420 + // Once we've saved meaningful data, protect against future empty saves 421 + if (state.byteLength >= MIN_SNAPSHOT_BYTES) { 422 + this._hadSnapshot = true; 423 + } 424 + } else { 425 + console.warn('Failed to save snapshot after %d retries', MAX_SAVE_RETRIES); 426 + this._setSaveStatus('error'); 337 427 } 338 428 } catch (err: unknown) { 339 429 console.warn('Failed to save snapshot', err); 430 + this._setSaveStatus('error'); 340 431 } 341 432 } 342 433
+3
src/sheets/index.html
··· 30 30 <input class="doc-title-input" id="doc-title" type="text" value="Untitled Spreadsheet" spellcheck="false"> 31 31 <span class="topbar-spacer"></span> 32 32 <div class="collab-avatars" id="collab-avatars"></div> 33 + <!-- Version history --> 34 + <button class="btn-icon" id="btn-history" title="Version history">&#128339;</button> 33 35 <!-- Share button --> 34 36 <button class="btn-icon" id="btn-share" title="Share spreadsheet">&#128279;</button> 35 37 <div class="save-indicator saved" id="save-indicator"> 38 + <span class="save-dot save-dot--saved"></span> 36 39 <span id="save-text">Saved</span> 37 40 </div> 38 41 <div class="status-indicator" id="status">
+43 -6
src/sheets/main.ts
··· 9 9 import * as Y from 'yjs'; 10 10 import { importKey, encryptString, decryptString } from '../lib/crypto.js'; 11 11 import { EncryptedProvider } from '../lib/provider.js'; 12 + import { createVersionPanel } from '../version-panel.js'; 12 13 import { evaluate, extractRefs, formatCell, parseRef, colToLetter, letterToCol, cellId } from './formulas.js'; 13 14 import { RecalcEngine } from './recalc.js'; 14 15 import { importXlsx, isValidXlsx } from './xlsx-import.js'; ··· 1884 1885 1885 1886 setInterval(updateSaveTimestamp, 30_000); 1886 1887 1887 - const origSaveSnapshot = provider._saveSnapshot.bind(provider); 1888 - provider._saveSnapshot = async function() { 1889 - setSaveState('saving'); 1890 - await origSaveSnapshot(); 1891 - setSaveState('saved', Date.now()); 1892 - }; 1888 + // Listen for save-status events from the provider (replaces monkey-patching) 1889 + provider.on('save-status', (payload) => { 1890 + if (payload.status === 'saving') setSaveState('saving'); 1891 + else if (payload.status === 'saved') setSaveState('saved', Date.now()); 1892 + else if (payload.status === 'error') setSaveState('unsaved'); 1893 + }); 1894 + 1895 + // Update save dot color based on save-status events 1896 + const saveDotEl = document.querySelector('.save-dot'); 1897 + if (saveDotEl) { 1898 + provider.on('save-status', (payload) => { 1899 + saveDotEl.classList.remove('save-dot--saved', 'save-dot--saving', 'save-dot--error'); 1900 + if (payload.status === 'saving') saveDotEl.classList.add('save-dot--saving'); 1901 + else if (payload.status === 'saved') saveDotEl.classList.add('save-dot--saved'); 1902 + else if (payload.status === 'error') saveDotEl.classList.add('save-dot--error'); 1903 + }); 1904 + } 1893 1905 1894 1906 ydoc.on('update', (update, origin) => { 1895 1907 if (origin !== provider && saveState === 'saved') setSaveState('unsaved'); 1896 1908 }); 1909 + 1910 + // --- Version Panel (slide-in, Cmd+Shift+H) --- 1911 + const sheetsVersionPanel = createVersionPanel({ 1912 + docId, 1913 + cryptoKey, 1914 + docType: 'sheet', 1915 + onRestore: async (_versionId, decryptedData) => { 1916 + Y.applyUpdate(ydoc, decryptedData); 1917 + await provider._saveSnapshot(); 1918 + }, 1919 + }); 1920 + 1921 + // Wire Cmd+Shift+H to toggle version panel 1922 + document.addEventListener('keydown', (e) => { 1923 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'h') { 1924 + e.preventDefault(); 1925 + sheetsVersionPanel.toggle(); 1926 + } 1927 + }); 1928 + 1929 + // Wire version history button if present 1930 + const btnHistory = document.getElementById('btn-history'); 1931 + if (btnHistory) { 1932 + btnHistory.addEventListener('click', () => sheetsVersionPanel.toggle()); 1933 + } 1897 1934 1898 1935 // --- Keyboard Shortcut Cheatsheet Modal (#15) --- 1899 1936 const SHEETS_SHORTCUTS = [
+403
src/version-panel.ts
··· 1 + /** 2 + * Version History Panel — reusable slide-in panel for docs and sheets. 3 + * 4 + * Exports pure functions (testable) and a panel factory. 5 + */ 6 + 7 + // --- Pure functions (exported for testing) --- 8 + 9 + /** 10 + * Format an ISO date string as a human-readable relative time. 11 + * 12 + * Rules: 13 + * - < 60s: "Just now" 14 + * - < 60m: "N min ago" 15 + * - < 24h: "N hours ago" 16 + * - < 48h: "Yesterday" 17 + * - else: "Mar 15" (short month + day) 18 + */ 19 + export function formatRelativeTime(dateStr: string): string { 20 + if (!dateStr) return 'Unknown'; 21 + 22 + // Server returns UTC without 'Z' suffix — normalise 23 + const normalised = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; 24 + const date = new Date(normalised); 25 + if (isNaN(date.getTime())) return 'Unknown'; 26 + 27 + const now = Date.now(); 28 + const diffMs = now - date.getTime(); 29 + const diffSec = Math.floor(diffMs / 1000); 30 + const diffMin = Math.floor(diffSec / 60); 31 + const diffHours = Math.floor(diffMin / 60); 32 + const diffDays = Math.floor(diffHours / 24); 33 + 34 + if (diffSec < 60) return 'Just now'; 35 + if (diffMin < 60) return `${diffMin} min ago`; 36 + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; 37 + if (diffDays < 2) return 'Yesterday'; 38 + 39 + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 40 + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 41 + return `${months[date.getMonth()]} ${date.getDate()}`; 42 + } 43 + 44 + /** 45 + * Safely parse a JSON metadata string. 46 + * Returns an empty object on any failure. 47 + */ 48 + export function parseMetadata(raw: string | null): Record<string, unknown> { 49 + if (!raw) return {}; 50 + try { 51 + const parsed = JSON.parse(raw); 52 + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { 53 + return parsed as Record<string, unknown>; 54 + } 55 + return {}; 56 + } catch { 57 + return {}; 58 + } 59 + } 60 + 61 + /** 62 + * Compute diff stats between current and previous version metadata. 63 + */ 64 + export function computeDiffStats( 65 + current?: { wordCount?: number }, 66 + previous?: { wordCount?: number }, 67 + ): { delta: number; label: string } { 68 + const curCount = current?.wordCount ?? 0; 69 + const prevCount = previous?.wordCount ?? 0; 70 + 71 + const delta = curCount - prevCount; 72 + 73 + if (!previous || previous.wordCount === undefined) { 74 + return { delta: curCount, label: `+${curCount}` }; 75 + } 76 + if (delta > 0) return { delta, label: `+${delta}` }; 77 + if (delta < 0) return { delta, label: `${delta}` }; 78 + return { delta: 0, label: '0' }; 79 + } 80 + 81 + // --- Panel UI --- 82 + 83 + export interface VersionPanelConfig { 84 + /** Document ID */ 85 + docId: string; 86 + /** API base URL (empty for same-origin) */ 87 + apiUrl?: string; 88 + /** Crypto key for decrypting version snapshots */ 89 + cryptoKey: CryptoKey; 90 + /** Container to mount the panel into (defaults to document.body) */ 91 + container?: HTMLElement; 92 + /** Callback when user confirms version restore */ 93 + onRestore?: (versionId: string, decryptedData: Uint8Array) => Promise<void>; 94 + /** Document type for display context */ 95 + docType?: 'doc' | 'sheet'; 96 + } 97 + 98 + interface VersionData { 99 + id: string; 100 + document_id: string; 101 + created_at: string; 102 + metadata: Record<string, unknown> | null; 103 + _name?: string; 104 + } 105 + 106 + export interface VersionPanel { 107 + toggle: () => void; 108 + open: () => void; 109 + close: () => void; 110 + isOpen: () => boolean; 111 + destroy: () => void; 112 + } 113 + 114 + export function createVersionPanel(config: VersionPanelConfig): VersionPanel { 115 + const { 116 + docId, 117 + apiUrl = '', 118 + container = document.body, 119 + onRestore, 120 + docType = 'doc', 121 + } = config; 122 + 123 + // Build DOM 124 + const panel = document.createElement('div'); 125 + panel.className = 'version-panel'; 126 + panel.setAttribute('role', 'dialog'); 127 + panel.setAttribute('aria-label', 'Version history'); 128 + 129 + panel.innerHTML = ` 130 + <div class="version-panel-header"> 131 + <h3>Version History</h3> 132 + <button class="btn-icon version-panel-close" title="Close (Esc)" aria-label="Close">&times;</button> 133 + </div> 134 + <div class="version-panel-list"></div> 135 + <div class="version-panel-preview" style="display:none"> 136 + <div class="version-panel-preview-header"> 137 + <button class="btn-ghost version-panel-back">&larr; Back</button> 138 + <button class="btn-primary btn-sm version-panel-restore">Restore this version</button> 139 + </div> 140 + <div class="version-panel-preview-content"></div> 141 + </div> 142 + `; 143 + 144 + container.appendChild(panel); 145 + 146 + const closeBtn = panel.querySelector('.version-panel-close') as HTMLButtonElement; 147 + const listEl = panel.querySelector('.version-panel-list') as HTMLDivElement; 148 + const previewEl = panel.querySelector('.version-panel-preview') as HTMLDivElement; 149 + const previewContent = panel.querySelector('.version-panel-preview-content') as HTMLDivElement; 150 + const backBtn = panel.querySelector('.version-panel-back') as HTMLButtonElement; 151 + const restoreBtn = panel.querySelector('.version-panel-restore') as HTMLButtonElement; 152 + 153 + let isOpen_ = false; 154 + let selectedVersionId: string | null = null; 155 + let versions: VersionData[] = []; 156 + 157 + // --- Event handlers --- 158 + closeBtn.addEventListener('click', close); 159 + backBtn.addEventListener('click', () => { 160 + previewEl.style.display = 'none'; 161 + selectedVersionId = null; 162 + }); 163 + restoreBtn.addEventListener('click', handleRestore); 164 + 165 + function handleKeydown(e: KeyboardEvent): void { 166 + if (e.key === 'Escape' && isOpen_) { 167 + e.preventDefault(); 168 + close(); 169 + } 170 + // Cmd+Shift+H toggles 171 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'h') { 172 + e.preventDefault(); 173 + toggle(); 174 + } 175 + } 176 + document.addEventListener('keydown', handleKeydown); 177 + 178 + // --- Methods --- 179 + function toggle(): void { 180 + if (isOpen_) close(); 181 + else open(); 182 + } 183 + 184 + function open(): void { 185 + isOpen_ = true; 186 + panel.classList.add('open'); 187 + loadVersions(); 188 + } 189 + 190 + function close(): void { 191 + isOpen_ = false; 192 + panel.classList.remove('open'); 193 + previewEl.style.display = 'none'; 194 + selectedVersionId = null; 195 + } 196 + 197 + async function loadVersions(): Promise<void> { 198 + listEl.innerHTML = '<div class="version-empty">Loading...</div>'; 199 + 200 + try { 201 + const res = await fetch(`${apiUrl}/api/documents/${docId}/versions`); 202 + if (!res.ok) throw new Error('Failed to fetch'); 203 + versions = (await res.json()) as VersionData[]; 204 + 205 + if (versions.length === 0) { 206 + listEl.innerHTML = '<div class="version-empty">No versions yet</div>'; 207 + return; 208 + } 209 + 210 + listEl.innerHTML = ''; 211 + 212 + for (let i = 0; i < versions.length; i++) { 213 + const v = versions[i]!; 214 + const meta = (v.metadata && typeof v.metadata === 'object') ? v.metadata : {}; 215 + const prevMeta = (i < versions.length - 1) 216 + ? ((versions[i + 1]!.metadata && typeof versions[i + 1]!.metadata === 'object') 217 + ? versions[i + 1]!.metadata as Record<string, unknown> 218 + : {}) 219 + : undefined; 220 + 221 + const countKey = docType === 'sheet' ? 'cellCount' : 'wordCount'; 222 + const stats = computeDiffStats( 223 + { wordCount: typeof meta[countKey] === 'number' ? (meta[countKey] as number) : undefined }, 224 + prevMeta ? { wordCount: typeof prevMeta[countKey] === 'number' ? (prevMeta[countKey] as number) : undefined } : undefined, 225 + ); 226 + 227 + const item = document.createElement('div'); 228 + item.className = 'version-panel-item'; 229 + item.setAttribute('role', 'button'); 230 + item.setAttribute('tabindex', '0'); 231 + 232 + const timeStr = formatRelativeTime(v.created_at); 233 + const author = (typeof meta['author'] === 'string' ? meta['author'] : null) || 'Unknown'; 234 + const countVal = typeof meta[countKey] === 'number' ? (meta[countKey] as number) : null; 235 + const countLabel = docType === 'sheet' ? 'cells' : 'words'; 236 + const namedVersion = v._name || (typeof meta['name'] === 'string' ? meta['name'] as string : null); 237 + 238 + const deltaClass = stats.delta > 0 ? 'positive' : stats.delta < 0 ? 'negative' : ''; 239 + 240 + item.innerHTML = ` 241 + <div class="version-panel-item-top"> 242 + <span class="version-panel-time">${timeStr}</span> 243 + ${namedVersion ? `<span class="version-panel-named-badge">${escapeHtml(namedVersion)}</span>` : ''} 244 + </div> 245 + <div class="version-panel-item-meta"> 246 + <span class="version-panel-author">${escapeHtml(author)}</span> 247 + ${countVal !== null ? `<span class="version-panel-count">${countVal} ${countLabel}</span>` : ''} 248 + <span class="version-panel-delta ${deltaClass}">${stats.label}</span> 249 + </div> 250 + <button class="version-panel-name-btn" title="Name this version">&#9998;</button> 251 + `; 252 + 253 + const nameBtn = item.querySelector('.version-panel-name-btn') as HTMLButtonElement; 254 + nameBtn.addEventListener('click', (e) => { 255 + e.stopPropagation(); 256 + promptNameVersion(v.id, nameBtn); 257 + }); 258 + 259 + item.addEventListener('click', () => showPreview(v.id)); 260 + item.addEventListener('keydown', (e) => { 261 + if (e.key === 'Enter' || e.key === ' ') { 262 + e.preventDefault(); 263 + showPreview(v.id); 264 + } 265 + }); 266 + 267 + listEl.appendChild(item); 268 + } 269 + } catch { 270 + listEl.innerHTML = '<div class="version-empty">Failed to load versions</div>'; 271 + } 272 + } 273 + 274 + async function promptNameVersion(versionId: string, btn: HTMLButtonElement): Promise<void> { 275 + const name = prompt('Name this version:'); 276 + if (!name) return; 277 + 278 + try { 279 + await fetch(`${apiUrl}/api/documents/${docId}/versions/${versionId}/metadata`, { 280 + method: 'PUT', 281 + headers: { 'Content-Type': 'application/json' }, 282 + body: JSON.stringify({ name }), 283 + }); 284 + 285 + // Update local state 286 + const v = versions.find(ver => ver.id === versionId); 287 + if (v && v.metadata && typeof v.metadata === 'object') { 288 + (v.metadata as Record<string, unknown>)['name'] = name; 289 + } 290 + 291 + // Refresh the badge in the parent element 292 + const itemTop = btn.parentElement?.querySelector('.version-panel-item-top'); 293 + if (itemTop) { 294 + const existingBadge = itemTop.querySelector('.version-panel-named-badge'); 295 + if (existingBadge) { 296 + existingBadge.textContent = name; 297 + } else { 298 + const badge = document.createElement('span'); 299 + badge.className = 'version-panel-named-badge'; 300 + badge.textContent = name; 301 + itemTop.appendChild(badge); 302 + } 303 + } 304 + } catch { 305 + // Silently fail — best effort 306 + } 307 + } 308 + 309 + async function showPreview(versionId: string): Promise<void> { 310 + selectedVersionId = versionId; 311 + previewEl.style.display = ''; 312 + previewContent.textContent = 'Loading...'; 313 + 314 + try { 315 + const res = await fetch(`${apiUrl}/api/documents/${docId}/versions/${versionId}`); 316 + if (!res.ok) throw new Error('Not found'); 317 + const encrypted = new Uint8Array(await res.arrayBuffer()); 318 + 319 + // Decrypt 320 + const { decrypt } = await import('./lib/crypto.js'); 321 + const decrypted = await decrypt(encrypted, config.cryptoKey); 322 + 323 + // Render preview using Yjs 324 + const Y = await import('yjs'); 325 + const tempDoc = new Y.Doc(); 326 + Y.applyUpdate(tempDoc, decrypted); 327 + 328 + previewContent.innerHTML = ''; 329 + 330 + if (docType === 'sheet') { 331 + // Simple cell preview for sheets 332 + const sheets = tempDoc.getMap('sheets'); 333 + const previewDiv = document.createElement('div'); 334 + previewDiv.className = 'version-preview-text'; 335 + const firstSheet = sheets.get('sheet_0') as import('yjs').Map<unknown> | undefined; 336 + if (firstSheet) { 337 + const cells = firstSheet.get('cells') as import('yjs').Map<unknown> | undefined; 338 + if (cells) { 339 + const cellCount = cells.size; 340 + previewDiv.textContent = `${cellCount} cells with data`; 341 + } else { 342 + previewDiv.textContent = 'Empty sheet'; 343 + } 344 + } else { 345 + previewDiv.textContent = 'Empty spreadsheet'; 346 + } 347 + previewContent.appendChild(previewDiv); 348 + } else { 349 + // Doc preview 350 + const fragment = tempDoc.getXmlFragment('default'); 351 + const previewDiv = document.createElement('div'); 352 + previewDiv.className = 'version-preview-text'; 353 + previewDiv.textContent = fragment.toString(); 354 + previewContent.appendChild(previewDiv); 355 + } 356 + 357 + tempDoc.destroy(); 358 + } catch { 359 + previewContent.textContent = 'Failed to load version preview'; 360 + } 361 + } 362 + 363 + async function handleRestore(): Promise<void> { 364 + if (!selectedVersionId) return; 365 + if (!confirm('Restore this version? Current changes will be replaced.')) return; 366 + 367 + try { 368 + const res = await fetch(`${apiUrl}/api/documents/${docId}/versions/${selectedVersionId}`); 369 + if (!res.ok) throw new Error('Not found'); 370 + const encrypted = new Uint8Array(await res.arrayBuffer()); 371 + 372 + const { decrypt } = await import('./lib/crypto.js'); 373 + const decrypted = await decrypt(encrypted, config.cryptoKey); 374 + 375 + if (onRestore) { 376 + await onRestore(selectedVersionId, decrypted); 377 + } 378 + 379 + close(); 380 + } catch { 381 + alert('Failed to restore version'); 382 + } 383 + } 384 + 385 + function escapeHtml(str: string): string { 386 + const div = document.createElement('div'); 387 + div.textContent = str; 388 + return div.innerHTML; 389 + } 390 + 391 + function destroy(): void { 392 + document.removeEventListener('keydown', handleKeydown); 393 + panel.remove(); 394 + } 395 + 396 + return { 397 + toggle, 398 + open, 399 + close, 400 + isOpen: () => isOpen_, 401 + destroy, 402 + }; 403 + }
+264
tests/local-backup.test.ts
··· 1 + import 'fake-indexeddb/auto'; 2 + import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 3 + 4 + import { 5 + fnv1aHash, 6 + saveLocalBackup, 7 + loadLocalBackup, 8 + clearLocalBackup, 9 + listLocalBackups, 10 + } from '../src/lib/local-backup.js'; 11 + 12 + // Helper: create a Uint8Array from a string for testing 13 + function strToBytes(s: string): Uint8Array { 14 + return new TextEncoder().encode(s); 15 + } 16 + 17 + // Helper: create an ArrayBuffer from a string 18 + function strToBuffer(s: string): ArrayBuffer { 19 + const bytes = strToBytes(s); 20 + const ab = new ArrayBuffer(bytes.byteLength); 21 + new Uint8Array(ab).set(bytes); 22 + return ab; 23 + } 24 + 25 + // Clean up IndexedDB between tests 26 + async function deleteDB(): Promise<void> { 27 + return new Promise((resolve) => { 28 + const req = indexedDB.deleteDatabase('tools-backups'); 29 + req.onsuccess = () => resolve(); 30 + req.onerror = () => resolve(); 31 + req.onblocked = () => resolve(); 32 + }); 33 + } 34 + 35 + describe('fnv1aHash', () => { 36 + it('returns a 32-bit unsigned integer', () => { 37 + const hash = fnv1aHash(strToBytes('hello')); 38 + expect(typeof hash).toBe('number'); 39 + expect(hash).toBeGreaterThanOrEqual(0); 40 + expect(hash).toBeLessThanOrEqual(0xFFFFFFFF); 41 + }); 42 + 43 + it('produces consistent hashes for the same input', () => { 44 + const a = fnv1aHash(strToBytes('test data')); 45 + const b = fnv1aHash(strToBytes('test data')); 46 + expect(a).toBe(b); 47 + }); 48 + 49 + it('produces different hashes for different inputs', () => { 50 + const a = fnv1aHash(strToBytes('hello')); 51 + const b = fnv1aHash(strToBytes('world')); 52 + expect(a).not.toBe(b); 53 + }); 54 + 55 + it('handles empty input', () => { 56 + const hash = fnv1aHash(new Uint8Array(0)); 57 + expect(typeof hash).toBe('number'); 58 + // FNV-1a of empty input should be the offset basis 59 + expect(hash).toBe(0x811c9dc5); 60 + }); 61 + 62 + it('handles single byte', () => { 63 + const hash = fnv1aHash(new Uint8Array([0x61])); // 'a' 64 + expect(typeof hash).toBe('number'); 65 + expect(hash).toBeGreaterThan(0); 66 + }); 67 + 68 + it('handles large input without overflow issues', () => { 69 + const large = new Uint8Array(10000); 70 + for (let i = 0; i < large.length; i++) large[i] = i % 256; 71 + const hash = fnv1aHash(large); 72 + expect(hash).toBeGreaterThanOrEqual(0); 73 + expect(hash).toBeLessThanOrEqual(0xFFFFFFFF); 74 + }); 75 + 76 + it('is sensitive to byte order', () => { 77 + const a = fnv1aHash(new Uint8Array([1, 2, 3])); 78 + const b = fnv1aHash(new Uint8Array([3, 2, 1])); 79 + expect(a).not.toBe(b); 80 + }); 81 + }); 82 + 83 + describe('saveLocalBackup', () => { 84 + beforeEach(async () => { 85 + await deleteDB(); 86 + }); 87 + 88 + afterEach(async () => { 89 + await deleteDB(); 90 + }); 91 + 92 + it('saves a backup that can be loaded', async () => { 93 + await saveLocalBackup('doc1', strToBuffer('encrypted-data')); 94 + const result = await loadLocalBackup('doc1'); 95 + expect(result).not.toBeNull(); 96 + expect(result!.docId).toBe('doc1'); 97 + expect(new Uint8Array(result!.encrypted)).toEqual(strToBytes('encrypted-data')); 98 + }); 99 + 100 + it('saves multiple backups for the same doc', async () => { 101 + await saveLocalBackup('doc1', strToBuffer('v1')); 102 + // Need different content (different hash) for it not to be skipped 103 + await saveLocalBackup('doc1', strToBuffer('v2')); 104 + await saveLocalBackup('doc1', strToBuffer('v3')); 105 + 106 + const backups = await listLocalBackups('doc1'); 107 + expect(backups.length).toBe(3); 108 + }); 109 + 110 + it('prunes to keep only 3 backups per doc', async () => { 111 + await saveLocalBackup('doc1', strToBuffer('v1')); 112 + await saveLocalBackup('doc1', strToBuffer('v2')); 113 + await saveLocalBackup('doc1', strToBuffer('v3')); 114 + await saveLocalBackup('doc1', strToBuffer('v4')); 115 + 116 + const backups = await listLocalBackups('doc1'); 117 + expect(backups.length).toBe(3); 118 + }); 119 + 120 + it('skips duplicate saves (same hash)', async () => { 121 + await saveLocalBackup('doc1', strToBuffer('same-data')); 122 + await saveLocalBackup('doc1', strToBuffer('same-data')); 123 + 124 + const backups = await listLocalBackups('doc1'); 125 + expect(backups.length).toBe(1); 126 + }); 127 + 128 + it('accepts a pre-computed hash', async () => { 129 + const data = strToBuffer('test'); 130 + const hash = fnv1aHash(new Uint8Array(data)); 131 + await saveLocalBackup('doc1', data, hash); 132 + const result = await loadLocalBackup('doc1'); 133 + expect(result).not.toBeNull(); 134 + expect(result!.hash).toBe(hash); 135 + }); 136 + 137 + it('handles different docs independently', async () => { 138 + await saveLocalBackup('doc1', strToBuffer('data-1')); 139 + await saveLocalBackup('doc2', strToBuffer('data-2')); 140 + 141 + const r1 = await loadLocalBackup('doc1'); 142 + const r2 = await loadLocalBackup('doc2'); 143 + expect(r1).not.toBeNull(); 144 + expect(r2).not.toBeNull(); 145 + expect(new Uint8Array(r1!.encrypted)).toEqual(strToBytes('data-1')); 146 + expect(new Uint8Array(r2!.encrypted)).toEqual(strToBytes('data-2')); 147 + }); 148 + }); 149 + 150 + describe('loadLocalBackup', () => { 151 + beforeEach(async () => { 152 + await deleteDB(); 153 + }); 154 + 155 + afterEach(async () => { 156 + await deleteDB(); 157 + }); 158 + 159 + it('returns null when no backup exists', async () => { 160 + const result = await loadLocalBackup('nonexistent'); 161 + expect(result).toBeNull(); 162 + }); 163 + 164 + it('returns the most recent backup', async () => { 165 + await saveLocalBackup('doc1', strToBuffer('old')); 166 + await saveLocalBackup('doc1', strToBuffer('new')); 167 + 168 + const result = await loadLocalBackup('doc1'); 169 + expect(result).not.toBeNull(); 170 + expect(new Uint8Array(result!.encrypted)).toEqual(strToBytes('new')); 171 + }); 172 + 173 + it('returns backup with correct structure', async () => { 174 + await saveLocalBackup('doc1', strToBuffer('data')); 175 + const result = await loadLocalBackup('doc1'); 176 + expect(result).not.toBeNull(); 177 + expect(result!.id).toContain('doc1-'); 178 + expect(result!.docId).toBe('doc1'); 179 + expect(typeof result!.hash).toBe('number'); 180 + expect(typeof result!.timestamp).toBe('number'); 181 + expect(result!.encrypted).toBeInstanceOf(ArrayBuffer); 182 + }); 183 + }); 184 + 185 + describe('clearLocalBackup', () => { 186 + beforeEach(async () => { 187 + await deleteDB(); 188 + }); 189 + 190 + afterEach(async () => { 191 + await deleteDB(); 192 + }); 193 + 194 + it('removes all backups for a doc', async () => { 195 + await saveLocalBackup('doc1', strToBuffer('v1')); 196 + await saveLocalBackup('doc1', strToBuffer('v2')); 197 + 198 + await clearLocalBackup('doc1'); 199 + const result = await loadLocalBackup('doc1'); 200 + expect(result).toBeNull(); 201 + }); 202 + 203 + it('does not affect other docs', async () => { 204 + await saveLocalBackup('doc1', strToBuffer('data')); 205 + await saveLocalBackup('doc2', strToBuffer('data')); 206 + 207 + await clearLocalBackup('doc1'); 208 + const r1 = await loadLocalBackup('doc1'); 209 + const r2 = await loadLocalBackup('doc2'); 210 + expect(r1).toBeNull(); 211 + expect(r2).not.toBeNull(); 212 + }); 213 + 214 + it('handles clearing nonexistent doc gracefully', async () => { 215 + // Should not throw 216 + await clearLocalBackup('nonexistent'); 217 + const result = await loadLocalBackup('nonexistent'); 218 + expect(result).toBeNull(); 219 + }); 220 + }); 221 + 222 + describe('listLocalBackups', () => { 223 + beforeEach(async () => { 224 + await deleteDB(); 225 + }); 226 + 227 + afterEach(async () => { 228 + await deleteDB(); 229 + }); 230 + 231 + it('returns empty array when no backups exist', async () => { 232 + const result = await listLocalBackups('nonexistent'); 233 + expect(result).toEqual([]); 234 + }); 235 + 236 + it('returns backups sorted newest first', async () => { 237 + await saveLocalBackup('doc1', strToBuffer('v1')); 238 + await saveLocalBackup('doc1', strToBuffer('v2')); 239 + 240 + const backups = await listLocalBackups('doc1'); 241 + expect(backups.length).toBe(2); 242 + expect(backups[0]!.timestamp).toBeGreaterThanOrEqual(backups[1]!.timestamp); 243 + }); 244 + 245 + it('does not include encrypted data in listed entries', async () => { 246 + await saveLocalBackup('doc1', strToBuffer('data')); 247 + const backups = await listLocalBackups('doc1'); 248 + expect(backups.length).toBe(1); 249 + // The 'encrypted' field should not be present 250 + expect((backups[0] as Record<string, unknown>)['encrypted']).toBeUndefined(); 251 + }); 252 + 253 + it('lists only backups for the specified doc', async () => { 254 + await saveLocalBackup('doc1', strToBuffer('a')); 255 + await saveLocalBackup('doc2', strToBuffer('b')); 256 + 257 + const backups1 = await listLocalBackups('doc1'); 258 + const backups2 = await listLocalBackups('doc2'); 259 + expect(backups1.length).toBe(1); 260 + expect(backups2.length).toBe(1); 261 + expect(backups1[0]!.docId).toBe('doc1'); 262 + expect(backups2[0]!.docId).toBe('doc2'); 263 + }); 264 + });
+201
tests/version-panel.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + 3 + /** 4 + * Tests for the Version Panel pure functions. 5 + * 6 + * We test only the exported pure functions (formatRelativeTime, 7 + * parseMetadata, computeDiffStats) — not the DOM-dependent 8 + * createVersionPanel which requires a full browser environment. 9 + */ 10 + 11 + import { 12 + formatRelativeTime, 13 + parseMetadata, 14 + computeDiffStats, 15 + } from '../src/version-panel.js'; 16 + 17 + describe('formatRelativeTime', () => { 18 + it('returns "Just now" for a time less than 60s ago', () => { 19 + const now = new Date(); 20 + const result = formatRelativeTime(now.toISOString()); 21 + expect(result).toBe('Just now'); 22 + }); 23 + 24 + it('returns "N min ago" for times within the hour', () => { 25 + const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000); 26 + const result = formatRelativeTime(fiveMinAgo.toISOString()); 27 + expect(result).toBe('5 min ago'); 28 + }); 29 + 30 + it('returns "1 min ago" for exactly 1 minute', () => { 31 + const oneMinAgo = new Date(Date.now() - 60 * 1000); 32 + const result = formatRelativeTime(oneMinAgo.toISOString()); 33 + expect(result).toBe('1 min ago'); 34 + }); 35 + 36 + it('returns "N hours ago" for times within 24h', () => { 37 + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); 38 + const result = formatRelativeTime(twoHoursAgo.toISOString()); 39 + expect(result).toBe('2 hours ago'); 40 + }); 41 + 42 + it('returns "1 hour ago" for exactly 1 hour (no plural)', () => { 43 + const oneHourAgo = new Date(Date.now() - 1 * 60 * 60 * 1000); 44 + const result = formatRelativeTime(oneHourAgo.toISOString()); 45 + expect(result).toBe('1 hour ago'); 46 + }); 47 + 48 + it('returns "Yesterday" for times 24-48h ago', () => { 49 + const yesterday = new Date(Date.now() - 30 * 60 * 60 * 1000); 50 + const result = formatRelativeTime(yesterday.toISOString()); 51 + expect(result).toBe('Yesterday'); 52 + }); 53 + 54 + it('returns "Mon DD" for older dates', () => { 55 + const oldDate = new Date('2025-03-15T10:00:00Z'); 56 + const result = formatRelativeTime(oldDate.toISOString()); 57 + expect(result).toBe('Mar 15'); 58 + }); 59 + 60 + it('handles dates without Z suffix (server format)', () => { 61 + const now = new Date(); 62 + // Server returns UTC without Z — e.g. "2025-03-15 10:00:00" 63 + const serverFormat = now.toISOString().replace('T', ' ').replace('Z', ''); 64 + const result = formatRelativeTime(serverFormat); 65 + expect(result).toBe('Just now'); 66 + }); 67 + 68 + it('returns "Unknown" for empty string', () => { 69 + expect(formatRelativeTime('')).toBe('Unknown'); 70 + }); 71 + 72 + it('returns "Unknown" for invalid date string', () => { 73 + expect(formatRelativeTime('not-a-date')).toBe('Unknown'); 74 + }); 75 + 76 + it('returns "Unknown" for null-like empty input', () => { 77 + expect(formatRelativeTime('')).toBe('Unknown'); 78 + }); 79 + 80 + it('handles January dates correctly', () => { 81 + const jan = new Date('2025-01-05T10:00:00Z'); 82 + const result = formatRelativeTime(jan.toISOString()); 83 + expect(result).toBe('Jan 5'); 84 + }); 85 + 86 + it('handles December dates correctly', () => { 87 + const dec = new Date('2025-12-25T10:00:00Z'); 88 + const result = formatRelativeTime(dec.toISOString()); 89 + expect(result).toBe('Dec 25'); 90 + }); 91 + }); 92 + 93 + describe('parseMetadata', () => { 94 + it('parses valid JSON string', () => { 95 + const result = parseMetadata('{"author":"Alice","wordCount":42}'); 96 + expect(result).toEqual({ author: 'Alice', wordCount: 42 }); 97 + }); 98 + 99 + it('returns empty object for null input', () => { 100 + expect(parseMetadata(null)).toEqual({}); 101 + }); 102 + 103 + it('returns empty object for empty string', () => { 104 + expect(parseMetadata('')).toEqual({}); 105 + }); 106 + 107 + it('returns empty object for invalid JSON', () => { 108 + expect(parseMetadata('not json')).toEqual({}); 109 + }); 110 + 111 + it('returns empty object for JSON array', () => { 112 + expect(parseMetadata('[1, 2, 3]')).toEqual({}); 113 + }); 114 + 115 + it('returns empty object for JSON primitive', () => { 116 + expect(parseMetadata('"just a string"')).toEqual({}); 117 + }); 118 + 119 + it('returns empty object for JSON number', () => { 120 + expect(parseMetadata('42')).toEqual({}); 121 + }); 122 + 123 + it('handles nested objects', () => { 124 + const result = parseMetadata('{"a":{"b":1}}'); 125 + expect(result).toEqual({ a: { b: 1 } }); 126 + }); 127 + 128 + it('handles empty JSON object', () => { 129 + expect(parseMetadata('{}')).toEqual({}); 130 + }); 131 + 132 + it('preserves all properties in metadata', () => { 133 + const result = parseMetadata('{"author":"Bob","wordCount":100,"timestamp":12345,"custom":"value"}'); 134 + expect(result['author']).toBe('Bob'); 135 + expect(result['wordCount']).toBe(100); 136 + expect(result['timestamp']).toBe(12345); 137 + expect(result['custom']).toBe('value'); 138 + }); 139 + }); 140 + 141 + describe('computeDiffStats', () => { 142 + it('returns positive delta when words added', () => { 143 + const result = computeDiffStats({ wordCount: 100 }, { wordCount: 80 }); 144 + expect(result.delta).toBe(20); 145 + expect(result.label).toBe('+20'); 146 + }); 147 + 148 + it('returns negative delta when words removed', () => { 149 + const result = computeDiffStats({ wordCount: 50 }, { wordCount: 80 }); 150 + expect(result.delta).toBe(-30); 151 + expect(result.label).toBe('-30'); 152 + }); 153 + 154 + it('returns zero delta when no change', () => { 155 + const result = computeDiffStats({ wordCount: 42 }, { wordCount: 42 }); 156 + expect(result.delta).toBe(0); 157 + expect(result.label).toBe('0'); 158 + }); 159 + 160 + it('handles first version (no previous)', () => { 161 + const result = computeDiffStats({ wordCount: 50 }, undefined); 162 + expect(result.delta).toBe(50); 163 + expect(result.label).toBe('+50'); 164 + }); 165 + 166 + it('handles missing wordCount in current', () => { 167 + const result = computeDiffStats({}, { wordCount: 10 }); 168 + expect(result.delta).toBe(-10); 169 + expect(result.label).toBe('-10'); 170 + }); 171 + 172 + it('handles both undefined', () => { 173 + const result = computeDiffStats(undefined, undefined); 174 + expect(result.delta).toBe(0); 175 + expect(result.label).toBe('+0'); 176 + }); 177 + 178 + it('handles previous without wordCount property', () => { 179 + const result = computeDiffStats({ wordCount: 10 }, {}); 180 + expect(result.delta).toBe(10); 181 + expect(result.label).toBe('+10'); 182 + }); 183 + 184 + it('handles zero word count', () => { 185 + const result = computeDiffStats({ wordCount: 0 }, { wordCount: 0 }); 186 + expect(result.delta).toBe(0); 187 + expect(result.label).toBe('0'); 188 + }); 189 + 190 + it('handles transition from zero to non-zero', () => { 191 + const result = computeDiffStats({ wordCount: 5 }, { wordCount: 0 }); 192 + expect(result.delta).toBe(5); 193 + expect(result.label).toBe('+5'); 194 + }); 195 + 196 + it('handles large numbers', () => { 197 + const result = computeDiffStats({ wordCount: 100000 }, { wordCount: 50000 }); 198 + expect(result.delta).toBe(50000); 199 + expect(result.label).toBe('+50000'); 200 + }); 201 + });