diff --git a/src/basic_memory/cli/commands/sync.py b/src/basic_memory/cli/commands/sync.py index a888c2a8..a3dcfdcc 100644 --- a/src/basic_memory/cli/commands/sync.py +++ b/src/basic_memory/cli/commands/sync.py @@ -1,22 +1,43 @@ """Command module for basic-memory sync operations.""" from pathlib import Path -from typing import Optional +from typing import Optional, List, Tuple +from dataclasses import dataclass import typer import asyncio from loguru import logger +from rich.console import Console from basic_memory.cli.app import app from basic_memory import db from basic_memory.config import config from basic_memory.db import DatabaseType -from basic_memory.repository import DocumentRepository, EntityRepository +from basic_memory.repository import DocumentRepository, EntityRepository, ObservationRepository, RelationRepository from basic_memory.services import ( DocumentService, - EntityService, + EntityService, ObservationService, RelationService, ) from basic_memory.markdown import KnowledgeParser from basic_memory.services.sync import SyncService, FileChangeScanner, KnowledgeSyncService +from basic_memory.utils.file_utils import ParseError + +console = Console() + +@dataclass +class ValidationIssue: + file_path: str + error: str + +def display_validation_errors(issues: List[ValidationIssue]): + """Display validation errors in a clear, git-like format.""" + console.print("\n[red]Error: Invalid frontmatter in knowledge files[/red]\n") + + for issue in issues: + console.print(f"{issue.file_path}") + console.print(f" {issue.error}\n") + + console.print("To fix, add required frontmatter fields to each file") + console.print("Once fixed, run 'basic-memory sync' again") async def get_sync_service(db_type=DatabaseType.FILESYSTEM): @@ -28,14 +49,19 @@ async def get_sync_service(db_type=DatabaseType.FILESYSTEM): # Initialize repositories document_repository = DocumentRepository(session_maker) entity_repository = EntityRepository(session_maker) + observation_repository = ObservationRepository(session_maker) + relation_repository = RelationRepository(session_maker) # Initialize scanner file_change_scanner = FileChangeScanner(document_repository, entity_repository) # Initialize services - document_service = DocumentService(document_repository) + document_service = DocumentService(document_repository, config.documents_dir) entity_service = EntityService(entity_repository) - knowledge_sync_service = KnowledgeSyncService(entity_service) + observation_service = ObservationService(observation_repository) + relation_service = RelationService(relation_repository) + + knowledge_sync_service = KnowledgeSyncService(entity_service, observation_service, relation_service) knowledge_parser = KnowledgeParser() # Create sync service @@ -49,14 +75,46 @@ async def get_sync_service(db_type=DatabaseType.FILESYSTEM): return sync_service -async def run_sync(root_dir: Path): +async def validate_knowledge_files(sync_service: SyncService, directory: Path) -> List[ValidationIssue]: + """Pre-validate knowledge files and collect all issues.""" + issues = [] + changes = await sync_service.scanner.find_knowledge_changes(directory) + + for file_path in [*changes.new, *changes.modified]: + try: + await sync_service.knowledge_parser.parse_file(directory / file_path) + except ParseError as e: + issues.append(ValidationIssue(file_path=file_path, error=str(e))) + + return issues + + +async def run_sync(): """Run sync operation.""" sync_service = await get_sync_service() - await sync_service.sync(root_dir) + + # Sync documents first + await sync_service.sync_documents(config.documents_dir) + + # Validate knowledge files before attempting sync + issues = await validate_knowledge_files(sync_service, config.knowledge_dir) + if issues: + display_validation_errors(issues) + raise typer.Exit(1) + + # If validation passes, sync knowledge files + await sync_service.sync_knowledge(config.knowledge_dir) @app.command() def sync( + path: Optional[Path] = typer.Argument( + None, + help="Path to sync. Defaults to current project directory.", + exists=True, + dir_okay=True, + file_okay=False, + ), verbose: bool = typer.Option( False, "--verbose", @@ -66,23 +124,23 @@ def sync( ) -> None: """Sync knowledge files with the database. - This command syncs both documents and knowledge files with the database, - using a two-pass strategy for knowledge files to handle relations correctly. - - Use 'basic-memory status' to preview changes before syncing. + This command syncs both documents and knowledge files with the database. + Knowledge files must have required frontmatter fields: type, id, created, modified. """ try: # Get project directory - project_dir = config.home + project_dir = path or Path.cwd() logger.info(f"Syncing directory: {project_dir}") # Run sync - asyncio.run(run_sync(project_dir)) + asyncio.run(run_sync()) if verbose: logger.info("Sync completed successfully") except Exception as e: - logger.exception(f"Sync failed: {e}") - typer.echo(f"Error during sync: {e}", err=True) - raise typer.Exit(1) + if not isinstance(e, typer.Exit): + logger.exception("Sync failed") + typer.echo(f"Error during sync: {e}", err=True) + raise typer.Exit(1) + raise