personal memory agent
0
fork

Configure Feed

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

Add Obsidian path input and dedup detection to import UX

- Obsidian source uses path_input: text field for vault folder path
instead of file drop zone, with /api/save-path backend endpoint
- Dedup check on file upload: /api/save returns previous import info
when the same file hash is found in manifests
- Frontend shows inline dedup notice with "Re-import anyway" button
that sets --force flag on the import command
- Handle local filesystem paths in /api/start (skip timestamp rename
logic for paths not under imports/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+205 -17
+81 -7
apps/import/routes.py
··· 82 82 "emoji": "📝", 83 83 "icon": "file-text", 84 84 "description": "Import notes from Obsidian, Logseq, or any markdown vault", 85 - "input_type": "directory", 86 - "upload_prompt": "Upload your vault as a .zip file", 85 + "input_type": "path_input", 86 + "upload_prompt": "Paste the full path to your vault folder", 87 87 "has_guide": True, 88 - "accept": ".zip", 88 + "accept": "", 89 89 }, 90 90 { 91 91 "name": "kindle", ··· 250 250 metadata=metadata, 251 251 ) 252 252 253 + # Check for dedup — has this exact file been imported before? 254 + dedup = None 255 + try: 256 + from think.importers.shared import find_manifest_by_hash, hash_source 257 + 258 + source_hash = hash_source(file_path) 259 + existing = find_manifest_by_hash(Path(state.journal_root), source_hash) 260 + if existing: 261 + dedup = { 262 + "imported_at": existing.get("imported_at", "unknown"), 263 + "entry_count": existing.get("entry_count", 0), 264 + "import_id": existing.get("import_id", ""), 265 + } 266 + except Exception: 267 + pass # dedup check is best-effort 268 + 269 + result: dict[str, Any] = { 270 + "path": str(file_path), 271 + "timestamp": folder_timestamp, 272 + "facet": facet, 273 + "setting": setting, 274 + } 275 + if dedup: 276 + result["dedup"] = dedup 277 + 278 + return jsonify(result) 279 + 280 + 281 + @import_bp.route("/api/save-path", methods=["POST"]) 282 + def import_save_path() -> Any: 283 + """Register a local filesystem path for import (e.g. Obsidian vault).""" 284 + from datetime import datetime 285 + 286 + data = request.get_json(force=True) 287 + local_path = data.get("path", "").strip() 288 + facet = data.get("facet", "").strip() or None 289 + setting = data.get("setting", "").strip() or None 290 + 291 + if not local_path: 292 + return jsonify({"error": "Missing path"}), 400 293 + 294 + local = Path(local_path) 295 + if not local.exists(): 296 + return jsonify({"error": f"Path not found: {local_path}"}), 404 297 + 298 + timestamp_ms = now_ms() 299 + folder_timestamp = ( 300 + f"{datetime.fromtimestamp(timestamp_ms / 1000).strftime('%Y%m%d_%H%M%S')}" 301 + ) 302 + 303 + # Create import directory and metadata 304 + journal_root = Path(state.journal_root) 305 + import_dir = journal_root / "imports" / folder_timestamp 306 + import_dir.mkdir(parents=True, exist_ok=True) 307 + 308 + metadata = { 309 + "original_filename": local.name, 310 + "upload_timestamp": timestamp_ms, 311 + "upload_datetime": datetime.fromtimestamp(timestamp_ms / 1000).isoformat(), 312 + "user_timestamp": folder_timestamp, 313 + "file_path": local_path, 314 + "facet": facet, 315 + "setting": setting, 316 + "is_local_path": True, 317 + } 318 + 319 + write_import_metadata( 320 + journal_root=journal_root, 321 + timestamp=folder_timestamp, 322 + metadata=metadata, 323 + ) 324 + 253 325 return jsonify( 254 326 { 255 - "path": str(file_path), 327 + "path": local_path, 256 328 "timestamp": folder_timestamp, 257 329 "facet": facet, 258 330 "setting": setting, ··· 409 481 410 482 # Extract original timestamp from path and handle timestamp changes 411 483 file_path = Path(path) 412 - original_timestamp = file_path.parent.name 413 484 journal_root = Path(state.journal_root) 485 + imports_dir = journal_root / "imports" 486 + is_local_path = not str(file_path).startswith(str(imports_dir)) 487 + original_timestamp = file_path.parent.name if not is_local_path else ts 414 488 415 489 # If timestamp changed, rename the import directory 416 - if original_timestamp != ts: 490 + if not is_local_path and original_timestamp != ts: 417 491 old_import_dir = journal_root / "imports" / original_timestamp 418 492 new_import_dir = journal_root / "imports" / ts 419 493 ··· 454 528 return jsonify({"error": f"Failed to read metadata: {str(e)}"}), 500 455 529 456 530 # Update file_path in metadata if timestamp changed 457 - if original_timestamp != ts: 531 + if not is_local_path and original_timestamp != ts: 458 532 try: 459 533 update_import_metadata_fields( 460 534 journal_root=journal_root,
+124 -10
apps/import/workspace.html
··· 173 173 color: #2f3a4c; 174 174 } 175 175 176 + .import-path-input { 177 + width: 100%; 178 + padding: 0.85rem; 179 + border: 1px solid #cfd7e3; 180 + border-radius: 10px; 181 + font-family: monospace; 182 + font-size: 0.95rem; 183 + box-sizing: border-box; 184 + background: #f8fbff; 185 + } 186 + 187 + .import-path-input:focus { 188 + outline: none; 189 + border-color: #0f4c81; 190 + background: #ffffff; 191 + } 192 + 193 + .import-dedup-notice { 194 + margin-top: 1rem; 195 + padding: 0.85rem 1rem; 196 + background: #fef9e7; 197 + border: 1px solid #f0d858; 198 + border-radius: 10px; 199 + font-size: 0.92rem; 200 + display: flex; 201 + align-items: center; 202 + gap: 0.75rem; 203 + flex-wrap: wrap; 204 + } 205 + 206 + .import-dedup-notice .dedup-text { 207 + flex: 1; 208 + color: #5b4a08; 209 + } 210 + 211 + .import-dedup-notice button { 212 + padding: 0.4rem 0.85rem; 213 + font-size: 0.85rem; 214 + font-weight: 600; 215 + background: white; 216 + border: 1px solid #d9a800; 217 + border-radius: 8px; 218 + cursor: pointer; 219 + color: #5b4a08; 220 + white-space: nowrap; 221 + } 222 + 223 + .import-dedup-notice button:hover { 224 + background: #fef3c7; 225 + } 226 + 176 227 .import-paste-text { 177 228 width: 100%; 178 229 min-height: 220px; ··· 496 547 currentGuideSource = null; 497 548 currentGuidedFile = null; 498 549 currentGuidedSaved = null; 550 + window._guidedForceImport = false; 499 551 navigateTo('grid'); 500 552 } 501 553 ··· 915 967 ${guideHtml} 916 968 <div class="import-step"> 917 969 <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> 970 + ${source.input_type === 'path_input' ? ` 971 + <h4>Point to your vault</h4> 972 + <input id="guidedPathInput" class="import-path-input" type="text" 973 + placeholder="${escapeHtml(source.upload_prompt || 'Paste the full path to your folder')}" /> 974 + ` : ` 975 + <h4>Upload your export</h4> 976 + <div class="import-upload-area" id="guidedDropArea"> 977 + <p>${escapeHtml(source.upload_prompt || 'Select a file to import')}</p> 978 + <div class="import-upload-help">${escapeHtml(source.accept ? `Accepted: ${source.accept}` : 'Any supported file')}</div> 979 + <input id="guidedFileInput" type="file" accept="${escapeHtml(source.accept || '')}" style="display:none" /> 980 + <div id="guidedFileLabel" class="import-file-label"></div> 981 + </div> 982 + `} 983 + <div id="guidedDedupNotice"></div> 925 984 </div> 926 985 <div class="import-step"> 927 986 <div class="import-step-number">Step 3</div> ··· 943 1002 `; 944 1003 945 1004 loadFacets(window.selectedFacet || '', document.getElementById('guidedFacetSelect')); 946 - setupGuidedUploadArea(); 1005 + if (source.input_type !== 'path_input') { 1006 + setupGuidedUploadArea(); 1007 + } 947 1008 document.getElementById('guidedStartBtn').addEventListener('click', () => startGuidedImport(source)); 948 1009 } 949 1010 ··· 1148 1209 } 1149 1210 1150 1211 currentGuidedSaved = data; 1212 + 1213 + // Show dedup notice if the file was previously imported 1214 + if (data.dedup) { 1215 + showDedupNotice(data.dedup); 1216 + } 1217 + 1218 + return data; 1219 + } 1220 + 1221 + function showDedupNotice(dedup) { 1222 + const container = document.getElementById('guidedDedupNotice'); 1223 + if (!container) return; 1224 + container.innerHTML = ` 1225 + <div class="import-dedup-notice"> 1226 + <span class="dedup-text">This file was already imported on ${escapeHtml(dedup.imported_at)}${dedup.entry_count ? ` (${dedup.entry_count} entries)` : ''}.</span> 1227 + <button type="button" id="dedupForceBtn">Re-import anyway</button> 1228 + </div> 1229 + `; 1230 + document.getElementById('dedupForceBtn').addEventListener('click', () => { 1231 + window._guidedForceImport = true; 1232 + container.innerHTML = '<div class="import-dedup-notice"><span class="dedup-text">Will re-import. Click Start Import to proceed.</span></div>'; 1233 + }); 1234 + } 1235 + 1236 + async function saveGuidedPath(source) { 1237 + const pathInput = document.getElementById('guidedPathInput'); 1238 + if (!pathInput || !pathInput.value.trim()) { 1239 + throw new Error('Please enter the path to your vault folder.'); 1240 + } 1241 + 1242 + const guidedFacetSelect = document.getElementById('guidedFacetSelect'); 1243 + const guidedSettingInput = document.getElementById('guidedSettingInput'); 1244 + const facet = guidedFacetSelect ? guidedFacetSelect.value : ''; 1245 + const setting = guidedSettingInput ? guidedSettingInput.value.trim() : ''; 1246 + 1247 + const response = await fetch('/app/import/api/save-path', { 1248 + method: 'POST', 1249 + headers: { 'Content-Type': 'application/json' }, 1250 + body: JSON.stringify({ path: pathInput.value.trim(), facet, setting }), 1251 + }); 1252 + const data = await response.json(); 1253 + if (!response.ok) { 1254 + throw new Error(data.error || 'Path not found'); 1255 + } 1256 + 1257 + currentGuidedSaved = data; 1151 1258 return data; 1152 1259 } 1153 1260 ··· 1161 1268 startButton.innerHTML = 'Starting<span class="spinner"></span>'; 1162 1269 1163 1270 try { 1164 - const saved = currentGuidedSaved || await uploadGuidedSourceFile(); 1271 + let saved; 1272 + if (source.input_type === 'path_input') { 1273 + saved = currentGuidedSaved || await saveGuidedPath(source); 1274 + } else { 1275 + saved = currentGuidedSaved || await uploadGuidedSourceFile(); 1276 + } 1165 1277 const path = saved.path; 1166 1278 const ts = saved.timestamp; 1167 1279 const facet = guidedFacetSelect ? guidedFacetSelect.value : ''; 1168 1280 const setting = guidedSettingInput ? guidedSettingInput.value.trim() : ''; 1281 + const force = !!(window._guidedForceImport); 1169 1282 1170 1283 const metaResponse = await fetch('/app/import/api/facet', { 1171 1284 method: 'POST', ··· 1181 1294 const startResponse = await fetch('/app/import/api/start', { 1182 1295 method: 'POST', 1183 1296 headers: { 'Content-Type': 'application/json' }, 1184 - body: JSON.stringify({ path, timestamp: ts, source: source.name, force: false }), 1297 + body: JSON.stringify({ path, timestamp: ts, source: source.name, force }), 1185 1298 }); 1186 1299 1187 1300 if (!startResponse.ok) { ··· 1196 1309 source_type: source.name, 1197 1310 source_display: source.display_name, 1198 1311 }; 1312 + window._guidedForceImport = false; 1199 1313 loadImports(); 1200 1314 navigateTo(`progress/${ts}`); 1201 1315 } catch (err) {