personal memory agent
0
fork

Configure Feed

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

fix: resolve merge conflict keeping feature branch ObsidianSyncBackend

Kept the feature branch's implementation (inline vault resolution,
always-increment edit_count) and removed the duplicate from main.
Applied ruff formatting fixes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+102 -170
+1 -3
apps/settings/routes.py
··· 1564 1564 "obsidian": { 1565 1565 "available": True, 1566 1566 "enabled": ( 1567 - obsidian_entry.get("enabled", True) 1568 - if obsidian_entry 1569 - else False 1567 + obsidian_entry.get("enabled", True) if obsidian_entry else False 1570 1568 ), 1571 1569 "configured": bool(obsidian_entry), 1572 1570 },
+101 -167
think/importers/obsidian.py
··· 424 424 ) 425 425 426 426 427 - # Common Obsidian vault locations for auto-detection 428 - DEFAULT_VAULT_PATHS = [ 429 - Path.home() / "Documents" / "Obsidian", 430 - Path.home() / "Obsidian", 431 - ] 432 - 433 - 434 - def _content_hash(content: str) -> str: 435 - """SHA-256 hash of file content for change detection.""" 436 - return hashlib.sha256(content.encode("utf-8")).hexdigest() 437 - 438 - 439 - def _find_vault_path( 440 - source_path: Path | None, state: dict[str, Any] | None 441 - ) -> Path: 442 - """Resolve vault path from explicit arg, sync state, or auto-detection.""" 443 - if source_path: 444 - if not source_path.is_dir(): 445 - raise ValueError(f"Vault path does not exist: {source_path}") 446 - return source_path 427 + class ObsidianSyncBackend: 428 + """Syncable backend for Obsidian vault incremental sync. 447 429 448 - # Check existing sync state 449 - if state and state.get("source_path"): 450 - saved = Path(state["source_path"]) 451 - if saved.is_dir(): 452 - return saved 453 - 454 - # Auto-detect common locations 455 - for p in DEFAULT_VAULT_PATHS: 456 - if p.is_dir(): 457 - return p 458 - 459 - raise ValueError( 460 - "No Obsidian vault found.\n" 461 - "Specify a vault path: sol import --sync obsidian --path /path/to/vault" 462 - ) 463 - 464 - 465 - class ObsidianSyncBackend: 466 - """Syncable backend for Obsidian vault notes with edit-as-activity model.""" 430 + Edits are activities -- when a note's content changes, a new journal segment 431 + is created at the edit timestamp. Old segments are preserved. 432 + """ 467 433 468 434 name: str = "obsidian" 469 435 ··· 475 441 source_path: Path | None = None, 476 442 force: bool = False, 477 443 ) -> dict[str, Any]: 478 - """Sync Obsidian vault notes incrementally. 479 - 480 - Scans the vault for markdown files, compares against sync state, 481 - and imports new notes and captures edits as new journal segments. 482 - 483 - Args: 484 - journal_root: Path to the journal root directory. 485 - dry_run: If True, catalog only (no import). If False, import. 486 - source_path: Override vault directory path. 487 - force: If True, clear sync state and re-import everything. 488 - 489 - Returns: 490 - Summary dict with total, imported, available, skipped, downloaded, errors. 491 - """ 492 444 state = load_sync_state(journal_root, "obsidian") 493 445 494 - vault_path = _find_vault_path(source_path, state) 446 + vault_path: Path | None = None 447 + if source_path is not None: 448 + vault_path = source_path 449 + elif state and state.get("source_path"): 450 + vault_path = Path(str(state["source_path"])) 451 + else: 452 + for candidate in ( 453 + Path.home() / "Documents" / "Obsidian", 454 + Path.home() / "Obsidian", 455 + ): 456 + if candidate.exists() and candidate.is_dir(): 457 + vault_path = candidate 458 + break 495 459 496 - if state is None: 497 - state = { 498 - "backend": "obsidian", 499 - "source_path": str(vault_path), 500 - "files": {}, 501 - } 460 + if vault_path is None: 461 + raise ValueError( 462 + "No Obsidian vault found. Use --path to specify your vault location." 463 + ) 464 + if not vault_path.exists() or not vault_path.is_dir(): 465 + raise ValueError( 466 + f"Obsidian vault not found at {vault_path}. " 467 + "Use --path to specify your vault location." 468 + ) 502 469 470 + state = state or { 471 + "backend": "obsidian", 472 + "source_path": str(vault_path), 473 + "files": {}, 474 + } 503 475 if force: 504 476 state["files"] = {} 505 477 506 478 known_files: dict[str, dict[str, Any]] = state.get("files", {}) 507 - 508 - # Walk vault using existing importer logic 509 - md_files = _walk_md_files(vault_path) 510 - 511 - current_rel_paths: set[str] = set() 512 - to_import: list[tuple[Path, str, str]] = [] # (path, rel_path, change_type) 479 + to_import: list[dict[str, Any]] = [] 480 + current_paths: set[str] = set() 513 481 514 - for md_path in md_files: 482 + for md_path in _walk_md_files(vault_path): 515 483 rel_path = str(md_path.relative_to(vault_path)) 516 - current_rel_paths.add(rel_path) 484 + current_paths.add(rel_path) 517 485 518 486 content = _read_file_safe(md_path) 519 487 if content is None or not content.strip(): ··· 524 492 except OSError: 525 493 continue 526 494 527 - hash_val = _content_hash(content) 528 - 529 - if rel_path in known_files and not force: 530 - existing = known_files[rel_path] 531 - if existing.get("status") == "imported": 532 - # Fast path: mtime unchanged — skip 533 - if mtime == existing.get("mtime"): 534 - continue 535 - # Correctness: hash unchanged — mtime-only change, skip 536 - if hash_val == existing.get("content_hash"): 537 - existing["mtime"] = mtime 538 - continue 539 - # Content actually changed — edit 540 - change_type = "edited" 541 - elif existing.get("status") == "removed": 542 - change_type = "new" 543 - else: 544 - change_type = "new" 495 + existing = known_files.get(rel_path) 496 + if existing and existing.get("status") == "imported" and not force: 497 + if existing.get("mtime") == mtime: 498 + continue 499 + content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() 500 + if existing.get("content_hash") == content_hash: 501 + existing["mtime"] = mtime 502 + continue 545 503 else: 546 - change_type = "new" 504 + content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() 505 + if ( 506 + existing 507 + and existing.get("content_hash") == content_hash 508 + and not force 509 + ): 510 + continue 511 + 512 + title = md_path.stem 513 + tags = _parse_frontmatter_tags(content) 514 + wikilinks = WIKILINK_RE.findall(content) 515 + is_daily = _parse_daily_note_date(md_path.name) is not None 547 516 548 - known_files.setdefault(rel_path, {}) 549 - known_files[rel_path].update( 517 + known_files[rel_path] = { 518 + **(known_files.get(rel_path) or {}), 519 + "filename": md_path.name, 520 + "title": title, 521 + "mtime": mtime, 522 + "content_hash": content_hash, 523 + "status": "available", 524 + "edit_count": known_files.get(rel_path, {}).get("edit_count", 0), 525 + } 526 + to_import.append( 550 527 { 551 - "filename": md_path.name, 552 - "title": md_path.stem, 553 528 "mtime": mtime, 554 - "content_hash": hash_val, 555 - "status": "available", 556 - "_change_type": change_type, 529 + "title": title, 530 + "content": content, 531 + "source_path": rel_path, 532 + "is_daily": is_daily, 533 + "tags": tags, 534 + "wikilinks": wikilinks, 535 + "rel_path": rel_path, 536 + "content_hash": content_hash, 557 537 } 558 538 ) 559 - to_import.append((md_path, rel_path, change_type)) 560 539 561 - # Detect deleted files 562 540 for rel_path, info in known_files.items(): 563 - if rel_path not in current_rel_paths and info.get("status") not in ( 564 - "removed", 565 - "skipped", 566 - ): 541 + if rel_path not in current_paths and info.get("status") not in ("removed",): 567 542 info["status"] = "removed" 568 543 569 - total = len(known_files) 570 - imported = sum( 571 - 1 for f in known_files.values() if f.get("status") == "imported" 572 - ) 573 - available = len(to_import) 574 - skipped_total = sum( 575 - 1 576 - for f in known_files.values() 577 - if f.get("status") in ("skipped", "removed") 578 - ) 579 - 580 544 result: dict[str, Any] = { 581 - "total": total, 582 - "imported": imported, 583 - "available": available, 584 - "skipped": skipped_total, 545 + "total": len(known_files), 546 + "imported": sum( 547 + 1 for f in known_files.values() if f.get("status") == "imported" 548 + ), 549 + "available": len(to_import), 550 + "skipped": 0, 585 551 "downloaded": 0, 586 552 "errors": [], 587 553 } ··· 590 556 downloaded = 0 591 557 errors: list[str] = [] 592 558 593 - for md_path, rel_path, change_type in to_import: 594 - try: 595 - content = _read_file_safe(md_path) 596 - if content is None: 597 - errors.append(f"Failed to read: {rel_path}") 598 - continue 599 - 600 - mtime = md_path.stat().st_mtime 601 - tags = _parse_frontmatter_tags(content) 602 - wikilinks = WIKILINK_RE.findall(content) 603 - 604 - note = { 605 - "mtime": mtime, 606 - "title": md_path.stem, 607 - "content": content, 608 - "source_path": rel_path, 609 - "is_daily": _parse_daily_note_date(md_path.name) is not None, 610 - "tags": tags, 611 - "wikilinks": wikilinks, 612 - } 559 + def render_fn(items: list[dict[str, Any]]) -> str: 560 + return "\n\n".join(_render_note_markdown(n) for n in items) 613 561 562 + for note in to_import: 563 + try: 614 564 windows = window_items([note], "mtime", tz=None) 615 - created_files, segments = write_markdown_segments( 565 + _created_files, segs = write_markdown_segments( 616 566 "obsidian", 617 567 windows, 618 - lambda items: "\n\n".join( 619 - _render_note_markdown(n) for n in items 620 - ), 568 + render_fn, 621 569 filename="note_transcript.md", 622 570 ) 623 571 624 - # Seed entities from wikilinks 625 - if wikilinks and segments: 572 + if note["wikilinks"] and segs: 573 + day = segs[0][0] 626 574 entity_dicts = [ 627 575 {"name": link, "type": "Topic"} 628 - for link in sorted(set(wikilinks)) 576 + for link in sorted(set(note["wikilinks"])) 629 577 ] 630 578 try: 631 - seed_entities( 632 - "import.obsidian", segments[0][0], entity_dicts 633 - ) 579 + seed_entities("import.obsidian", day, entity_dicts) 634 580 except Exception as exc: 635 581 logger.warning( 636 - "Entity seeding failed for %s: %s", rel_path, exc 582 + "Entity seeding failed for %s: %s", 583 + note["rel_path"], 584 + exc, 637 585 ) 638 586 639 - # Update sync state for this file 640 - file_state = known_files[rel_path] 641 - edit_count = file_state.get("edit_count", 0) 642 - if change_type == "edited": 643 - edit_count += 1 644 - 645 - file_state.update( 587 + known_files[note["rel_path"]].update( 646 588 { 647 589 "status": "imported", 648 - "mtime": mtime, 649 - "content_hash": _content_hash(content), 650 - "edit_count": edit_count, 651 590 "imported_at": dt.datetime.now().isoformat(), 652 - "segments": len(segments), 591 + "segments": len(segs), 592 + "edit_count": known_files[note["rel_path"]].get( 593 + "edit_count", 0 594 + ) 595 + + 1, 653 596 } 654 597 ) 655 - file_state.pop("_change_type", None) 656 - 657 598 downloaded += 1 658 - action = "Re-imported (edit)" if change_type == "edited" else "Imported" 659 - logger.info("%s %s: %d segments", action, rel_path, len(segments)) 660 - 661 599 except Exception as exc: 662 - msg = f"{rel_path}: {exc}" 600 + msg = f"{note['rel_path']}: {exc}" 663 601 logger.warning("Import failed: %s", msg) 664 602 errors.append(msg) 665 603 ··· 671 609 result["available"] = sum( 672 610 1 for f in known_files.values() if f.get("status") == "available" 673 611 ) 674 - 675 - # Clean transient fields before persisting 676 - for info in known_files.values(): 677 - info.pop("_change_type", None) 678 612 679 613 state["files"] = known_files 680 614 state["source_path"] = str(vault_path)