file-attachment-resources-architecture.md
1 # File Attachment Resources Architecture 2 3 ## Feature Summary 4 5 Enhance the Dashboard Resources Card to support drag-and-drop file attachments (images, PDFs, documents, text files) with dual storage strategy: Logseq assets (primary) and local fallback when Logseq is not configured. 6 7 --- 8 9 ## Requirements 10 11 ### Core Functionality 12 - **Drag-and-drop** any file type onto the Resources Card 13 - **25MB maximum** per file attachment 14 - **Icons only** display (no thumbnails) - file type icons 15 - **Click to open** launches file in OS default application 16 - **Keep broken references** with error message if file is missing 17 18 ### Storage Strategy 19 1. **Logseq Configured**: Copy file to Logseq's `assets/mnemonic/{workspaceId}/` folder, track in workspace-specific Logseq page 20 2. **Logseq NOT Configured**: Store as local file path reference (fallback) 21 3. **Queue for later sync** when Logseq is temporarily unavailable 22 4. **Migration**: When Logseq becomes configured, migrate existing local attachments 23 24 --- 25 26 ## Architecture Overview 27 28 ``` 29 User drops file --> ResourcesCard.svelte 30 | 31 v 32 AttachmentManager (new) 33 / \ 34 [Logseq ON + [Logseq OFF or 35 FS Access] No FS Access] 36 | | 37 File System Access API Path reference only 38 Write to assets/mnemonic/ (stored in workspace.resources) 39 | 40 v 41 Track in Logseq page 42 (Mnemonic/{Browser}/{Workspace}) 43 | 44 v 45 File stored at: 46 {graphPath}/assets/mnemonic/{workspaceId}/{filename}_{timestamp}_0.ext 47 ``` 48 49 ### File System Access API Flow 50 51 ``` 52 1. User enables Logseq integration in Options 53 2. Extension prompts: "Grant access to your Logseq graph directory" 54 3. User clicks "Grant" → showDirectoryPicker() opens 55 4. User selects Logseq graph root folder 56 5. FileSystemDirectoryHandle stored in IndexedDB 57 6. Permission persists until extension reinstall 58 7. Extension can now write to {graphRoot}/assets/mnemonic/ 59 ``` 60 61 --- 62 63 ## Data Structures 64 65 ### Extended ResourceType 66 67 ```typescript 68 // src/lib/types/resource.ts 69 export type ResourceType = 'logseq' | 'url' | 'attachment'; 70 ``` 71 72 ### AttachmentMetadata Interface 73 74 ```typescript 75 export interface AttachmentMetadata { 76 /** Original filename */ 77 originalName: string; 78 79 /** File size in bytes */ 80 size: number; 81 82 /** MIME type */ 83 mimeType: string; 84 85 /** File extension (lowercase, no dot) */ 86 extension: string; 87 88 /** When the file was attached */ 89 attachedAt: string; 90 91 /** Storage location type */ 92 storageType: 'logseq' | 'path-only'; 93 94 /** For Logseq: relative path from graph root */ 95 logseqAssetPath?: string; 96 97 /** For path-only fallback: original file path from drop */ 98 originalPath?: string; 99 100 /** Whether file is pending sync to Logseq */ 101 pendingSync?: boolean; 102 103 /** Error message if file is broken/missing */ 104 error?: string; 105 } 106 ``` 107 108 ### Extended WorkspaceResource 109 110 ```typescript 111 export interface WorkspaceResource { 112 id: string; 113 type: ResourceType; 114 title: string; 115 value: string; // For attachments: asset path or original path 116 createdAt: string; 117 favIconUrl?: string; 118 119 /** Attachment-specific metadata (only for type='attachment') */ 120 attachment?: AttachmentMetadata; 121 } 122 ``` 123 124 ### Pending Attachment Queue 125 126 ```typescript 127 export interface PendingAttachment { 128 id: string; 129 workspaceId: string; 130 fileName: string; 131 mimeType: string; 132 size: number; 133 base64Data: string; 134 createdAt: string; 135 retryCount: number; 136 lastError?: string; 137 } 138 ``` 139 140 --- 141 142 ## Logseq Asset Storage 143 144 ### File Path Convention 145 Following Logseq's native pattern with Mnemonic namespace: 146 147 ``` 148 {graphPath}/assets/mnemonic/{workspaceId}/{filename}_{timestamp}_0.{ext} 149 ``` 150 151 Example: 152 ``` 153 ~/Logseq/MyGraph/assets/mnemonic/ws-abc123/report_1735689600000_0.pdf 154 ``` 155 156 ### Workspace Page Structure 157 158 Attachments tracked in the workspace-specific Logseq page: 159 160 ```markdown 161 # Mnemonic/Chromium/My Workspace 162 type:: workspace-standalone 163 workspace-id:: ws-abc123 164 165 ## Notes 166 - Note content here... 167 168 ## Attachments 169 - [report.pdf](../assets/mnemonic/ws-abc123/report_1735689600000_0.pdf) 170 - [screenshot.png](../assets/mnemonic/ws-abc123/screenshot_1735689700000_0.png) 171 ``` 172 173 --- 174 175 ## Storage Flow 176 177 ### When Logseq IS Configured 178 179 ``` 180 1. User drops file 181 2. Validate size (< 25MB) 182 3. Read file as base64 183 4. Call REST Bridge: POST /files/upload 184 - REST Bridge writes to {graphPath}/assets/mnemonic/{workspaceId}/ 185 5. Create WorkspaceResource with storageType: 'logseq' 186 6. Add attachment reference to Logseq workspace page 187 7. Save resource to workspace.resources 188 ``` 189 190 ### When Logseq is NOT Configured 191 192 ``` 193 1. User drops file 194 2. Validate size (< 25MB) 195 3. Extract original file path from DataTransfer (if available) 196 4. Create WorkspaceResource with storageType: 'path-only' 197 5. Save resource to workspace.resources 198 6. Note: File NOT copied - just path reference stored 199 ``` 200 201 ### When Logseq Temporarily Unavailable 202 203 ``` 204 1. User drops file 205 2. Validate size (< 25MB) 206 3. Read file as base64 207 4. Store in pending queue (browser.storage.local) 208 5. Create WorkspaceResource with pendingSync: true 209 6. When Logseq reconnects: 210 - Process pending queue 211 - Upload files 212 - Update resource metadata 213 - Clean up queue 214 ``` 215 216 --- 217 218 ## Migration: Local to Logseq 219 220 When Logseq becomes configured after attachments were added: 221 222 ``` 223 1. Find all attachments with storageType: 'path-only' 224 2. For each attachment: 225 a. Check if original file still exists (via REST Bridge) 226 b. If exists: read file, upload to Logseq assets 227 c. Update resource: storageType → 'logseq', add logseqAssetPath 228 d. Add reference to Logseq workspace page 229 3. Log migration results 230 ``` 231 232 --- 233 234 ## File System Access API 235 236 ### Browser Support 237 - **Chromium (Chrome, Edge, Brave)**: Full support 238 - **Firefox**: Limited - `showDirectoryPicker()` not supported; fallback to path references 239 240 ### Permission Flow UI 241 242 Add to Options page (Logseq section): 243 244 ``` 245 ┌─────────────────────────────────────────────────────────────────┐ 246 │ Logseq Graph Access │ 247 │ │ 248 │ To save attachments to your Logseq graph, write access is │ 249 │ required. │ 250 │ │ 251 │ ┌──────────┬─────────────────────┬─────────────┐ │ 252 │ │ Graph │ Directory │ Permission │ │ 253 │ ├──────────┼─────────────────────┼─────────────┤ │ 254 │ │ Notes │ C:\Logseq\Notes │ [Grant] │ │ 255 │ └──────────┴─────────────────────┴─────────────┘ │ 256 │ │ 257 │ Status: ✓ Write access granted │ 258 └─────────────────────────────────────────────────────────────────┘ 259 ``` 260 261 ### FileSystemService Implementation 262 263 ```typescript 264 // src/lib/filesystem/filesystem-service.ts 265 266 class FileSystemService { 267 private directoryHandle: FileSystemDirectoryHandle | null = null; 268 269 /** 270 * Request access to Logseq graph directory 271 */ 272 async requestAccess(): Promise<boolean> { 273 try { 274 this.directoryHandle = await window.showDirectoryPicker({ 275 mode: 'readwrite', 276 startIn: 'documents', 277 }); 278 await this.persistHandle(); 279 return true; 280 } catch (e) { 281 return false; // User cancelled or API not supported 282 } 283 } 284 285 /** 286 * Store handle in IndexedDB (can't use localStorage) 287 */ 288 private async persistHandle(): Promise<void> { 289 const db = await this.openDB(); 290 await db.put('handles', this.directoryHandle, 'logseq-graph'); 291 } 292 293 /** 294 * Restore handle from IndexedDB 295 */ 296 async restoreAccess(): Promise<boolean> { 297 const db = await this.openDB(); 298 this.directoryHandle = await db.get('handles', 'logseq-graph'); 299 if (!this.directoryHandle) return false; 300 301 // Verify permission still valid 302 const permission = await this.directoryHandle.queryPermission({ mode: 'readwrite' }); 303 return permission === 'granted'; 304 } 305 306 /** 307 * Write file to assets/mnemonic/{workspaceId}/ 308 */ 309 async writeAsset( 310 workspaceId: string, 311 fileName: string, 312 data: ArrayBuffer 313 ): Promise<string> { 314 if (!this.directoryHandle) throw new Error('No directory access'); 315 316 // Navigate to assets/mnemonic/{workspaceId}/ 317 const assetsDir = await this.directoryHandle.getDirectoryHandle('assets', { create: true }); 318 const mnemonicDir = await assetsDir.getDirectoryHandle('mnemonic', { create: true }); 319 const workspaceDir = await mnemonicDir.getDirectoryHandle(workspaceId, { create: true }); 320 321 // Create file with timestamp (Logseq convention) 322 const timestamp = Date.now(); 323 const ext = fileName.split('.').pop() || ''; 324 const baseName = fileName.replace(/\.[^.]+$/, ''); 325 const finalName = `${baseName}_${timestamp}_0.${ext}`; 326 327 const fileHandle = await workspaceDir.getFileHandle(finalName, { create: true }); 328 const writable = await fileHandle.createWritable(); 329 await writable.write(data); 330 await writable.close(); 331 332 return `assets/mnemonic/${workspaceId}/${finalName}`; 333 } 334 335 /** 336 * Check if file exists 337 */ 338 async fileExists(relativePath: string): Promise<boolean> { 339 if (!this.directoryHandle) return false; 340 try { 341 const parts = relativePath.split('/'); 342 let current: FileSystemDirectoryHandle = this.directoryHandle; 343 for (let i = 0; i < parts.length - 1; i++) { 344 current = await current.getDirectoryHandle(parts[i]); 345 } 346 await current.getFileHandle(parts[parts.length - 1]); 347 return true; 348 } catch { 349 return false; 350 } 351 } 352 353 /** 354 * Get file URL for opening 355 */ 356 async getFileUrl(relativePath: string): Promise<string | null> { 357 if (!this.directoryHandle) return null; 358 try { 359 const parts = relativePath.split('/'); 360 let current: FileSystemDirectoryHandle = this.directoryHandle; 361 for (let i = 0; i < parts.length - 1; i++) { 362 current = await current.getDirectoryHandle(parts[i]); 363 } 364 const fileHandle = await current.getFileHandle(parts[parts.length - 1]); 365 const file = await fileHandle.getFile(); 366 return URL.createObjectURL(file); 367 } catch { 368 return null; 369 } 370 } 371 } 372 ``` 373 374 ### Opening Files in OS 375 376 Since we can't launch files directly in the OS default app from a browser extension, we have two options: 377 378 1. **Download trigger**: Create a temporary download link and click it 379 2. **Blob URL**: Open file in new tab using `URL.createObjectURL()` 380 381 ```typescript 382 async openAttachment(resource: WorkspaceResource): Promise<void> { 383 const url = await this.fileSystemService.getFileUrl(resource.attachment!.logseqAssetPath!); 384 if (url) { 385 // Option 1: Open in new tab 386 window.open(url, '_blank'); 387 388 // Option 2: Trigger download (opens in default app) 389 // const a = document.createElement('a'); 390 // a.href = url; 391 // a.download = resource.attachment!.originalName; 392 // a.click(); 393 } 394 } 395 ``` 396 397 --- 398 399 ## File Icons 400 401 Map file extensions to icons (no thumbnails): 402 403 | Extension | Icon | 404 |-----------|------| 405 | pdf | Document PDF | 406 | doc, docx | Document Word | 407 | xls, xlsx | Document Excel | 408 | ppt, pptx | Document PowerPoint | 409 | png, jpg, jpeg, gif, webp | Image | 410 | txt, md | Document Text | 411 | zip, rar, 7z | Archive | 412 | mp3, wav, ogg | Audio | 413 | mp4, webm, mov | Video | 414 | * (default) | Document Generic | 415 416 --- 417 418 ## Error Handling 419 420 | Scenario | Handling | 421 |----------|----------| 422 | File > 25MB | Reject with user alert | 423 | Logseq disconnected | Queue for later sync | 424 | REST Bridge unavailable | Store path reference only | 425 | File missing on open | Show error icon + message, keep reference | 426 | Upload timeout | Retry up to 3 times, then queue | 427 | Logseq assets folder not writable | Alert user, fall back to path-only | 428 429 --- 430 431 ## TDD Test Cases 432 433 ### Unit Tests: File Validation 434 ``` 435 - Rejects files over 25MB 436 - Accepts files at exactly 25MB 437 - Extracts correct file extension 438 - Handles files without extension 439 - Sanitizes filenames (removes special chars) 440 ``` 441 442 ### Unit Tests: Storage Strategy 443 ``` 444 - Uses Logseq storage when connected 445 - Falls back to path-only when disconnected 446 - Queues for sync on temporary failure 447 - Generates correct Logseq asset paths 448 ``` 449 450 ### Unit Tests: Queue Management 451 ``` 452 - Adds to queue on failure 453 - Processes queue when reconnected 454 - Retries failed items up to limit 455 - Removes successful items from queue 456 ``` 457 458 ### Unit Tests: Migration 459 ``` 460 - Migrates path-only to Logseq on connect 461 - Handles missing files gracefully 462 - Updates resource metadata correctly 463 ``` 464 465 ### Integration Tests 466 ``` 467 - Full drop → store → retrieve cycle 468 - Open file in OS application 469 - Broken reference detection 470 - Queue processing on reconnect 471 ``` 472 473 --- 474 475 ## Files to Create 476 477 | Path | Purpose | 478 |------|---------| 479 | `src/lib/filesystem/filesystem-service.ts` | File System Access API wrapper | 480 | `src/lib/filesystem/types.ts` | Filesystem types and IndexedDB helpers | 481 | `src/lib/filesystem/index.ts` | Module exports | 482 | `src/lib/attachments/types.ts` | Attachment type definitions | 483 | `src/lib/attachments/attachment-manager.ts` | Core attachment logic | 484 | `src/lib/attachments/file-icons.ts` | Extension → icon mapping | 485 | `src/lib/attachments/index.ts` | Module exports | 486 | `src/components/resources/AttachmentItem.svelte` | Attachment display component | 487 | `tests/unit/attachments/*.test.ts` | Unit tests | 488 | `tests/unit/filesystem/*.test.ts` | Filesystem service tests | 489 490 --- 491 492 ## Files to Modify 493 494 | Path | Changes | 495 |------|---------| 496 | `src/lib/types/resource.ts` | Add `'attachment'` type, `AttachmentMetadata` | 497 | `src/components/resources/ResourcesCard.svelte` | Extend drop handler for files | 498 | `src/components/resources/ResourceItem.svelte` | Handle attachment type | 499 | `src/lib/logseq/note-manager.ts` | Add attachment tracking methods | 500 | `src/entrypoints/options/App.svelte` | Add "Grant Graph Access" UI | 501 502 --- 503 504 ## Implementation Order 505 506 1. **Phase 1**: Core types and interfaces 507 2. **Phase 2**: FileSystemService with TDD (File System Access API) 508 3. **Phase 3**: Options page "Grant Access" UI 509 4. **Phase 4**: AttachmentManager with TDD 510 5. **Phase 5**: UI components (drag-drop, display) 511 6. **Phase 6**: Logseq integration (page tracking) 512 7. **Phase 7**: Queue and migration logic 513 8. **Phase 8**: Error handling, Firefox fallback, edge cases 514 515 --- 516 517 ## Security Considerations 518 519 1. **Path Sanitization**: Remove `..`, `/`, `\` from filenames 520 2. **Size Enforcement**: Validate 25MB limit before reading file 521 3. **MIME Validation**: Verify MIME type matches extension 522 4. **No Arbitrary Paths**: Only allow writing to Logseq assets folder