personal memory agent
0

Configure Feed

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

solstone / tests / test_importer_obsidian_sync.py
20 kB 582 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4import glob 5from pathlib import Path 6from textwrap import dedent 7from unittest.mock import patch 8 9import pytest 10 11 12def _write_note( 13 vault_dir: Path, 14 rel_path: str, 15 content: str, 16 mtime: float | None = None, 17) -> Path: 18 """Write a note file to the vault.""" 19 path = vault_dir / rel_path 20 path.parent.mkdir(parents=True, exist_ok=True) 21 path.write_text(content, encoding="utf-8") 22 if mtime is not None: 23 import os 24 25 os.utime(path, (mtime, mtime)) 26 return path 27 28 29SAMPLE_NOTE = dedent("""\ 30 --- 31 tags: [project, alpha] 32 --- 33 # Alpha Project 34 35 This is a note about the [[Alpha Project]]. 36 See also [[Bob Smith]] and [[Design Doc]]. 37""") 38 39SAMPLE_NOTE_2 = dedent("""\ 40 # Daily Note 41 42 Today I worked on [[Beta Launch]]. 43""") 44 45UPDATED_NOTE = dedent("""\ 46 --- 47 tags: [project, alpha] 48 --- 49 # Alpha Project 50 51 This is an updated note about the [[Alpha Project]]. 52 See also [[Bob Smith]], [[Design Doc]], and [[Launch Plan]]. 53""") 54 55 56def test_obsidian_sync_protocol_conformance(): 57 """ObsidianSyncBackend satisfies SyncableBackend protocol.""" 58 from solstone.think.importers.obsidian import ObsidianSyncBackend 59 from solstone.think.importers.sync import SyncableBackend 60 61 assert isinstance(ObsidianSyncBackend(), SyncableBackend) 62 63 64def test_obsidian_sync_registry_discovery(): 65 """Registry discovery includes obsidian.""" 66 from solstone.think.importers.sync import get_syncable_backends 67 68 backends = get_syncable_backends() 69 assert "obsidian" in [backend.name for backend in backends] 70 71 72def test_obsidian_sync_dry_run(tmp_path): 73 """Dry-run catalogs notes and saves state.""" 74 from solstone.think.importers.obsidian import ObsidianSyncBackend 75 from solstone.think.importers.sync import load_sync_state 76 77 vault = tmp_path / "vault" 78 _write_note(vault, "Projects/Alpha.md", SAMPLE_NOTE, mtime=1_700_000_000) 79 _write_note(vault, "Daily/2026-03-14.md", SAMPLE_NOTE_2, mtime=1_700_000_600) 80 81 result = ObsidianSyncBackend().sync(tmp_path, source_path=vault, dry_run=True) 82 83 assert result["total"] >= 2 84 assert result["available"] == 2 85 assert result["imported"] == 0 86 assert result["downloaded"] == 0 87 88 state = load_sync_state(tmp_path, "obsidian") 89 assert state is not None 90 assert state["files"]["Projects/Alpha.md"]["status"] == "available" 91 assert state["files"]["Daily/2026-03-14.md"]["status"] == "available" 92 93 94def test_obsidian_sync_import(tmp_path, monkeypatch): 95 """Import mode writes note segments and updates state.""" 96 from solstone.think.importers.obsidian import ObsidianSyncBackend 97 from solstone.think.importers.sync import load_sync_state 98 99 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 100 vault = tmp_path / "vault" 101 _write_note(vault, "Projects/Alpha.md", SAMPLE_NOTE, mtime=1_700_000_000) 102 103 result = ObsidianSyncBackend().sync(tmp_path, source_path=vault, dry_run=False) 104 105 assert result["downloaded"] >= 1 106 assert result["imported"] >= 1 107 108 segments = glob.glob( 109 str( 110 tmp_path 111 / "chronicle" 112 / "*" 113 / "import.obsidian" 114 / "*" 115 / "note_transcript.md" 116 ) 117 ) 118 assert len(segments) >= 1 119 120 state = load_sync_state(tmp_path, "obsidian") 121 assert state is not None 122 assert state["files"]["Projects/Alpha.md"]["status"] == "imported" 123 124 125def test_obsidian_sync_edit_creates_new_segments(tmp_path, monkeypatch): 126 """Editing a note creates new segments and preserves old ones.""" 127 from solstone.think.importers.obsidian import ObsidianSyncBackend 128 from solstone.think.importers.sync import load_sync_state 129 130 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 131 vault = tmp_path / "vault" 132 _write_note(vault, "Projects/Alpha.md", SAMPLE_NOTE, mtime=1_700_000_000) 133 134 backend = ObsidianSyncBackend() 135 first = backend.sync(tmp_path, source_path=vault, dry_run=False) 136 assert first["downloaded"] == 1 137 first_segments = sorted( 138 glob.glob( 139 str( 140 tmp_path 141 / "chronicle" 142 / "*" 143 / "import.obsidian" 144 / "*" 145 / "note_transcript.md" 146 ) 147 ) 148 ) 149 assert len(first_segments) == 1 150 151 _write_note(vault, "Projects/Alpha.md", UPDATED_NOTE, mtime=1_700_000_900) 152 second = backend.sync(tmp_path, source_path=vault, dry_run=False) 153 assert second["downloaded"] == 1 154 155 all_segments = sorted( 156 glob.glob( 157 str( 158 tmp_path 159 / "chronicle" 160 / "*" 161 / "import.obsidian" 162 / "*" 163 / "note_transcript.md" 164 ) 165 ) 166 ) 167 assert len(all_segments) == 2 168 assert first_segments[0] in all_segments 169 170 state = load_sync_state(tmp_path, "obsidian") 171 assert state is not None 172 assert state["files"]["Projects/Alpha.md"]["edit_count"] >= 2 173 174 175def test_obsidian_sync_unchanged_skip(tmp_path, monkeypatch): 176 """Mtime-only changes are skipped when content hash matches.""" 177 from solstone.think.importers.obsidian import ObsidianSyncBackend 178 179 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 180 vault = tmp_path / "vault" 181 _write_note(vault, "Projects/Alpha.md", SAMPLE_NOTE, mtime=1_700_000_000) 182 183 backend = ObsidianSyncBackend() 184 backend.sync(tmp_path, source_path=vault, dry_run=False) 185 _write_note(vault, "Projects/Alpha.md", SAMPLE_NOTE, mtime=1_700_000_300) 186 187 result = backend.sync(tmp_path, source_path=vault, dry_run=True) 188 assert result["available"] == 0 189 190 191def test_obsidian_sync_deleted_note(tmp_path, monkeypatch): 192 """Deleted notes are marked removed in state.""" 193 from solstone.think.importers.obsidian import ObsidianSyncBackend 194 from solstone.think.importers.sync import load_sync_state 195 196 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 197 vault = tmp_path / "vault" 198 note = _write_note(vault, "Projects/Alpha.md", SAMPLE_NOTE, mtime=1_700_000_000) 199 200 backend = ObsidianSyncBackend() 201 backend.sync(tmp_path, source_path=vault, dry_run=False) 202 note.unlink() 203 backend.sync(tmp_path, source_path=vault, dry_run=True) 204 205 state = load_sync_state(tmp_path, "obsidian") 206 assert state is not None 207 assert state["files"]["Projects/Alpha.md"]["status"] == "removed" 208 209 210def test_obsidian_sync_force(tmp_path, monkeypatch): 211 """Force re-detects notes by clearing state.""" 212 from solstone.think.importers.obsidian import ObsidianSyncBackend 213 214 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 215 vault = tmp_path / "vault" 216 _write_note(vault, "Projects/Alpha.md", SAMPLE_NOTE, mtime=1_700_000_000) 217 218 backend = ObsidianSyncBackend() 219 backend.sync(tmp_path, source_path=vault, dry_run=False) 220 result = backend.sync(tmp_path, source_path=vault, dry_run=True, force=True) 221 222 assert result["available"] >= 1 223 224 225def test_obsidian_sync_vault_auto_detection(tmp_path, monkeypatch): 226 """Raises when no vault can be auto-detected.""" 227 from solstone.think.importers.obsidian import ObsidianSyncBackend 228 229 home = tmp_path / "home" 230 home.mkdir() 231 monkeypatch.setattr("solstone.think.importers.obsidian.Path.home", lambda: home) 232 233 with pytest.raises( 234 ValueError, 235 match="No Obsidian vault found. Use --path to specify your vault location.", 236 ): 237 ObsidianSyncBackend().sync(tmp_path) 238 239 240def test_obsidian_sync_entity_seeding(tmp_path, monkeypatch): 241 """Wikilinks are converted into Topic entities on import.""" 242 from solstone.think.importers.obsidian import ObsidianSyncBackend 243 244 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 245 vault = tmp_path / "vault" 246 _write_note(vault, "Projects/Alpha.md", SAMPLE_NOTE, mtime=1_700_000_000) 247 248 captured: list[tuple[str, str, list[dict[str, str]]]] = [] 249 250 def _fake_seed_entities( 251 facet: str, 252 day: str, 253 entities: list[dict[str, str]], 254 ) -> list[dict[str, str]]: 255 captured.append((facet, day, entities)) 256 return entities 257 258 with patch( 259 "solstone.think.importers.obsidian.seed_entities", 260 side_effect=_fake_seed_entities, 261 ): 262 ObsidianSyncBackend().sync(tmp_path, source_path=vault, dry_run=False) 263 264 assert len(captured) == 1 265 facet, _day, entities = captured[0] 266 assert facet == "import.obsidian" 267 assert entities == [ 268 {"name": "Alpha Project", "type": "Topic"}, 269 {"name": "Bob Smith", "type": "Topic"}, 270 {"name": "Design Doc", "type": "Topic"}, 271 ] 272 273 274def test_obsidian_sync_incremental(tmp_path, monkeypatch): 275 """Incremental sync imports only newly added notes.""" 276 from solstone.think.importers.obsidian import ObsidianSyncBackend 277 278 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 279 vault = tmp_path / "vault" 280 _write_note(vault, "Projects/Alpha.md", SAMPLE_NOTE, mtime=1_700_000_000) 281 282 backend = ObsidianSyncBackend() 283 first = backend.sync(tmp_path, source_path=vault, dry_run=False) 284 assert first["downloaded"] == 1 285 286 _write_note(vault, "Daily/2026-03-14.md", SAMPLE_NOTE_2, mtime=1_700_000_600) 287 second = backend.sync(tmp_path, source_path=vault, dry_run=False) 288 289 assert second["downloaded"] == 1 290 assert second["available"] == 0 291 assert second["imported"] >= 1 292 293 294def test_infer_entity_type_from_path(): 295 """Folder path entity type inference.""" 296 from solstone.think.importers.obsidian import infer_entity_type_from_path 297 298 # Direct folder match 299 assert infer_entity_type_from_path("People/Jane Smith.md") == "Person" 300 assert infer_entity_type_from_path("Contacts/John.md") == "Person" 301 assert infer_entity_type_from_path("Projects/Alpha.md") == "Project" 302 assert infer_entity_type_from_path("Companies/Acme.md") == "Organization" 303 assert infer_entity_type_from_path("Organizations/UN.md") == "Organization" 304 assert infer_entity_type_from_path("Places/Paris.md") == "Place" 305 assert infer_entity_type_from_path("Locations/HQ.md") == "Place" 306 307 # Case insensitive 308 assert infer_entity_type_from_path("people/jane.md") == "Person" 309 assert infer_entity_type_from_path("PEOPLE/Jane.md") == "Person" 310 311 # Any depth in path 312 assert infer_entity_type_from_path("00 knowledge/People/Jane Smith.md") == "Person" 313 assert infer_entity_type_from_path("Atlas/References/People/Jane.md") == "Person" 314 315 # Numeric prefix stripping 316 assert infer_entity_type_from_path("00 People/Jane.md") == "Person" 317 assert infer_entity_type_from_path("01 Projects/Alpha.md") == "Project" 318 319 # No match → None 320 assert infer_entity_type_from_path("Notes/random.md") is None 321 assert infer_entity_type_from_path("Daily/2026-03-14.md") is None 322 assert infer_entity_type_from_path("random.md") is None 323 324 325def test_clean_at_prefix(): 326 """@ prefix stripping from entity names.""" 327 from solstone.think.importers.obsidian import _clean_at_prefix 328 329 assert _clean_at_prefix("@JaneSmith") == ("JaneSmith", True) 330 assert _clean_at_prefix("@ Jane Smith") == ("Jane Smith", True) 331 assert _clean_at_prefix("@") == ("", True) 332 assert _clean_at_prefix("Jane Smith") == ("Jane Smith", False) 333 assert _clean_at_prefix("") == ("", False) 334 335 336def test_build_entity_dicts_precedence(): 337 """Entity type inference precedence: @ > folder-path > Topic.""" 338 from solstone.think.importers.obsidian import _build_entity_dicts 339 340 # Basic Topic fallback 341 result = _build_entity_dicts({"Design Doc"}, {}) 342 assert result == [{"name": "Design Doc", "type": "Topic"}] 343 344 # Folder-path type 345 result = _build_entity_dicts({"Jane Smith"}, {"Jane Smith": "Person"}) 346 assert result == [{"name": "Jane Smith", "type": "Person"}] 347 348 # @ prefix → Person 349 result = _build_entity_dicts({"@Jane Smith"}, {}) 350 assert result == [{"name": "Jane Smith", "type": "Person"}] 351 352 # @ wins over folder-path 353 result = _build_entity_dicts( 354 {"@Jane Smith"}, 355 {"Jane Smith": "Organization"}, 356 ) 357 assert result == [{"name": "Jane Smith", "type": "Person"}] 358 359 # @ filename added even without wikilink 360 result = _build_entity_dicts(set(), {}, at_filenames={"Jane Smith"}) 361 assert result == [{"name": "Jane Smith", "type": "Person"}] 362 363 # Dedup: both @Jane and Jane as wikilinks — @ wins 364 result = _build_entity_dicts({"@Jane Smith", "Jane Smith"}, {}) 365 assert result == [{"name": "Jane Smith", "type": "Person"}] 366 367 368def test_obsidian_sync_folder_path_entity_typing(tmp_path, monkeypatch): 369 """Notes in typed folders produce typed entities.""" 370 from solstone.think.importers.obsidian import ObsidianSyncBackend 371 372 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 373 vault = tmp_path / "vault" 374 375 _write_note( 376 vault, "People/Jane Smith.md", "# Jane Smith\nA person.", mtime=1_700_000_000 377 ) 378 _write_note( 379 vault, 380 "Notes/meeting.md", 381 "# Meeting\nMet with [[Jane Smith]] about [[Design Doc]].", 382 mtime=1_700_000_100, 383 ) 384 385 captured: list[tuple[str, str, list[dict[str, str]]]] = [] 386 387 def _fake_seed(facet, day, entities): 388 captured.append((facet, day, entities)) 389 return entities 390 391 with patch( 392 "solstone.think.importers.obsidian.seed_entities", side_effect=_fake_seed 393 ): 394 ObsidianSyncBackend().sync(tmp_path, source_path=vault, dry_run=False) 395 396 all_entities = {} 397 for _, _, entities in captured: 398 for e in entities: 399 all_entities[e["name"]] = e["type"] 400 401 assert all_entities["Jane Smith"] == "Person" 402 assert all_entities["Design Doc"] == "Topic" 403 404 405def test_obsidian_sync_at_prefix_entity_typing(tmp_path, monkeypatch): 406 """Wikilinks with @ prefix produce Person entities.""" 407 from solstone.think.importers.obsidian import ObsidianSyncBackend 408 409 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 410 vault = tmp_path / "vault" 411 412 _write_note( 413 vault, 414 "Notes/meeting.md", 415 "# Meeting\nMet with [[@Bob Jones]] and [[Design Doc]].", 416 mtime=1_700_000_000, 417 ) 418 419 captured: list[tuple[str, str, list[dict[str, str]]]] = [] 420 421 def _fake_seed(facet, day, entities): 422 captured.append((facet, day, entities)) 423 return entities 424 425 with patch( 426 "solstone.think.importers.obsidian.seed_entities", side_effect=_fake_seed 427 ): 428 ObsidianSyncBackend().sync(tmp_path, source_path=vault, dry_run=False) 429 430 all_entities = {} 431 for _, _, entities in captured: 432 for e in entities: 433 all_entities[e["name"]] = e["type"] 434 435 assert all_entities["Bob Jones"] == "Person" 436 assert all_entities["Design Doc"] == "Topic" 437 438 439def test_obsidian_sync_numeric_prefix_folder(tmp_path, monkeypatch): 440 """Numeric-prefixed folder names are matched after stripping.""" 441 from solstone.think.importers.obsidian import ObsidianSyncBackend 442 443 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 444 vault = tmp_path / "vault" 445 446 _write_note(vault, "00 People/Jane.md", "# Jane\nA person.", mtime=1_700_000_000) 447 _write_note( 448 vault, 449 "Notes/ref.md", 450 "# Ref\nSee [[Jane]].", 451 mtime=1_700_000_100, 452 ) 453 454 captured: list[tuple[str, str, list[dict[str, str]]]] = [] 455 456 def _fake_seed(facet, day, entities): 457 captured.append((facet, day, entities)) 458 return entities 459 460 with patch( 461 "solstone.think.importers.obsidian.seed_entities", side_effect=_fake_seed 462 ): 463 ObsidianSyncBackend().sync(tmp_path, source_path=vault, dry_run=False) 464 465 all_entities = {} 466 for _, _, entities in captured: 467 for e in entities: 468 all_entities[e["name"]] = e["type"] 469 470 assert all_entities["Jane"] == "Person" 471 472 473def test_obsidian_walk_excludes_templates(tmp_path): 474 """templates/ and _templates/ folders are excluded from file walking.""" 475 from solstone.think.importers.obsidian import _walk_md_files 476 477 vault = tmp_path / "vault" 478 _write_note(vault, "Notes/real.md", "# Real note", mtime=1_700_000_000) 479 _write_note(vault, "templates/daily.md", "# Template", mtime=1_700_000_000) 480 _write_note(vault, "_templates/weekly.md", "# Template", mtime=1_700_000_000) 481 482 files = _walk_md_files(vault) 483 rel_paths = [str(f.relative_to(vault)) for f in files] 484 485 assert "Notes/real.md" in rel_paths 486 assert "templates/daily.md" not in rel_paths 487 assert "_templates/weekly.md" not in rel_paths 488 489 490def test_obsidian_walk_excludes_templates_case_insensitive(tmp_path): 491 """Template folder exclusion is case-insensitive.""" 492 from solstone.think.importers.obsidian import _walk_md_files 493 494 vault = tmp_path / "vault" 495 _write_note(vault, "Notes/real.md", "# Real note", mtime=1_700_000_000) 496 _write_note(vault, "Templates/daily.md", "# Template", mtime=1_700_000_000) 497 _write_note(vault, "TEMPLATES/weekly.md", "# Template", mtime=1_700_000_000) 498 _write_note(vault, "_Templates/meeting.md", "# Template", mtime=1_700_000_000) 499 500 files = _walk_md_files(vault) 501 rel_paths = [str(f.relative_to(vault)) for f in files] 502 503 assert "Notes/real.md" in rel_paths 504 assert "Templates/daily.md" not in rel_paths 505 assert "TEMPLATES/weekly.md" not in rel_paths 506 assert "_Templates/meeting.md" not in rel_paths 507 508 509def test_obsidian_walk_excludes_hidden_dirs(tmp_path): 510 """Hidden dirs (.obsidian, .trash) are excluded by _is_hidden.""" 511 from solstone.think.importers.obsidian import _walk_md_files 512 513 vault = tmp_path / "vault" 514 _write_note(vault, "Notes/real.md", "# Real note", mtime=1_700_000_000) 515 _write_note(vault, ".obsidian/plugins/list.md", "# Plugin", mtime=1_700_000_000) 516 _write_note(vault, ".trash/deleted.md", "# Deleted", mtime=1_700_000_000) 517 518 files = _walk_md_files(vault) 519 rel_paths = [str(f.relative_to(vault)) for f in files] 520 521 assert "Notes/real.md" in rel_paths 522 assert ".obsidian/plugins/list.md" not in rel_paths 523 assert ".trash/deleted.md" not in rel_paths 524 525 526def test_obsidian_walk_excludes_nested_templates(tmp_path): 527 """Templates folders nested inside other folders are also excluded.""" 528 from solstone.think.importers.obsidian import _walk_md_files 529 530 vault = tmp_path / "vault" 531 _write_note(vault, "Notes/real.md", "# Real note", mtime=1_700_000_000) 532 _write_note( 533 vault, "Areas/Work/templates/standup.md", "# Template", mtime=1_700_000_000 534 ) 535 536 files = _walk_md_files(vault) 537 rel_paths = [str(f.relative_to(vault)) for f in files] 538 539 assert "Notes/real.md" in rel_paths 540 assert "Areas/Work/templates/standup.md" not in rel_paths 541 542 543def test_obsidian_sync_excludes_templates(tmp_path, monkeypatch): 544 """Incremental sync skips template folders.""" 545 from solstone.think.importers.obsidian import ObsidianSyncBackend 546 from solstone.think.importers.sync import load_sync_state 547 548 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 549 vault = tmp_path / "vault" 550 _write_note(vault, "Notes/real.md", SAMPLE_NOTE, mtime=1_700_000_000) 551 _write_note( 552 vault, "templates/daily.md", "# Daily Template\n{{date}}", mtime=1_700_000_000 553 ) 554 _write_note( 555 vault, 556 "Templates/weekly.md", 557 "# Weekly Template\n{{date}}", 558 mtime=1_700_000_000, 559 ) 560 561 result = ObsidianSyncBackend().sync(tmp_path, source_path=vault, dry_run=True) 562 563 assert result["available"] == 1 564 565 state = load_sync_state(tmp_path, "obsidian") 566 assert "Notes/real.md" in state["files"] 567 assert "templates/daily.md" not in state["files"] 568 assert "Templates/weekly.md" not in state["files"] 569 570 571def test_obsidian_backends_cli_flag(capsys, monkeypatch, tmp_path): 572 """sol import --backends lists obsidian.""" 573 import sys 574 575 from solstone.think.importers.cli import main 576 577 monkeypatch.setattr(sys, "argv", ["sol import", "--backends"]) 578 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path / "journal")) 579 580 main() 581 captured = capsys.readouterr() 582 assert "obsidian" in captured.out