diff --git a/src/contextseek/cli/main.py b/src/contextseek/cli/main.py index 340f74f..17b3380 100644 --- a/src/contextseek/cli/main.py +++ b/src/contextseek/cli/main.py @@ -1151,6 +1151,7 @@ def run_cli( _p = _pl.Path(args.path).expanduser() fmt = detect_format(_p) format_label = { + "obsidian_vault": "Obsidian vault", "auto_dir": "auto-detected directory", "markdown_file": "Markdown/text file", "code_file": "code file", @@ -1181,11 +1182,14 @@ def run_cli( if args.dry_run: print_success( - f"dry-run: would import {report.added} items " - f"({report.skipped} already exist)" + f"dry-run: would add {report.added}, update {report.updated}, " + f"delete {report.deleted} items ({report.skipped} unchanged)" ) else: - print_success(f"done: added {report.added} skipped {report.skipped}") + print_success( + f"done: added {report.added} updated {report.updated} " + f"deleted {report.deleted} skipped {report.skipped}" + ) return 0 return 1 diff --git a/src/contextseek/client/contextseek.py b/src/contextseek/client/contextseek.py index 774a988..27210e0 100644 --- a/src/contextseek/client/contextseek.py +++ b/src/contextseek/client/contextseek.py @@ -25,7 +25,7 @@ from contextseek.scope import ScopeStats, ScopeTree from contextseek.storage.protocol import SeekVFSAdapter -from contextseek.plugs.core.protocols import DataPlug, PlugMeta +from contextseek.plugs.core.protocols import DataPlug, PlugMeta, RawEvent from contextseek.domain.context_item import ContextItem from contextseek.domain.conflicts import ConflictType from contextseek.domain.inference import ( @@ -436,9 +436,11 @@ def plug(self, source: DataPlug, *, scope: str | None = None) -> None: """Register and consume a DataPlug. The plug's ``stream()`` iterator is consumed immediately, adding - each RawEvent as a ContextItem. Scope is taken from the explicit - ``scope`` argument first, then ``event.metadata["scope"]``, then the - plug name as a local fallback. ``event.source`` remains provenance. + each RawEvent as a ContextItem. Incremental events with a stable + ``item_id`` are updated in place, while delete events are soft-deleted. + Scope is taken from the explicit ``scope`` argument first, then + ``event.metadata["scope"]``, then the plug name as a local fallback. + ``event.source`` remains provenance. Args: source: A DataPlug implementation to register. @@ -452,6 +454,29 @@ def plug(self, source: DataPlug, *, scope: str | None = None) -> None: for event in source.stream(): event_scope = scope or str(event.metadata.get("scope") or meta.name) source_id = event.source or meta.name + operation = getattr(event, "operation", "add") + item_id = getattr(event, "item_id", None) + links = list(getattr(event, "links", [])) + + if operation not in {"add", "update", "delete", "noop"}: + raise ValueError(f"unsupported plug operation: {operation}") + if operation == "noop": + continue + if operation == "update" and not item_id: + raise ValueError("incremental plug update events require item_id") + if operation == "delete": + if not item_id: + raise ValueError("incremental plug delete events require item_id") + ref = self.resolver.ref_for(event_scope, item_id) + existing = self._read_item(ref) + if existing is not None and not existing.is_deleted: + existing.soft_delete(f"plug_delete:{meta.name}:{source_id}") + self._write_item_with_audit( + existing, + action="plug_delete", + detail={"source": source_id}, + ) + continue # Allow importers/plugs to override stage/stability (e.g. skill import) event_stage: Stage | None = None @@ -467,15 +492,26 @@ def plug(self, source: DataPlug, *, scope: str | None = None) -> None: except ValueError: pass - item = self.add( - content=event.content, - scope=event_scope, - source=source_id, - source_type=source_type, - tags=event.tags, - stage=event_stage, - stability=event_stability, - ) + if item_id: + item = self._upsert_raw_event( + event, + scope=event_scope, + source=source_id, + source_type=source_type, + stage=event_stage, + stability=event_stability, + ) + else: + item = self.add( + content=event.content, + scope=event_scope, + source=source_id, + source_type=source_type, + tags=event.tags, + stage=event_stage, + stability=event_stability, + links=links, + ) if "embedding" in event.metadata and self.embedder is None: item.embedding = event.metadata["embedding"] if "importance" in event.metadata: @@ -487,6 +523,63 @@ def plug(self, source: DataPlug, *, scope: str | None = None) -> None: ): self._write_item(item) + def _upsert_raw_event( + self, + event: RawEvent, + *, + scope: str, + source: str, + source_type: str, + stage: Stage | None, + stability: Stability | None, + ) -> ContextItem: + """Materialize an incremental RawEvent at its stable item identity.""" + item_id = getattr(event, "item_id", None) + if item_id is None: + raise ValueError("incremental plug events require item_id") + + content = self._apply_write_policy( + event.content, + scope=scope, + source=source, + source_type=source_type, + ) + resolved_stage, resolved_stability = self._resolve_stage_stability( + stage=stage, + stability=stability, + content=content, + source_type=source_type, + ) + ref = self.resolver.ref_for(scope, item_id) + existing = self._read_item(ref) + item = ContextItem( + id=item_id, + content=content, + scope=scope, + provenance=self._build_provenance(source, source_type, None), + tags=list(event.tags or []), + stage=resolved_stage, + stability=resolved_stability, + links=list(getattr(event, "links", [])), + ) + action = "plug_add" + if existing is not None: + action = "plug_update" + item.created_at = existing.created_at + item.updated_at = datetime.now(timezone.utc) + item.relevance_boost = existing.relevance_boost + item.access_count = existing.access_count + item.lineage_access_count = existing.lineage_access_count + item.last_accessed_at = existing.last_accessed_at + + self._prepare_item(item, check_conflicts=False) + self._write_item_with_audit( + item, + action=action, + detail={"source": source}, + ) + return item + def add( self, content: str | dict[str, Any], diff --git a/src/contextseek/daemon/sync_cmd.py b/src/contextseek/daemon/sync_cmd.py index e830384..f086148 100644 --- a/src/contextseek/daemon/sync_cmd.py +++ b/src/contextseek/daemon/sync_cmd.py @@ -85,6 +85,8 @@ def _iter_files(root: pathlib.Path) -> list[pathlib.Path]: @dataclass class SyncReport: added: int = 0 + updated: int = 0 + deleted: int = 0 skipped: int = 0 format_detected: str = "unknown" errors: list[str] = field(default_factory=list) @@ -98,12 +100,14 @@ class SyncReport: def detect_format(path: str | pathlib.Path) -> str: """Detect the source format from path structure and file content. - Returns one of: auto_dir, markdown_file, code_file, chatgpt_json, + Returns one of: obsidian_vault, auto_dir, markdown_file, code_file, chatgpt_json, claude_json, bookmarks_html, plaintext. """ p = pathlib.Path(path).expanduser() if p.is_dir(): + if (p / ".obsidian").is_dir(): + return "obsidian_vault" return "auto_dir" if p.is_file(): @@ -530,6 +534,25 @@ def sync_path( fmt = detect_format(p) report = SyncReport(format_detected=fmt) + if fmt == "obsidian_vault": + from contextseek.plugs.obsidian import ObsidianVaultPlug + + plug = ObsidianVaultPlug( + p, + sync_key=scope, + persist_state=not dry_run, + on_progress=on_progress, + ) + if dry_run: + list(plug.stream()) + else: + ctx.plug(plug, scope=scope) + report.added = plug.stats.added + report.updated = plug.stats.updated + report.deleted = plug.stats.deleted + report.skipped = plug.stats.skipped + return report + sync_backend = _resolve_seekdb_backend(ctx) if sync_backend is not None: if on_progress is not None: diff --git a/src/contextseek/plugs/__init__.py b/src/contextseek/plugs/__init__.py index bb8c2bc..ace40b1 100644 --- a/src/contextseek/plugs/__init__.py +++ b/src/contextseek/plugs/__init__.py @@ -4,6 +4,7 @@ "HermesSkillImporter", "MCPToolImporter", "OpenAIFunctionImporter", + "ObsidianVaultPlug", "PowerMemPlug", "PowerMemProxyPlug", "RAGPlug", @@ -23,6 +24,10 @@ def __getattr__(name: str): from contextseek.plugs.rag import RAGPlug return RAGPlug + if name == "ObsidianVaultPlug": + from contextseek.plugs.obsidian import ObsidianVaultPlug + + return ObsidianVaultPlug if name in {"HermesSkillImporter", "MCPToolImporter", "OpenAIFunctionImporter"}: from contextseek.plugs.skills import ( HermesSkillImporter, diff --git a/src/contextseek/plugs/core/protocols.py b/src/contextseek/plugs/core/protocols.py index eb59212..223f193 100644 --- a/src/contextseek/plugs/core/protocols.py +++ b/src/contextseek/plugs/core/protocols.py @@ -14,6 +14,8 @@ from datetime import datetime, timezone from typing import Any, Iterator, Literal, Protocol, runtime_checkable +from contextseek.domain.links import Link + PlugOperation = Literal["add", "update", "delete", "noop"] MaterializationStatus = Literal["applied", "skipped", "failed"] @@ -64,7 +66,20 @@ class RawEvent: """Optional tags to attach to the resulting ContextItem.""" metadata: dict = field(default_factory=dict) - """Extra key-value pairs the plug can supply (passed to provenance context).""" + """Optional materialization hints supplied by the plug. + + ContextSeek recognizes ``scope``, ``stage``, ``stability``, ``embedding``, + ``importance``, and ``summary``. Other keys are not persisted automatically. + """ + + operation: PlugOperation = "add" + """Requested materialization operation for incremental plugs.""" + + item_id: str | None = None + """Stable ContextItem identity used by incremental plugs for upserts/deletes.""" + + links: list[Link] = field(default_factory=list) + """Typed relationships to attach to the resulting ContextItem.""" @runtime_checkable diff --git a/src/contextseek/plugs/obsidian/__init__.py b/src/contextseek/plugs/obsidian/__init__.py new file mode 100644 index 0000000..809effa --- /dev/null +++ b/src/contextseek/plugs/obsidian/__init__.py @@ -0,0 +1,5 @@ +"""Incremental Obsidian vault DataPlug.""" + +from contextseek.plugs.obsidian.plug import ObsidianSyncStats, ObsidianVaultPlug + +__all__ = ["ObsidianSyncStats", "ObsidianVaultPlug"] diff --git a/src/contextseek/plugs/obsidian/plug.py b/src/contextseek/plugs/obsidian/plug.py new file mode 100644 index 0000000..d508825 --- /dev/null +++ b/src/contextseek/plugs/obsidian/plug.py @@ -0,0 +1,274 @@ +"""Incremental DataPlug for Obsidian Markdown vaults.""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath +from typing import Any, Callable, Iterator +from uuid import NAMESPACE_URL, uuid5 + +from contextseek.domain.links import Link, LinkType +from contextseek.plugs.core.protocols import PlugMeta, RawEvent + + +_STATE_VERSION = 1 +_WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]") + + +@dataclass +class ObsidianSyncStats: + """Counts collected during one vault stream.""" + + added: int = 0 + updated: int = 0 + deleted: int = 0 + skipped: int = 0 + + @property + def changed(self) -> int: + return self.added + self.updated + self.deleted + + +@dataclass +class ObsidianVaultPlug: + """Stream incremental changes from an Obsidian Markdown vault. + + A compact JSON cursor records each source path's mtime, size, content hash, + and stable ContextItem id. The cursor is committed only after the stream is + consumed successfully, so failed materialization is retried on the next run. + """ + + vault_path: str | Path + state_path: str | Path | None = None + sync_key: str = "default" + persist_state: bool = True + on_progress: Callable[[int, int, int], None] | None = None + stats: ObsidianSyncStats = field(default_factory=ObsidianSyncStats, init=False) + + def metadata(self) -> PlugMeta: + return PlugMeta( + name="obsidian_vault", + source_type="document", + description="Incremental Obsidian Markdown vault import", + ) + + def stream(self) -> Iterator[RawEvent]: + """Yield add/update/delete events and commit the cursor on success.""" + vault = Path(self.vault_path).expanduser().resolve() + if not vault.is_dir(): + raise ValueError(f"Obsidian vault not found: {vault}") + + store = _CursorStore(self._resolved_state_path(vault)) + previous = store.load() + vault_id = str(previous.get("vault_id") or _vault_id(vault)) + syncs = _valid_sync_records(previous.get("syncs")) + old_files = _valid_file_records(syncs.get(self.sync_key, {}).get("files")) + markdown_files = _markdown_files(vault) + item_ids = { + rel: str(old_files.get(rel, {}).get("item_id") or _item_id(vault_id, rel)) + for rel in markdown_files + } + lookup = _NoteLookup(item_ids) + + events: list[RawEvent] = [] + next_files: dict[str, dict[str, Any]] = {} + skipped = 0 + + for rel, path in markdown_files.items(): + stat = path.stat() + old = old_files.get(rel) + raw = path.read_text(encoding="utf-8", errors="replace") + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() + record = { + "mtime_ns": stat.st_mtime_ns, + "size": stat.st_size, + "content_hash": digest, + "item_id": item_ids[rel], + } + next_files[rel] = record + if old is not None and old.get("content_hash") == digest: + skipped += 1 + continue + + operation = "update" if old is not None else "add" + events.append( + RawEvent( + content=_indexable_markdown(raw), + source=f"obsidian://{rel}", + tags=["obsidian", "markdown"], + metadata={"vault": str(vault), "path": rel}, + operation=operation, + item_id=item_ids[rel], + links=_wikilinks(raw, source=rel, lookup=lookup), + ) + ) + + for rel in sorted(set(old_files) - set(markdown_files)): + old = old_files[rel] + item_id = old.get("item_id") + if not item_id: + continue + events.append( + RawEvent( + content="", + source=f"obsidian://{rel}", + operation="delete", + item_id=str(item_id), + ) + ) + + self.stats = ObsidianSyncStats(skipped=skipped) + total = len(events) + skipped + if not events and self.on_progress is not None: + self.on_progress(0, skipped, total) + for event in events: + yield event + if event.operation == "add": + self.stats.added += 1 + elif event.operation == "update": + self.stats.updated += 1 + elif event.operation == "delete": + self.stats.deleted += 1 + if self.on_progress is not None: + self.on_progress(self.stats.changed, self.stats.skipped, total) + + if self.persist_state: + syncs[self.sync_key] = {"files": next_files} + store.save( + { + "version": _STATE_VERSION, + "vault_id": vault_id, + "syncs": syncs, + } + ) + + def _resolved_state_path(self, vault: Path) -> Path: + if self.state_path is None: + return vault / ".contextseek" / "obsidian-sync.json" + return Path(self.state_path).expanduser().resolve() + + +class _CursorStore: + def __init__(self, path: Path) -> None: + self._path = path + + def load(self) -> dict[str, Any]: + if not self._path.is_file(): + return {} + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"Invalid Obsidian sync cursor: {self._path}") from exc + if not isinstance(data, dict) or data.get("version") != _STATE_VERSION: + raise ValueError(f"Unsupported Obsidian sync cursor: {self._path}") + return data + + def save(self, data: dict[str, Any]) -> None: + serialized = ( + json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + ) + if self._path.is_file(): + try: + if self._path.read_text(encoding="utf-8") == serialized: + return + except OSError: + pass + self._path.parent.mkdir(parents=True, exist_ok=True) + temporary = self._path.with_suffix(self._path.suffix + ".tmp") + temporary.write_text(serialized, encoding="utf-8") + temporary.replace(self._path) + + +class _NoteLookup: + def __init__(self, item_ids: dict[str, str]) -> None: + self._by_path = {_note_key(rel): item_id for rel, item_id in item_ids.items()} + by_stem: dict[str, list[str]] = {} + for rel, item_id in item_ids.items(): + by_stem.setdefault(PurePosixPath(rel).stem.casefold(), []).append(item_id) + self._by_stem = {stem: ids[0] for stem, ids in by_stem.items() if len(ids) == 1} + + def resolve(self, target: str, *, source: str) -> str | None: + clean = target.split("|", 1)[0].split("#", 1)[0].split("^", 1)[0].strip() + if not clean: + return None + clean = clean.replace("\\", "/") + source_dir = PurePosixPath(source).parent + local = (source_dir / clean).as_posix() + for candidate in (local, clean): + item_id = self._by_path.get(_note_key(candidate)) + if item_id is not None: + return item_id + return self._by_stem.get(PurePosixPath(clean).stem.casefold()) + + +def _valid_file_records(value: Any) -> dict[str, dict[str, Any]]: + if not isinstance(value, dict): + return {} + return { + str(key): dict(record) + for key, record in value.items() + if isinstance(record, dict) + } + + +def _valid_sync_records(value: Any) -> dict[str, dict[str, Any]]: + if not isinstance(value, dict): + return {} + return { + str(key): dict(record) + for key, record in value.items() + if isinstance(record, dict) + } + + +def _markdown_files(vault: Path) -> dict[str, Path]: + files: dict[str, Path] = {} + for path in vault.rglob("*"): + if not path.is_file() or path.suffix.casefold() != ".md": + continue + rel_path = path.relative_to(vault) + if any(part.startswith(".") for part in rel_path.parts[:-1]): + continue + files[rel_path.as_posix()] = path + return dict(sorted(files.items())) + + +def _vault_id(vault: Path) -> str: + return uuid5(NAMESPACE_URL, f"contextseek:obsidian-vault:{vault.as_posix()}").hex + + +def _item_id(vault_id: str, rel: str) -> str: + return uuid5(NAMESPACE_URL, f"contextseek:obsidian:{vault_id}:{rel}").hex + + +def _note_key(value: str) -> str: + normalized = value.replace("\\", "/") + if normalized.casefold().endswith(".md"): + normalized = normalized[:-3] + return normalized.strip("/").casefold() + + +def _wikilinks(raw: str, *, source: str, lookup: _NoteLookup) -> list[Link]: + links: list[Link] = [] + seen: set[str] = set() + for match in _WIKILINK_RE.finditer(raw): + target_id = lookup.resolve(match.group(1), source=source) + if target_id is None or target_id in seen: + continue + seen.add(target_id) + links.append(Link(target_id=target_id, relation=LinkType.related_to)) + return links + + +def _indexable_markdown(raw: str) -> str: + text = raw + if text.startswith("---"): + end = text.find("\n---", 3) + if end != -1: + text = text[end + 4 :] + text = re.sub(r"\[\[([^\]|]+)\|([^\]]+)\]\]", r"\2", text) + text = re.sub(r"\[\[([^\]]+)\]\]", r"\1", text) + return text.strip() diff --git a/tests/fixtures/obsidian_vault/.obsidian/app.json b/tests/fixtures/obsidian_vault/.obsidian/app.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/obsidian_vault/.obsidian/app.json @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/obsidian_vault/Home.md b/tests/fixtures/obsidian_vault/Home.md new file mode 100644 index 0000000..ccc66f0 --- /dev/null +++ b/tests/fixtures/obsidian_vault/Home.md @@ -0,0 +1,3 @@ +# Home + +The current project is [[Projects/Alpha|Project Alpha]]. diff --git a/tests/fixtures/obsidian_vault/Projects/Alpha.md b/tests/fixtures/obsidian_vault/Projects/Alpha.md new file mode 100644 index 0000000..20382eb --- /dev/null +++ b/tests/fixtures/obsidian_vault/Projects/Alpha.md @@ -0,0 +1,3 @@ +# Project Alpha + +Alpha is the active delivery project for this fixture vault. diff --git a/tests/integration_tests/test_obsidian_sync.py b/tests/integration_tests/test_obsidian_sync.py new file mode 100644 index 0000000..f273a68 --- /dev/null +++ b/tests/integration_tests/test_obsidian_sync.py @@ -0,0 +1,149 @@ +"""Integration coverage for incremental Obsidian vault sync.""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from seekvfs import VFS + +from contextseek.client.contextseek import ContextSeek +from contextseek.daemon.sync_cmd import sync_path +from contextseek.domain.links import LinkType +from contextseek.storage.sqlite_backend import SQLiteBackend +from contextseek.storage.storage_adapter import SeekVFSStorageAdapter + + +FIXTURE_VAULT = Path(__file__).parents[1] / "fixtures" / "obsidian_vault" +SCOPE = "tests/obsidian/vault" + + +def _contextseek(tmp_path: Path) -> tuple[ContextSeek, SQLiteBackend]: + backend = SQLiteBackend(path=str(tmp_path / "contextseek.sqlite3")) + backend.initialize() + vfs = VFS( + routes={"contextseek://": {"backend": backend}}, + scheme="contextseek://", + ) + return ContextSeek(adapter=SeekVFSStorageAdapter(vfs)), backend + + +def _copy_vault(tmp_path: Path) -> Path: + vault = tmp_path / "vault" + shutil.copytree(FIXTURE_VAULT, vault) + return vault + + +def test_obsidian_sync_is_incremental_and_materializes_wikilinks( + tmp_path: Path, +) -> None: + vault = _copy_vault(tmp_path) + ctx, backend = _contextseek(tmp_path) + + first = sync_path(ctx, vault, scope=SCOPE) + + assert first.format_detected == "obsidian_vault" + assert (first.added, first.updated, first.deleted, first.skipped) == (2, 0, 0, 0) + items = ctx.items(scope=SCOPE) + by_source = {item.provenance.source_id: item for item in items} + home = by_source["obsidian://Home.md"] + alpha = by_source["obsidian://Projects/Alpha.md"] + assert home.links == [ + next( + link + for link in home.links + if link.target_id == alpha.id and link.relation is LinkType.related_to + ) + ] + + original_write = ctx.adapter.write + writes: list[str] = [] + + def counting_write(ref: str, payload: dict) -> None: + writes.append(ref) + original_write(ref, payload) + + ctx.adapter.write = counting_write # type: ignore[method-assign] + second = sync_path(ctx, vault, scope=SCOPE) + + assert (second.added, second.updated, second.deleted, second.skipped) == ( + 0, + 0, + 0, + 2, + ) + assert writes == [] + + alpha_path = vault / "Projects" / "Alpha.md" + original = alpha_path.read_text(encoding="utf-8") + original_stat = alpha_path.stat() + same_size_edit = original.replace("active", "edited") + assert len(same_size_edit) == len(original) + alpha_path.write_text(same_size_edit, encoding="utf-8") + os.utime( + alpha_path, + ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns), + ) + same_metadata = sync_path(ctx, vault, scope=SCOPE) + + assert ( + same_metadata.added, + same_metadata.updated, + same_metadata.deleted, + same_metadata.skipped, + ) == (0, 1, 0, 1) + assert len(writes) == 1 + same_metadata_alpha = { + item.provenance.source_id: item for item in ctx.items(scope=SCOPE) + }["obsidian://Projects/Alpha.md"] + assert "edited delivery project" in same_metadata_alpha.content_text + + writes.clear() + alpha_path.write_text( + alpha_path.read_text(encoding="utf-8") + "\nOnly this note changed.\n", + encoding="utf-8", + ) + changed = sync_path(ctx, vault, scope=SCOPE) + + assert (changed.added, changed.updated, changed.deleted, changed.skipped) == ( + 0, + 1, + 0, + 1, + ) + assert len(writes) == 1 + updated_alpha = { + item.provenance.source_id: item for item in ctx.items(scope=SCOPE) + }["obsidian://Projects/Alpha.md"] + assert updated_alpha.id == alpha.id + assert "Only this note changed" in updated_alpha.content_text + + writes.clear() + alpha_path.unlink() + deleted = sync_path(ctx, vault, scope=SCOPE) + + assert (deleted.added, deleted.updated, deleted.deleted, deleted.skipped) == ( + 0, + 0, + 1, + 1, + ) + assert len(writes) == 1 + alpha_ref = ctx.resolver.ref_for(SCOPE, alpha.id) + assert ctx._read_item(alpha_ref).is_deleted is True # noqa: SLF001 + backend.close() + + +def test_obsidian_cursor_is_isolated_by_destination_scope(tmp_path: Path) -> None: + vault = _copy_vault(tmp_path) + ctx, backend = _contextseek(tmp_path) + + first = sync_path(ctx, vault, scope="tests/obsidian/first") + second = sync_path(ctx, vault, scope="tests/obsidian/second") + + assert first.added == 2 + assert second.added == 2 + assert len(ctx.items(scope="tests/obsidian/first")) == 2 + assert len(ctx.items(scope="tests/obsidian/second")) == 2 + backend.close()