personal memory agent
0
fork

Configure Feed

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

Add import UX frontend: source card grid, guided flows, and enhanced history

Replace the import app's generic upload form with a source-aware experience:
- Source card grid as landing page with all 9 import sources
- Per-source guided flow with export instructions (via marked.js) and
source-appropriate upload UI
- Enhanced progress display with accumulating stats and human-readable stages
- Quick Import card preserving the existing drag-and-drop + paste flow
- Import history table with source type column, stats column, and filtering
- Backend: pass --source/--force flags through /api/start, add emoji to
SOURCE_METADATA, expose enriched fields from imported.json via build_import_info

+1075 -157
+16 -1
apps/import/routes.py
··· 35 35 { 36 36 "name": "ics", 37 37 "display_name": "Calendar", 38 + "emoji": "📅", 38 39 "icon": "calendar", 39 40 "description": "Import events from Google Calendar, Apple Calendar, or Outlook", 40 41 "input_type": "file", ··· 45 46 { 46 47 "name": "chatgpt", 47 48 "display_name": "ChatGPT", 49 + "emoji": "💬", 48 50 "icon": "message-square", 49 51 "description": "Import your conversation history from ChatGPT", 50 52 "input_type": "file", ··· 55 57 { 56 58 "name": "claude", 57 59 "display_name": "Claude", 60 + "emoji": "🤖", 58 61 "icon": "message-circle", 59 62 "description": "Import your conversation history from Claude", 60 63 "input_type": "file", ··· 65 68 { 66 69 "name": "gemini", 67 70 "display_name": "Gemini", 71 + "emoji": "✨", 68 72 "icon": "sparkles", 69 73 "description": "Import your activity history from Google Gemini", 70 74 "input_type": "file", ··· 75 79 { 76 80 "name": "obsidian", 77 81 "display_name": "Notes", 82 + "emoji": "📝", 78 83 "icon": "file-text", 79 84 "description": "Import notes from Obsidian, Logseq, or any markdown vault", 80 85 "input_type": "directory", ··· 85 90 { 86 91 "name": "kindle", 87 92 "display_name": "Kindle", 93 + "emoji": "📚", 88 94 "icon": "book-open", 89 95 "description": "Import highlights and clippings from your Kindle", 90 96 "input_type": "file", ··· 95 101 { 96 102 "name": "recording", 97 103 "display_name": "Meeting Recording", 104 + "emoji": "🎙️", 98 105 "icon": "mic", 99 106 "description": "Import audio recordings of meetings or conversations", 100 107 "input_type": "file", ··· 105 112 { 106 113 "name": "document", 107 114 "display_name": "Document", 115 + "emoji": "📄", 108 116 "icon": "file", 109 117 "description": "Import a document, PDF, or text file", 110 118 "input_type": "file", ··· 115 123 { 116 124 "name": "quick", 117 125 "display_name": "Quick Import", 126 + "emoji": "⚡", 118 127 "icon": "zap", 119 128 "description": "Paste text or drop any file for quick import", 120 129 "input_type": "text", ··· 390 399 data = request.get_json(force=True) 391 400 path = data.get("path") 392 401 ts = data.get("timestamp") 402 + source = data.get("source") 403 + force = data.get("force", False) 393 404 if not path or not ts: 394 405 return jsonify({"error": "missing params"}), 400 395 406 ··· 465 476 cmd.extend(["--facet", facet]) 466 477 if setting: 467 478 cmd.extend(["--setting", setting]) 479 + if source: 480 + cmd.extend(["--source", source]) 481 + if force: 482 + cmd.append("--force") 468 483 469 484 # Store task_id in metadata 470 485 try: 471 486 update_import_metadata_fields( 472 487 journal_root=journal_root, 473 488 timestamp=ts, 474 - updates={"task_id": task_id}, 489 + updates={"task_id": task_id, "source": source}, 475 490 ) 476 491 except Exception as e: 477 492 return jsonify({"error": f"Failed to update metadata: {str(e)}"}), 500
+1054 -156
apps/import/workspace.html
··· 4 4 5 5 {% if view == 'list' %} 6 6 <style> 7 - .import-inputs { display: flex; gap: 1em; margin-bottom: 1em; } 8 - .import-section { flex: 1; display: flex; flex-direction: column; } 9 - #dropArea { border:2px dashed #999; padding:2em; text-align:center; cursor:pointer; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; } 10 - #dropArea.dragover { background:#eef; border-color:#333; } 11 - #dropArea p { margin: 0 0 0.5em 0; } 12 - #fileLabel { margin-top: 0.5em; font-weight: bold; color: #333; } 13 - #pasteText { width:100%; flex: 1; min-height: 200px; padding: 0.5em; border: 2px solid #999; border-radius: 4px; font-family: monospace; font-size: 0.9em; resize: vertical; } 14 - #validateBtn { width: 100%; padding: 10px; font-size: 1em; font-weight: bold; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; margin-bottom: 1em; } 15 - #validateBtn:hover { background: #0056b3; } 16 - #validateBtn:disabled { background:#ccc; color:#666; cursor:not-allowed; opacity:0.6; } 7 + .import-source-grid { 8 + display: grid; 9 + grid-template-columns: repeat(3, minmax(0, 1fr)); 10 + gap: 1rem; 11 + margin-bottom: 2rem; 12 + } 13 + 14 + .import-source-card { 15 + border: 1px solid #d9dfeb; 16 + border-radius: 14px; 17 + padding: 1.25rem; 18 + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); 19 + cursor: pointer; 20 + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; 21 + min-height: 180px; 22 + display: flex; 23 + flex-direction: column; 24 + gap: 0.65rem; 25 + } 26 + 27 + .import-source-card:hover { 28 + transform: translateY(-2px); 29 + border-color: #7ea6d8; 30 + box-shadow: 0 10px 28px rgba(30, 64, 175, 0.08); 31 + } 32 + 33 + .import-source-card-emoji { 34 + font-size: 2.1em; 35 + line-height: 1; 36 + } 37 + 38 + .import-source-card-name { 39 + font-weight: 700; 40 + font-size: 1.05rem; 41 + } 42 + 43 + .import-source-card-desc { 44 + color: #5b6473; 45 + font-size: 0.92rem; 46 + line-height: 1.45; 47 + } 48 + 49 + .import-guided-flow { 50 + margin-bottom: 2rem; 51 + border: 1px solid #d9dfeb; 52 + border-radius: 16px; 53 + padding: 1.25rem; 54 + background: #ffffff; 55 + } 56 + 57 + .import-back-link { 58 + display: inline-block; 59 + margin-bottom: 1rem; 60 + color: #0f4c81; 61 + text-decoration: none; 62 + font-weight: 600; 63 + } 64 + 65 + .import-back-link:hover { 66 + text-decoration: underline; 67 + } 68 + 69 + .import-step { 70 + border-top: 1px solid #edf1f7; 71 + padding-top: 1rem; 72 + margin-top: 1rem; 73 + } 74 + 75 + .import-step:first-child { 76 + border-top: none; 77 + margin-top: 0; 78 + padding-top: 0; 79 + } 80 + 81 + .import-step-number { 82 + display: inline-flex; 83 + align-items: center; 84 + justify-content: center; 85 + min-width: 70px; 86 + padding: 0.2rem 0.55rem; 87 + border-radius: 999px; 88 + background: #e8f1fb; 89 + color: #0f4c81; 90 + font-size: 0.78rem; 91 + font-weight: 700; 92 + text-transform: uppercase; 93 + letter-spacing: 0.04em; 94 + margin-bottom: 0.5rem; 95 + } 96 + 97 + .import-guide-title { 98 + margin: 0 0 0.25rem 0; 99 + font-size: 1.35rem; 100 + } 101 + 102 + .import-guide-subtitle { 103 + margin: 0 0 1rem 0; 104 + color: #5b6473; 105 + } 106 + 107 + .import-guide-content { 108 + line-height: 1.6; 109 + } 110 + 111 + .import-guide-content h1, 112 + .import-guide-content h2, 113 + .import-guide-content h3 { 114 + margin-top: 1em; 115 + margin-bottom: 0.5em; 116 + } 117 + 118 + .import-guide-content h1 { font-size: 1.3em; } 119 + .import-guide-content h2 { font-size: 1.15em; } 120 + .import-guide-content h3 { font-size: 1.05em; } 121 + 122 + .import-guide-content ul, 123 + .import-guide-content ol { 124 + padding-left: 1.5em; 125 + } 126 + 127 + .import-guide-content pre { 128 + background: #f4f4f4; 129 + padding: 0.75em; 130 + border-radius: 4px; 131 + overflow-x: auto; 132 + } 133 + 134 + .import-guide-content code { 135 + background: #f4f4f4; 136 + padding: 0.1em 0.3em; 137 + border-radius: 3px; 138 + font-size: 0.9em; 139 + } 140 + 141 + .import-guide-content pre code { 142 + background: transparent; 143 + padding: 0; 144 + } 145 + 146 + .import-upload-area { 147 + border: 2px dashed #99abc7; 148 + border-radius: 12px; 149 + padding: 1.5rem; 150 + text-align: center; 151 + background: #f8fbff; 152 + cursor: pointer; 153 + transition: border-color 0.18s ease, background 0.18s ease; 154 + } 155 + 156 + .import-upload-area.dragover { 157 + border-color: #0f4c81; 158 + background: #edf5ff; 159 + } 160 + 161 + .import-upload-area p { 162 + margin: 0 0 0.5rem 0; 163 + } 164 + 165 + .import-upload-help { 166 + color: #5b6473; 167 + font-size: 0.9rem; 168 + } 169 + 170 + .import-file-label { 171 + margin-top: 0.65rem; 172 + font-weight: 700; 173 + color: #2f3a4c; 174 + } 175 + 176 + .import-paste-text { 177 + width: 100%; 178 + min-height: 220px; 179 + padding: 0.75rem; 180 + border: 1px solid #cfd7e3; 181 + border-radius: 10px; 182 + font-family: monospace; 183 + font-size: 0.9rem; 184 + resize: vertical; 185 + box-sizing: border-box; 186 + } 187 + 188 + .import-inline-fields { 189 + display: flex; 190 + gap: 1rem; 191 + flex-wrap: wrap; 192 + margin-top: 1rem; 193 + } 194 + 195 + .import-inline-fields .confirm-field { 196 + flex: 1 1 220px; 197 + } 198 + 199 + .import-action-row { 200 + margin-top: 1rem; 201 + display: flex; 202 + gap: 0.75rem; 203 + flex-wrap: wrap; 204 + align-items: center; 205 + } 206 + 207 + .import-primary-btn { 208 + padding: 0.7rem 1rem; 209 + font-size: 0.95rem; 210 + font-weight: 700; 211 + background: #0f4c81; 212 + color: white; 213 + border: none; 214 + border-radius: 10px; 215 + cursor: pointer; 216 + } 217 + 218 + .import-primary-btn:hover { 219 + background: #0a3b65; 220 + } 221 + 222 + .import-primary-btn:disabled { 223 + background: #c4ccd7; 224 + color: #5b6473; 225 + cursor: not-allowed; 226 + } 227 + 228 + .import-secondary-btn { 229 + display: inline-flex; 230 + align-items: center; 231 + justify-content: center; 232 + padding: 0.7rem 1rem; 233 + font-size: 0.95rem; 234 + font-weight: 600; 235 + background: white; 236 + color: #2f3a4c; 237 + border: 1px solid #cfd7e3; 238 + border-radius: 10px; 239 + cursor: pointer; 240 + text-decoration: none; 241 + } 242 + 243 + .import-secondary-btn:hover { 244 + background: #f7f9fc; 245 + } 246 + 247 + .import-progress-panel { 248 + border: 1px solid #d9dfeb; 249 + border-radius: 12px; 250 + padding: 1rem; 251 + background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%); 252 + } 253 + 254 + .import-progress-title { 255 + margin: 0; 256 + font-size: 1.2rem; 257 + } 258 + 259 + .import-progress-message { 260 + margin: 0.35rem 0 0 0; 261 + color: #5b6473; 262 + } 263 + 264 + .import-progress-stats { 265 + display: flex; 266 + gap: 0.75rem; 267 + flex-wrap: wrap; 268 + margin-top: 1rem; 269 + } 270 + 271 + .import-progress-stat { 272 + padding: 0.55rem 0.7rem; 273 + border-radius: 10px; 274 + background: #eef4fb; 275 + color: #2f3a4c; 276 + font-size: 0.9rem; 277 + } 278 + 279 + .import-completion-summary { 280 + margin-top: 1rem; 281 + padding: 1rem; 282 + border-radius: 12px; 283 + background: #edf8ef; 284 + border: 1px solid #bfdcc4; 285 + } 286 + 287 + .import-completion-summary.failed { 288 + background: #fff0f0; 289 + border-color: #f0c2c2; 290 + } 291 + 292 + .import-history-header { 293 + display: flex; 294 + justify-content: space-between; 295 + gap: 1rem; 296 + flex-wrap: wrap; 297 + align-items: center; 298 + margin-bottom: 1rem; 299 + } 300 + 301 + .import-history-summary { 302 + color: #4b5565; 303 + } 304 + 305 + .import-history-filters { 306 + display: flex; 307 + gap: 0.75rem; 308 + align-items: center; 309 + flex-wrap: wrap; 310 + } 311 + 312 + .import-history-filters select { 313 + padding: 0.45rem 0.65rem; 314 + border-radius: 8px; 315 + border: 1px solid #cfd7e3; 316 + background: white; 317 + } 318 + 17 319 .spinner { display:inline-block; width:12px; height:12px; border:2px solid #ccc; border-top-color:#fff; border-radius:50%; animation:spin 1s linear infinite; margin-left:6px; } 18 320 .modal { display:none; position:fixed; z-index:1000; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.5); } 19 321 .modal-content { background:white; margin:5% auto; padding:1em; border-radius:8px; max-width:800px; max-height:80vh; overflow:auto; position:relative; } ··· 25 327 .confirm-field select { padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; background: white; } 26 328 .modal-actions { margin-top: 1em; display: flex; justify-content: flex-end; } 27 329 28 - /* Import list styles */ 29 330 .import-list { margin-top: 2em; } 30 - .import-list h2 { margin-bottom: 1em; } 331 + .import-list h2 { margin: 0; } 31 332 .import-table { width: 100%; border-collapse: collapse; } 32 333 .import-table th { background: #f5f5f5; text-align: left; padding: 8px; border-bottom: 2px solid #ddd; font-size: 0.9em; } 33 - .import-table td { padding: 8px; border-bottom: 1px solid #eee; } 334 + .import-table td { padding: 8px; border-bottom: 1px solid #eee; vertical-align: top; } 34 335 .import-table tr:hover { background: #f9f9f9; cursor: pointer; } 35 336 .import-status { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 0.85em; } 36 337 .import-status.success { background: #d4edda; color: #155724; } ··· 44 345 .timestamp-link { color: #007bff; text-decoration: none; } 45 346 .timestamp-link:hover { text-decoration: underline; } 46 347 .no-imports { color: #666; font-style: italic; padding: 2em; text-align: center; } 348 + .import-source-inline { 349 + display: flex; 350 + align-items: center; 351 + gap: 0.45rem; 352 + } 353 + 354 + .import-source-inline-emoji { 355 + font-size: 1.1rem; 356 + } 357 + 358 + .import-stats-cell { 359 + color: #4b5565; 360 + font-size: 0.9rem; 361 + } 362 + 363 + @media (max-width: 980px) { 364 + .import-source-grid { 365 + grid-template-columns: repeat(2, minmax(0, 1fr)); 366 + } 367 + } 368 + 369 + @media (max-width: 720px) { 370 + .import-source-grid { 371 + grid-template-columns: 1fr; 372 + } 373 + 374 + .import-guided-flow, 375 + .modal-content { 376 + padding: 1rem; 377 + } 378 + } 47 379 48 380 @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } 49 381 @keyframes spin { to { transform:rotate(360deg); } } 50 382 </style> 51 383 <div class="workspace-content-wide"> 52 - <form id="importForm"> 53 - <div class="import-inputs"> 54 - <div class="import-section"> 55 - <div id="dropArea"> 56 - <p>Drag file here or click to select</p> 57 - <input id="fileInput" type="file" style="display:none" /> 58 - <div id="fileLabel"></div> 59 - </div> 60 - </div> 61 - <div class="import-section"> 62 - <textarea id="pasteText" placeholder="Paste text here..."></textarea> 63 - </div> 384 + <div id="importGrid" class="import-source-grid"></div> 385 + 386 + <div id="importGuide" class="import-guided-flow" style="display:none"> 387 + <a href="#" class="import-back-link" onclick="showGrid(); return false;">← Back to sources</a> 388 + <div id="guideSteps"></div> 389 + </div> 390 + 391 + <div class="import-list"> 392 + <h2>Import History</h2> 393 + <div id="importListContent"> 394 + <div class="no-imports">Loading imports...</div> 64 395 </div> 65 - <button type="submit" id="validateBtn">Validate</button> 66 - </form> 67 - 68 - <div class="import-list"> 69 - <h2>Import History</h2> 70 - <div id="importListContent"> 71 - <div class="no-imports">Loading imports...</div> 72 396 </div> 73 - </div> 74 397 </div> 75 398 76 399 <div id="detectModal" class="modal"> ··· 99 422 </div> 100 423 </div> 101 424 425 + <script src="{{ vendor_lib('marked') }}"></script> 102 426 <script> 103 - const dropArea = document.getElementById('dropArea'); 104 - const fileInput = document.getElementById('fileInput'); 105 - const fileLabel = document.getElementById('fileLabel'); 106 - const pasteText = document.getElementById('pasteText'); 107 427 const facetSelect = document.getElementById('facetSelect'); 108 428 const settingInput = document.getElementById('settingInput'); 109 429 const startBtn = document.getElementById('startBtn'); 430 + 431 + const STAGE_NAMES = { 432 + initialization: 'Preparing...', 433 + importing: 'Reading your data...', 434 + detection: 'Detecting content type...', 435 + audio_segmenting: 'Splitting audio...', 436 + transcription: 'Transcribing audio...', 437 + processing: 'Processing entries...', 438 + writing: 'Writing to journal...', 439 + indexing: 'Building search index...', 440 + cleanup: 'Finishing up...', 441 + segmenting: 'Preparing segments...', 442 + transcribing: 'Transcribing audio...', 443 + }; 444 + 445 + let cachedSources = []; 446 + let sourceMetadataByName = {}; 447 + let sourceEmojiByName = {}; 110 448 let currentFile = null; 449 + let currentGuideSource = null; 450 + let currentGuidedFile = null; 451 + let currentGuidedSaved = null; 452 + let importEvents = {}; 453 + let importsCache = []; 454 + let currentSourceFilter = ''; 111 455 112 - dropArea.onclick = () => fileInput.click(); 113 - ['dragenter','dragover'].forEach(ev=>dropArea.addEventListener(ev,e=>{e.preventDefault();dropArea.classList.add('dragover');})); 114 - ['dragleave','drop'].forEach(ev=>dropArea.addEventListener(ev,e=>{e.preventDefault();dropArea.classList.remove('dragover');if(ev==='drop'){}})); 115 - dropArea.addEventListener('drop',e=>{const f=e.dataTransfer.files[0];if(f){currentFile=f;fileLabel.textContent=f.name;}}); 116 - fileInput.onchange=e=>{currentFile=e.target.files[0];if(currentFile)fileLabel.textContent=currentFile.name;}; 456 + function escapeHtml(str) { 457 + if (str === null || str === undefined) { 458 + return ''; 459 + } 460 + return String(str) 461 + .replace(/&/g, '&amp;') 462 + .replace(/</g, '&lt;') 463 + .replace(/>/g, '&gt;') 464 + .replace(/"/g, '&quot;'); 465 + } 117 466 118 - document.getElementById('importForm').onsubmit=e=>{ 119 - e.preventDefault(); 120 - const validateBtn = document.getElementById('validateBtn'); 467 + function navigateTo(view, options = {}) { 468 + const { updateHash = true } = options; 469 + const grid = document.getElementById('importGrid'); 470 + const guide = document.getElementById('importGuide'); 471 + const nextHash = view === 'grid' ? '' : view; 121 472 122 - // Check that either file or text is provided 123 - const hasFile = currentFile != null; 124 - const hasText = pasteText.value.trim().length > 0; 473 + if (grid) { 474 + grid.style.display = view === 'grid' ? '' : 'none'; 475 + } 476 + if (guide) { 477 + guide.style.display = view === 'grid' ? 'none' : ''; 478 + } 125 479 126 - if (!hasFile && !hasText) { 127 - if(window.showError) showError('Please provide a file or paste text'); 128 - return; 480 + if (view.startsWith('guide/')) { 481 + const source = view.split('/')[1]; 482 + loadGuidedFlow(source); 483 + } else if (view.startsWith('progress/')) { 484 + const importId = view.split('/')[1]; 485 + showProgressView(importId); 486 + } else { 487 + currentGuideSource = null; 129 488 } 130 489 131 - // Disable button and show validating state 132 - validateBtn.disabled = true; 133 - validateBtn.innerHTML = 'Validating<span class="spinner"></span>'; 490 + if (updateHash && window.location.hash.slice(1) !== nextHash) { 491 + window.location.hash = nextHash; 492 + } 493 + } 134 494 135 - const fd=new FormData(); 136 - if(hasFile) { 137 - fd.append('file', currentFile); 495 + function showGrid() { 496 + currentGuideSource = null; 497 + currentGuidedFile = null; 498 + currentGuidedSaved = null; 499 + navigateTo('grid'); 500 + } 501 + 502 + window.addEventListener('hashchange', handleHashChange); 503 + 504 + function handleHashChange() { 505 + const hash = window.location.hash.slice(1); 506 + if (hash.startsWith('guide/')) { 507 + navigateTo(hash, { updateHash: false }); 508 + } else if (hash.startsWith('progress/')) { 509 + navigateTo(hash, { updateHash: false }); 138 510 } else { 139 - fd.append('text', pasteText.value); 511 + navigateTo('grid', { updateHash: false }); 140 512 } 513 + } 141 514 142 - // Add selected facet and setting if available 143 - const facet = facetSelect.value; 144 - if(facet) fd.append('facet', facet); 145 - const settingValue = settingInput.value.trim(); 146 - if(settingValue) fd.append('setting', settingValue); 515 + async function loadSourceGrid() { 516 + if (!cachedSources.length) { 517 + const res = await fetch('/app/import/api/sources'); 518 + cachedSources = await res.json(); 519 + sourceMetadataByName = Object.fromEntries(cachedSources.map(source => [source.name, source])); 520 + sourceEmojiByName = Object.fromEntries(cachedSources.map(source => [source.name, source.emoji || ''])); 521 + } 147 522 148 - fetch('/app/import/api/save', {method:'POST', body:fd}) 149 - .then(async r=>{ 150 - const d=await r.json(); 151 - // Re-enable button 152 - validateBtn.disabled = false; 153 - validateBtn.textContent = 'Validate'; 523 + const grid = document.getElementById('importGrid'); 524 + if (!grid) { 525 + return; 526 + } 154 527 155 - if(!r.ok){ 156 - if(window.showError)showError(d.error||'Validation failed'); 157 - return; 158 - } 159 - showDetect(d); 160 - }) 161 - .catch(err=>{ 162 - // Re-enable button on error 163 - validateBtn.disabled = false; 164 - validateBtn.textContent = 'Validate'; 165 - if(window.showError)showError('Validation failed'); 166 - }); 167 - }; 528 + grid.innerHTML = cachedSources.map(source => { 529 + const clickHandler = source.name === 'quick' 530 + ? 'showQuickImport()' 531 + : `navigateTo(&quot;guide/${source.name}&quot;)`; 532 + return ` 533 + <div class="import-source-card" onclick="${clickHandler}"> 534 + <div class="import-source-card-emoji">${source.emoji || ''}</div> 535 + <div class="import-source-card-name">${escapeHtml(source.display_name)}</div> 536 + <div class="import-source-card-desc">${escapeHtml(source.description || '')}</div> 537 + </div> 538 + `; 539 + }).join(''); 540 + } 168 541 169 - function showDetect(res){ 170 - document.getElementById('timestampInput').value=res.timestamp||''; 171 - document.getElementById('savedPath').value=res.path; 542 + function showQuickImport() { 543 + navigateTo('guide/quick'); 544 + } 172 545 173 - // Default to currently selected facet from app framework if not specified 174 - // res.facet may be null when server converts empty string to None 546 + function showDetect(res) { 547 + document.getElementById('timestampInput').value = res.timestamp || ''; 548 + document.getElementById('savedPath').value = res.path; 175 549 const defaultFacet = res.facet || window.selectedFacet || ''; 176 550 loadFacets(defaultFacet); 177 - 178 551 settingInput.value = res.setting || ''; 179 - document.getElementById('detectModal').style.display='block'; 552 + document.getElementById('detectModal').style.display = 'block'; 180 553 } 181 554 182 555 function formatFileSize(bytes) { ··· 213 586 return stage.charAt(0).toUpperCase() + stage.slice(1); 214 587 } 215 588 216 - function loadImports(){ 217 - fetch('/app/import/api/list').then(r=>r.json()).then(imports=>{ 589 + function humanStageName(stage, sourceDisplay = '') { 590 + const fallback = STAGE_NAMES[stage] || `${capitalizeStage(stage)}...`; 591 + if (stage === 'importing' && sourceDisplay) { 592 + return `Reading ${sourceDisplay}...`; 593 + } 594 + return fallback; 595 + } 596 + 597 + function formatDateRange(dateRange) { 598 + if (!Array.isArray(dateRange) || !dateRange[0]) { 599 + return ''; 600 + } 601 + if (!dateRange[1] || dateRange[0] === dateRange[1]) { 602 + return dateRange[0]; 603 + } 604 + return `${dateRange[0]} - ${dateRange[1]}`; 605 + } 606 + 607 + function formatImportStats(entriesWritten, entitiesSeeded) { 608 + const parts = []; 609 + if (typeof entriesWritten === 'number' && entriesWritten > 0) { 610 + parts.push(`${entriesWritten} entries`); 611 + } 612 + if (typeof entitiesSeeded === 'number' && entitiesSeeded > 0) { 613 + parts.push(`${entitiesSeeded} entities`); 614 + } 615 + return parts.join(' • ') || '-'; 616 + } 617 + 618 + function formatProgressStats(eventData) { 619 + const stats = []; 620 + if (eventData.items_total) { 621 + stats.push(`${eventData.items_processed || 0}/${eventData.items_total} items`); 622 + } 623 + if (eventData.earliest_date) { 624 + stats.push(`from ${eventData.earliest_date}`); 625 + } 626 + if (eventData.latest_date && eventData.latest_date !== eventData.earliest_date) { 627 + stats.push(`to ${eventData.latest_date}`); 628 + } 629 + if (eventData.entities_found) { 630 + stats.push(`${eventData.entities_found} entities found`); 631 + } 632 + return stats; 633 + } 634 + 635 + function renderProgressStats(eventData) { 636 + const stats = formatProgressStats(eventData); 637 + if (!stats.length) { 638 + return ''; 639 + } 640 + return `<div class="import-progress-stats">${stats.map(stat => `<div class="import-progress-stat">${escapeHtml(stat)}</div>`).join('')}</div>`; 641 + } 642 + 643 + function renderSourceDisplay(sourceType, sourceDisplay, fallbackText = '-') { 644 + const emoji = sourceEmojiByName[sourceType] || ''; 645 + const displayText = sourceDisplay || fallbackText || '-'; 646 + return `<div class="import-source-inline">${emoji ? `<span class="import-source-inline-emoji">${emoji}</span>` : ''}<span>${escapeHtml(displayText)}</span></div>`; 647 + } 648 + 649 + function getImportById(importId) { 650 + return importsCache.find(item => item.timestamp === importId) || null; 651 + } 652 + 653 + function buildHistoryHeader(imports) { 654 + const entryTotal = imports.reduce((sum, imp) => sum + (imp.entries_written || 0), 0); 655 + const entityTotal = imports.reduce((sum, imp) => sum + (imp.entities_seeded || 0), 0); 656 + const sourceOptions = [ 657 + '<option value="">All sources</option>', 658 + ...cachedSources.map(source => { 659 + const selected = currentSourceFilter === source.name ? ' selected' : ''; 660 + return `<option value="${escapeHtml(source.name)}"${selected}>${escapeHtml(source.display_name)}</option>`; 661 + }), 662 + ].join(''); 663 + 664 + return ` 665 + <div class="import-history-header"> 666 + <div class="import-history-summary">${imports.length} imports total, ${entryTotal} entries imported, ${entityTotal} entities found</div> 667 + <div class="import-history-filters"> 668 + <label for="importSourceFilter">Source</label> 669 + <select id="importSourceFilter"> 670 + ${sourceOptions} 671 + </select> 672 + </div> 673 + </div> 674 + `; 675 + } 676 + 677 + async function loadImports() { 678 + await loadSourceGrid(); 679 + fetch('/app/import/api/list').then(r => r.json()).then(imports => { 680 + importsCache = imports || []; 218 681 const container = document.getElementById('importListContent'); 219 682 220 683 if (!imports || imports.length === 0) { ··· 222 685 return; 223 686 } 224 687 225 - let html = '<table class="import-table">'; 688 + let html = buildHistoryHeader(imports); 689 + html += '<table class="import-table">'; 226 690 html += '<thead><tr>'; 227 691 html += '<th>Imported At</th>'; 228 692 html += '<th>File</th>'; 693 + html += '<th>Source</th>'; 229 694 html += '<th>Size</th>'; 230 695 html += '<th>Imported To</th>'; 231 696 html += '<th>Status</th>'; 232 697 html += '<th>Files Created</th>'; 698 + html += '<th>Stats</th>'; 233 699 html += '<th>Duration</th>'; 234 700 html += '</tr></thead><tbody>'; 235 701 236 702 imports.forEach(imp => { 237 - // Use imported_at timestamp for display 238 703 const importedAt = imp.imported_at ? new Date(imp.imported_at * 1000).toLocaleString() : new Date(imp.created_at * 1000).toLocaleString(); 239 704 const targetDay = imp.target_day ? imp.target_day.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3') : '-'; 240 705 241 - // Determine status class and text based on new status field 242 - let statusClass, statusText, statusDetail = ''; 706 + let statusClass; 707 + let statusText; 708 + let statusDetail = ''; 243 709 if (imp.status === 'success') { 244 710 statusClass = 'success'; 245 711 statusText = 'Completed'; 246 712 } else if (imp.status === 'failed') { 247 713 statusClass = 'failed'; 248 714 statusText = 'Failed'; 249 - // Show error message if available 250 715 if (imp.error) { 251 716 const errorStage = imp.error_stage ? ` (${capitalizeStage(imp.error_stage)})` : ''; 252 - statusDetail = `<div class="progress-detail">${imp.error}${errorStage}</div>`; 717 + statusDetail = `<div class="progress-detail">${escapeHtml(imp.error)}${escapeHtml(errorStage)}</div>`; 253 718 } 254 719 } else if (imp.status === 'running') { 255 720 statusClass = 'running'; ··· 259 724 statusText = 'Pending'; 260 725 } 261 726 262 - html += `<tr data-import-id="${imp.timestamp}" data-facet="${imp.facet || ''}" onclick="window.location.href='/app/import/${imp.timestamp}'">`; 727 + const sourceCell = imp.source_type 728 + ? renderSourceDisplay(imp.source_type, imp.source_display || sourceMetadataByName[imp.source_type]?.display_name || imp.original_filename || '-') 729 + : renderSourceDisplay('', imp.original_filename || '-', imp.original_filename || '-'); 730 + const statsText = formatImportStats(imp.entries_written, imp.entities_seeded); 731 + 732 + html += `<tr data-import-id="${imp.timestamp}" data-facet="${imp.facet || ''}" data-source-type="${imp.source_type || ''}" onclick="window.location.href='/app/import/${imp.timestamp}'">`; 263 733 html += `<td class="nowrap">${importedAt}</td>`; 264 - html += `<td>${imp.original_filename || 'Unknown'}</td>`; 734 + html += `<td>${escapeHtml(imp.original_filename || 'Unknown')}</td>`; 735 + html += `<td class="source-cell">${sourceCell}</td>`; 265 736 html += `<td class="file-size nowrap">${formatFileSize(imp.file_size || 0)}</td>`; 266 - html += `<td class="nowrap">`; 737 + html += '<td class="nowrap">'; 267 738 if (imp.target_day) { 268 739 html += `<a href="/calendar/${imp.target_day}" class="timestamp-link" onclick="event.stopPropagation();">${targetDay}</a>`; 269 740 } else { 270 741 html += '-'; 271 742 } 272 - html += `</td>`; 743 + html += '</td>'; 273 744 html += `<td class="status-cell"><span class="import-status ${statusClass}">${statusText}</span>${statusDetail}</td>`; 274 745 html += `<td class="files-cell">${imp.total_files_created || 0}</td>`; 746 + html += `<td class="stats-cell import-stats-cell">${statsText}</td>`; 275 747 html += `<td class="duration-cell">${formatDuration(imp.duration_minutes)}</td>`; 276 748 html += '</tr>'; 277 749 }); ··· 279 751 html += '</tbody></table>'; 280 752 container.innerHTML = html; 281 753 282 - // Apply facet filter after rendering 754 + const sourceFilter = document.getElementById('importSourceFilter'); 755 + if (sourceFilter) { 756 + sourceFilter.onchange = event => { 757 + currentSourceFilter = event.target.value; 758 + filterImportsByFacet(window.selectedFacet); 759 + }; 760 + } 761 + 283 762 filterImportsByFacet(window.selectedFacet); 284 - }).catch(err => { 763 + }).catch(() => { 285 764 document.getElementById('importListContent').innerHTML = '<div class="no-imports">Error loading imports</div>'; 286 765 }); 287 766 } 288 767 289 - // Filter imports by facet 290 768 function filterImportsByFacet(facetName) { 291 769 const rows = document.querySelectorAll('.import-table tbody tr'); 292 - const noImportsEl = document.querySelector('.no-imports'); 293 - 294 770 if (rows.length === 0) return; 295 771 296 772 let visibleCount = 0; 297 773 rows.forEach(row => { 298 - if (facetName === null) { 299 - // All-facet mode - show all 774 + const facetMatches = facetName === null || row.dataset.facet === facetName; 775 + const sourceMatches = !currentSourceFilter || row.dataset.sourceType === currentSourceFilter; 776 + if (facetMatches && sourceMatches) { 300 777 row.style.display = ''; 301 778 visibleCount++; 302 779 } else { 303 - // Specific facet mode - show only matching 304 - if (row.dataset.facet === facetName) { 305 - row.style.display = ''; 306 - visibleCount++; 307 - } else { 308 - row.style.display = 'none'; 309 - } 780 + row.style.display = 'none'; 310 781 } 311 782 }); 312 783 313 - // Show/hide no-imports message based on visible rows 314 784 const container = document.getElementById('importListContent'); 315 785 if (visibleCount === 0 && rows.length > 0) { 316 - // Have imports but none match filter 317 786 const facetData = window.facetsData?.find(f => f.name === facetName); 318 - const facetTitle = facetData?.title || facetName; 787 + const facetTitle = facetData?.title || facetName || 'this view'; 319 788 let noMatchEl = container.querySelector('.no-imports-filtered'); 320 789 if (!noMatchEl) { 321 790 noMatchEl = document.createElement('div'); ··· 325 794 noMatchEl.textContent = `No imports for ${facetTitle}`; 326 795 noMatchEl.style.display = ''; 327 796 } else { 328 - // Hide filtered message if visible rows exist 329 797 const noMatchEl = container.querySelector('.no-imports-filtered'); 330 798 if (noMatchEl) { 331 799 noMatchEl.style.display = 'none'; ··· 333 801 } 334 802 } 335 803 336 - // Update import row with live progress from importer events 337 804 function updateImportRow(importId, eventData) { 805 + if (importId) { 806 + importEvents[importId] = { ...(importEvents[importId] || {}), ...eventData }; 807 + } 808 + 338 809 const row = document.querySelector(`tr[data-import-id="${importId}"]`); 339 - if (!row) return; // Row not in current view 810 + if (!row) { 811 + refreshInlineProgress(importId, eventData); 812 + return; 813 + } 340 814 341 815 const statusCell = row.querySelector('.status-cell'); 342 816 const filesCell = row.querySelector('.files-cell'); 817 + const statsCell = row.querySelector('.stats-cell'); 343 818 const durationCell = row.querySelector('.duration-cell'); 819 + const sourceCell = row.querySelector('.source-cell'); 820 + 821 + if (sourceCell && (eventData.source_type || eventData.source_display)) { 822 + const displayText = eventData.source_display || sourceMetadataByName[eventData.source_type]?.display_name || row.children[1]?.textContent || '-'; 823 + sourceCell.innerHTML = renderSourceDisplay(eventData.source_type || '', displayText, displayText); 824 + row.dataset.sourceType = eventData.source_type || ''; 825 + } 344 826 345 827 if (eventData.event === 'started') { 346 - // Mark as running 347 828 if (statusCell) { 348 - statusCell.innerHTML = `<span class="import-status running">${capitalizeStage(eventData.stage)}</span>`; 829 + statusCell.innerHTML = `<span class="import-status running">${escapeHtml(humanStageName(eventData.stage, eventData.source_display))}</span>`; 349 830 } 350 831 } else if (eventData.event === 'status') { 351 - // Update with stage and timing 352 832 if (statusCell) { 353 833 const stageTime = formatElapsed(eventData.stage_elapsed_ms); 354 834 const totalTime = formatElapsed(eventData.elapsed_ms); 355 - statusCell.innerHTML = `<span class="import-status running">${capitalizeStage(eventData.stage)}</span><div class="progress-detail">${stageTime} (${totalTime} total)</div>`; 835 + const progressStats = formatProgressStats(eventData); 836 + const detailParts = [`${stageTime} (${totalTime} total)`]; 837 + if (progressStats.length) { 838 + detailParts.push(progressStats.join(' • ')); 839 + } 840 + statusCell.innerHTML = `<span class="import-status running">${escapeHtml(humanStageName(eventData.stage, eventData.source_display))}</span><div class="progress-detail">${escapeHtml(detailParts.join(' • '))}</div>`; 356 841 } 357 842 } else if (eventData.event === 'completed') { 358 - // Mark as completed 359 843 if (statusCell) { 360 844 statusCell.innerHTML = '<span class="import-status success">Completed</span>'; 361 845 } 362 846 if (filesCell && eventData.total_files_created !== undefined) { 363 847 filesCell.textContent = eventData.total_files_created; 364 848 } 849 + if (statsCell) { 850 + statsCell.textContent = formatImportStats(eventData.entries_written, eventData.entities_seeded); 851 + } 365 852 if (durationCell && eventData.duration_ms) { 366 853 durationCell.textContent = formatElapsed(eventData.duration_ms); 367 854 } 368 - // Reload imports to get full updated data 369 855 setTimeout(() => loadImports(), 1000); 370 856 } else if (eventData.event === 'error') { 371 - // Mark as failed 372 857 if (statusCell) { 373 - statusCell.innerHTML = `<span class="import-status failed">Failed</span><div class="progress-detail">${eventData.error || 'Unknown error'}</div>`; 858 + statusCell.innerHTML = `<span class="import-status failed">Failed</span><div class="progress-detail">${escapeHtml(eventData.error || 'Unknown error')}</div>`; 859 + } 860 + } 861 + 862 + refreshInlineProgress(importId, eventData); 863 + } 864 + 865 + function refreshInlineProgress(importId, eventData = null) { 866 + const hash = window.location.hash.slice(1); 867 + if (!hash.startsWith('progress/')) { 868 + return; 869 + } 870 + const currentImportId = hash.split('/')[1]; 871 + if (currentImportId !== importId) { 872 + return; 873 + } 874 + const merged = eventData ? { ...(importEvents[importId] || {}), ...eventData } : importEvents[importId]; 875 + if (merged) { 876 + renderProgressView(importId, merged); 877 + } 878 + } 879 + 880 + async function loadGuidedFlow(sourceName) { 881 + await loadSourceGrid(); 882 + const source = sourceMetadataByName[sourceName]; 883 + const guideSteps = document.getElementById('guideSteps'); 884 + currentGuideSource = sourceName; 885 + currentGuidedFile = null; 886 + currentGuidedSaved = null; 887 + if (!guideSteps) { 888 + return; 889 + } 890 + 891 + if (!source) { 892 + guideSteps.innerHTML = '<div class="no-imports">Unknown import source</div>'; 893 + return; 894 + } 895 + 896 + if (sourceName === 'quick') { 897 + renderQuickImportFlow(); 898 + return; 899 + } 900 + 901 + let guideHtml = ''; 902 + if (source.has_guide) { 903 + const response = await fetch(`/app/import/api/guide/${sourceName}`); 904 + if (response.ok) { 905 + const markdown = await response.text(); 906 + guideHtml = `<div class="import-step"><div class="import-step-number">Step 1</div><div class="import-guide-content">${marked.parse(markdown, { breaks: true, gfm: true })}</div></div>`; 907 + } 908 + } 909 + 910 + guideSteps.innerHTML = ` 911 + <div> 912 + <h3 class="import-guide-title">${source.emoji || ''} ${escapeHtml(source.display_name)}</h3> 913 + <p class="import-guide-subtitle">${escapeHtml(source.description || '')}</p> 914 + </div> 915 + ${guideHtml} 916 + <div class="import-step"> 917 + <div class="import-step-number">Step 2</div> 918 + <h4>Upload your export</h4> 919 + <div class="import-upload-area" id="guidedDropArea"> 920 + <p>${escapeHtml(source.upload_prompt || 'Select a file to import')}</p> 921 + <div class="import-upload-help">${escapeHtml(source.accept ? `Accepted: ${source.accept}` : 'Any supported file')}</div> 922 + <input id="guidedFileInput" type="file" accept="${escapeHtml(source.accept || '')}" style="display:none" /> 923 + <div id="guidedFileLabel" class="import-file-label"></div> 924 + </div> 925 + </div> 926 + <div class="import-step"> 927 + <div class="import-step-number">Step 3</div> 928 + <h4>Configure and start</h4> 929 + <div class="import-inline-fields"> 930 + <div class="confirm-field"> 931 + <label for="guidedFacetSelect">Facet</label> 932 + <select id="guidedFacetSelect"><option value="">None</option></select> 933 + </div> 934 + <div class="confirm-field"> 935 + <label for="guidedSettingInput">Setting</label> 936 + <input id="guidedSettingInput" placeholder="e.g. Weekly planning" /> 937 + </div> 938 + </div> 939 + <div class="import-action-row"> 940 + <button id="guidedStartBtn" class="import-primary-btn" type="button">Start Import</button> 941 + </div> 942 + </div> 943 + `; 944 + 945 + loadFacets(window.selectedFacet || '', document.getElementById('guidedFacetSelect')); 946 + setupGuidedUploadArea(); 947 + document.getElementById('guidedStartBtn').addEventListener('click', () => startGuidedImport(source)); 948 + } 949 + 950 + function renderQuickImportFlow() { 951 + const guideSteps = document.getElementById('guideSteps'); 952 + if (!guideSteps) { 953 + return; 954 + } 955 + 956 + guideSteps.innerHTML = ` 957 + <div> 958 + <h3 class="import-guide-title">⚡ Quick Import</h3> 959 + <p class="import-guide-subtitle">Paste text or drop a file, then confirm the detected timestamp in the modal.</p> 960 + </div> 961 + <form id="importForm"> 962 + <div class="import-step"> 963 + <div class="import-step-number">Step 1</div> 964 + <div class="import-upload-area" id="dropArea"> 965 + <p>Drag file here or click to select</p> 966 + <input id="fileInput" type="file" style="display:none" /> 967 + <div id="fileLabel" class="import-file-label"></div> 968 + </div> 969 + </div> 970 + <div class="import-step"> 971 + <div class="import-step-number">Step 2</div> 972 + <textarea id="pasteText" class="import-paste-text" placeholder="Paste text here..."></textarea> 973 + <div class="import-inline-fields"> 974 + <div class="confirm-field"> 975 + <label for="quickFacetSelect">Facet</label> 976 + <select id="quickFacetSelect"><option value="">None</option></select> 977 + </div> 978 + <div class="confirm-field"> 979 + <label for="quickSettingInput">Setting</label> 980 + <input id="quickSettingInput" placeholder="e.g. Paste from notes app" /> 981 + </div> 982 + </div> 983 + <div class="import-action-row"> 984 + <button type="submit" id="validateBtn" class="import-primary-btn">Validate</button> 985 + </div> 986 + </div> 987 + </form> 988 + `; 989 + 990 + loadFacets(window.selectedFacet || '', document.getElementById('quickFacetSelect')); 991 + setupQuickImportForm(); 992 + } 993 + 994 + function setupGuidedUploadArea() { 995 + const dropArea = document.getElementById('guidedDropArea'); 996 + const fileInput = document.getElementById('guidedFileInput'); 997 + const fileLabel = document.getElementById('guidedFileLabel'); 998 + 999 + if (!dropArea || !fileInput || !fileLabel) { 1000 + return; 1001 + } 1002 + 1003 + const setFile = file => { 1004 + currentGuidedFile = file || null; 1005 + fileLabel.textContent = file ? file.name : ''; 1006 + currentGuidedSaved = null; 1007 + }; 1008 + 1009 + dropArea.onclick = () => fileInput.click(); 1010 + ['dragenter', 'dragover'].forEach(eventName => { 1011 + dropArea.addEventListener(eventName, event => { 1012 + event.preventDefault(); 1013 + dropArea.classList.add('dragover'); 1014 + }); 1015 + }); 1016 + ['dragleave', 'drop'].forEach(eventName => { 1017 + dropArea.addEventListener(eventName, event => { 1018 + event.preventDefault(); 1019 + dropArea.classList.remove('dragover'); 1020 + }); 1021 + }); 1022 + dropArea.addEventListener('drop', event => { 1023 + const file = event.dataTransfer.files[0]; 1024 + if (file) { 1025 + setFile(file); 1026 + } 1027 + }); 1028 + fileInput.onchange = event => { 1029 + setFile(event.target.files[0]); 1030 + }; 1031 + } 1032 + 1033 + function setupQuickImportForm() { 1034 + const dropArea = document.getElementById('dropArea'); 1035 + const fileInput = document.getElementById('fileInput'); 1036 + const fileLabel = document.getElementById('fileLabel'); 1037 + const pasteText = document.getElementById('pasteText'); 1038 + const importForm = document.getElementById('importForm'); 1039 + const quickFacetSelect = document.getElementById('quickFacetSelect'); 1040 + const quickSettingInput = document.getElementById('quickSettingInput'); 1041 + 1042 + if (!dropArea || !fileInput || !fileLabel || !pasteText || !importForm) { 1043 + return; 1044 + } 1045 + 1046 + currentFile = null; 1047 + dropArea.onclick = () => fileInput.click(); 1048 + ['dragenter', 'dragover'].forEach(ev => dropArea.addEventListener(ev, e => { 1049 + e.preventDefault(); 1050 + dropArea.classList.add('dragover'); 1051 + })); 1052 + ['dragleave', 'drop'].forEach(ev => dropArea.addEventListener(ev, e => { 1053 + e.preventDefault(); 1054 + dropArea.classList.remove('dragover'); 1055 + })); 1056 + dropArea.addEventListener('drop', e => { 1057 + const file = e.dataTransfer.files[0]; 1058 + if (file) { 1059 + currentFile = file; 1060 + fileLabel.textContent = file.name; 1061 + } 1062 + }); 1063 + fileInput.onchange = e => { 1064 + currentFile = e.target.files[0]; 1065 + if (currentFile) { 1066 + fileLabel.textContent = currentFile.name; 1067 + } 1068 + }; 1069 + 1070 + importForm.onsubmit = async e => { 1071 + e.preventDefault(); 1072 + const validateBtn = document.getElementById('validateBtn'); 1073 + const hasFile = currentFile != null; 1074 + const hasText = pasteText.value.trim().length > 0; 1075 + 1076 + if (!hasFile && !hasText) { 1077 + if (window.showError) { 1078 + showError('Please provide a file or paste text'); 1079 + } 1080 + return; 1081 + } 1082 + 1083 + validateBtn.disabled = true; 1084 + validateBtn.innerHTML = 'Validating<span class="spinner"></span>'; 1085 + 1086 + try { 1087 + const fd = new FormData(); 1088 + if (hasFile) { 1089 + fd.append('file', currentFile); 1090 + } else { 1091 + fd.append('text', pasteText.value); 1092 + } 1093 + 1094 + const facet = quickFacetSelect ? quickFacetSelect.value : ''; 1095 + if (facet) { 1096 + fd.append('facet', facet); 1097 + } 1098 + const settingValue = quickSettingInput ? quickSettingInput.value.trim() : ''; 1099 + if (settingValue) { 1100 + fd.append('setting', settingValue); 1101 + } 1102 + 1103 + const response = await fetch('/app/import/api/save', { method: 'POST', body: fd }); 1104 + const data = await response.json(); 1105 + validateBtn.disabled = false; 1106 + validateBtn.textContent = 'Validate'; 1107 + 1108 + if (!response.ok) { 1109 + if (window.showError) { 1110 + showError(data.error || 'Validation failed'); 1111 + } 1112 + return; 1113 + } 1114 + showDetect(data); 1115 + } catch (_err) { 1116 + validateBtn.disabled = false; 1117 + validateBtn.textContent = 'Validate'; 1118 + if (window.showError) { 1119 + showError('Validation failed'); 1120 + } 374 1121 } 1122 + }; 1123 + } 1124 + 1125 + async function uploadGuidedSourceFile() { 1126 + if (!currentGuidedFile) { 1127 + throw new Error('Please select a file to import.'); 1128 + } 1129 + 1130 + const guidedFacetSelect = document.getElementById('guidedFacetSelect'); 1131 + const guidedSettingInput = document.getElementById('guidedSettingInput'); 1132 + const fd = new FormData(); 1133 + fd.append('file', currentGuidedFile); 1134 + if (guidedFacetSelect && guidedFacetSelect.value) { 1135 + fd.append('facet', guidedFacetSelect.value); 1136 + } 1137 + if (guidedSettingInput && guidedSettingInput.value.trim()) { 1138 + fd.append('setting', guidedSettingInput.value.trim()); 375 1139 } 1140 + 1141 + const response = await fetch('/app/import/api/save', { 1142 + method: 'POST', 1143 + body: fd, 1144 + }); 1145 + const data = await response.json(); 1146 + if (!response.ok) { 1147 + throw new Error(data.error || 'Failed to save import'); 1148 + } 1149 + 1150 + currentGuidedSaved = data; 1151 + return data; 376 1152 } 377 1153 378 - // Listen for importer events 379 - appEvents.listen('importer', msg => { 380 - updateImportRow(msg.import_id, msg); 381 - }); 1154 + async function startGuidedImport(source) { 1155 + const startButton = document.getElementById('guidedStartBtn'); 1156 + const guidedFacetSelect = document.getElementById('guidedFacetSelect'); 1157 + const guidedSettingInput = document.getElementById('guidedSettingInput'); 1158 + const originalText = startButton.textContent; 1159 + 1160 + startButton.disabled = true; 1161 + startButton.innerHTML = 'Starting<span class="spinner"></span>'; 1162 + 1163 + try { 1164 + const saved = currentGuidedSaved || await uploadGuidedSourceFile(); 1165 + const path = saved.path; 1166 + const ts = saved.timestamp; 1167 + const facet = guidedFacetSelect ? guidedFacetSelect.value : ''; 1168 + const setting = guidedSettingInput ? guidedSettingInput.value.trim() : ''; 1169 + 1170 + const metaResponse = await fetch('/app/import/api/facet', { 1171 + method: 'POST', 1172 + headers: { 'Content-Type': 'application/json' }, 1173 + body: JSON.stringify({ path, facet, setting }), 1174 + }); 1175 + 1176 + if (!metaResponse.ok) { 1177 + const errorData = await metaResponse.json().catch(() => ({})); 1178 + throw new Error(errorData.error || 'Failed to save import settings'); 1179 + } 1180 + 1181 + const startResponse = await fetch('/app/import/api/start', { 1182 + method: 'POST', 1183 + headers: { 'Content-Type': 'application/json' }, 1184 + body: JSON.stringify({ path, timestamp: ts, source: source.name, force: false }), 1185 + }); 1186 + 1187 + if (!startResponse.ok) { 1188 + const errorData = await startResponse.json().catch(() => ({})); 1189 + throw new Error(errorData.error || 'Failed to start import'); 1190 + } 1191 + 1192 + importEvents[ts] = { 1193 + import_id: ts, 1194 + event: 'started', 1195 + stage: 'initialization', 1196 + source_type: source.name, 1197 + source_display: source.display_name, 1198 + }; 1199 + loadImports(); 1200 + navigateTo(`progress/${ts}`); 1201 + } catch (err) { 1202 + if (window.showError && err instanceof Error) { 1203 + showError(err.message); 1204 + } 1205 + } finally { 1206 + startButton.disabled = false; 1207 + startButton.textContent = originalText; 1208 + } 1209 + } 1210 + 1211 + function showProgressView(importId, eventData = null) { 1212 + const guideSteps = document.getElementById('guideSteps'); 1213 + const source = sourceMetadataByName[currentGuideSource] || null; 1214 + const importInfo = getImportById(importId); 1215 + const currentEvent = eventData || importEvents[importId] || null; 1216 + 1217 + if (!guideSteps) { 1218 + return; 1219 + } 1220 + 1221 + const displayName = currentEvent?.source_display || importInfo?.source_display || source?.display_name || 'Import'; 1222 + const stageLabel = currentEvent?.event === 'completed' 1223 + ? 'Import complete' 1224 + : currentEvent?.event === 'error' 1225 + ? 'Import failed' 1226 + : humanStageName(currentEvent?.stage || 'initialization', displayName); 1227 + const progressHtml = currentEvent ? renderProgressStats(currentEvent) : ''; 1228 + let completionHtml = ''; 1229 + 1230 + if (currentEvent?.event === 'completed' || importInfo?.status === 'success') { 1231 + const completedInfo = currentEvent || importInfo || {}; 1232 + const range = completedInfo.date_range || importInfo?.date_range || null; 1233 + const day = range?.[0] || importInfo?.target_day || null; 1234 + completionHtml = ` 1235 + <div class="import-completion-summary"> 1236 + <div><strong>Entries written:</strong> ${completedInfo.entries_written || importInfo?.entries_written || 0}</div> 1237 + <div><strong>Entities seeded:</strong> ${completedInfo.entities_seeded || importInfo?.entities_seeded || 0}</div> 1238 + <div><strong>Date range:</strong> ${escapeHtml(formatDateRange(range) || '-')}</div> 1239 + <div><strong>Duration:</strong> ${formatElapsed(completedInfo.duration_ms || 0)}</div> 1240 + <div class="import-action-row"> 1241 + ${day ? `<a class="import-secondary-btn" href="/app/calendar/${day}">View in calendar</a>` : ''} 1242 + <a href="#" class="import-secondary-btn" onclick="showGrid(); return false;">Import another source</a> 1243 + </div> 1244 + </div> 1245 + `; 1246 + } else if (currentEvent?.event === 'error' || importInfo?.status === 'failed') { 1247 + const errorText = currentEvent?.error || importInfo?.error || 'Unknown error'; 1248 + completionHtml = ` 1249 + <div class="import-completion-summary failed"> 1250 + <div><strong>Error:</strong> ${escapeHtml(errorText)}</div> 1251 + <div class="import-action-row"> 1252 + <a href="#" class="import-secondary-btn" onclick="showGrid(); return false;">Import another source</a> 1253 + </div> 1254 + </div> 1255 + `; 1256 + } 1257 + 1258 + guideSteps.innerHTML = ` 1259 + <div class="import-progress-panel"> 1260 + <div class="import-step-number">Progress</div> 1261 + <h3 class="import-progress-title">${escapeHtml(displayName)}</h3> 1262 + <p class="import-progress-message">${escapeHtml(stageLabel)}</p> 1263 + ${currentEvent?.elapsed_ms ? `<div class="progress-detail">${escapeHtml(formatElapsed(currentEvent.elapsed_ms))} elapsed</div>` : ''} 1264 + ${progressHtml} 1265 + ${completionHtml} 1266 + </div> 1267 + `; 1268 + } 382 1269 383 1270 startBtn.addEventListener('click', async () => { 384 1271 const ts = document.getElementById('timestampInput').value.trim(); ··· 400 1287 const response = await fetch('/app/import/api/facet', { 401 1288 method: 'POST', 402 1289 headers: { 'Content-Type': 'application/json' }, 403 - body: JSON.stringify({ path, facet, setting }) 1290 + body: JSON.stringify({ path, facet, setting }), 404 1291 }); 405 1292 406 1293 if (!response.ok) { ··· 408 1295 throw new Error(errorData.error || 'Failed to save facet selection'); 409 1296 } 410 1297 411 - // Start the import task 412 1298 const startResponse = await fetch('/app/import/api/start', { 413 1299 method: 'POST', 414 1300 headers: { 'Content-Type': 'application/json' }, 415 - body: JSON.stringify({ path, timestamp: ts }) 1301 + body: JSON.stringify({ path, timestamp: ts, source: 'quick', force: false }), 416 1302 }); 417 1303 418 1304 if (!startResponse.ok) { ··· 420 1306 throw new Error(errorData.error || 'Failed to start import'); 421 1307 } 422 1308 423 - const result = await startResponse.json(); 424 - 425 1309 startBtn.disabled = false; 426 1310 startBtn.textContent = originalText; 427 1311 detectModal.style.display = 'none'; 428 - 429 - // Reload imports to show the new import in the list 430 - // Live updates will come via importer events 1312 + importEvents[ts] = { 1313 + import_id: ts, 1314 + event: 'started', 1315 + stage: 'initialization', 1316 + source_type: 'quick', 1317 + source_display: 'Quick Import', 1318 + }; 431 1319 loadImports(); 1320 + navigateTo(`progress/${ts}`); 432 1321 } catch (err) { 433 1322 startBtn.disabled = false; 434 1323 startBtn.textContent = originalText; 435 1324 if (window.showError && err instanceof Error) showError(err.message); 436 - return; 437 1325 } 438 1326 }); 439 1327 440 - document.querySelector('.close').onclick=()=>{document.getElementById('detectModal').style.display='none';}; 441 - window.onclick=e=>{if(e.target==document.getElementById('detectModal'))document.getElementById('detectModal').style.display='none';}; 1328 + document.querySelector('.close').onclick = () => { 1329 + document.getElementById('detectModal').style.display = 'none'; 1330 + }; 1331 + 1332 + window.onclick = e => { 1333 + if (e.target === document.getElementById('detectModal')) { 1334 + document.getElementById('detectModal').style.display = 'none'; 1335 + } 1336 + }; 442 1337 443 - // Load facets for dropdown 444 - function loadFacets(defaultValue = null){ 445 - const select=document.getElementById('facetSelect'); 446 - select.innerHTML='<option value="">None</option>'; 1338 + function loadFacets(defaultValue = null, selectEl = null) { 1339 + const select = selectEl || document.getElementById('facetSelect'); 1340 + if (!select) { 1341 + return; 1342 + } 447 1343 448 - // Use facets data from app framework 1344 + select.innerHTML = '<option value="">None</option>'; 449 1345 const facets = window.facetsData || []; 450 1346 facets.forEach(facet => { 451 - const opt=document.createElement('option'); 452 - opt.value=facet.name; 453 - opt.textContent=facet.title || facet.name; 1347 + const opt = document.createElement('option'); 1348 + opt.value = facet.name; 1349 + opt.textContent = facet.title || facet.name; 454 1350 select.appendChild(opt); 455 1351 }); 456 1352 457 - // Set value after options exist 458 1353 if (defaultValue !== null) { 459 1354 select.value = defaultValue; 460 1355 } 461 1356 } 462 1357 1358 + appEvents.listen('importer', eventData => updateImportRow(eventData.import_id, eventData)); 1359 + 1360 + loadSourceGrid(); 1361 + loadFacets(); 463 1362 loadImports(); 464 - loadFacets(); 1363 + handleHashChange(); 465 1364 466 - // Listen for facet selection changes 467 - window.addEventListener('facet.switch', (e) => { 1365 + window.addEventListener('facet.switch', e => { 468 1366 filterImportsByFacet(e.detail.facet); 469 1367 }); 470 1368 </script>
+5
think/importers/utils.py
··· 338 338 "total_files_created", 0 339 339 ) 340 340 import_data["target_day"] = imported_meta.get("target_day") 341 + import_data["source_type"] = imported_meta.get("source_type") 342 + import_data["source_display"] = imported_meta.get("source_display") 343 + import_data["entries_written"] = imported_meta.get("entries_written") 344 + import_data["entities_seeded"] = imported_meta.get("entities_seeded") 345 + import_data["date_range"] = imported_meta.get("date_range") 341 346 342 347 # Check for error state 343 348 if "error" in imported_meta: