mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
91bfe2dc92
Signed-off-by: phernandez <paul@basicmachines.co>
235 lines
7.8 KiB
Python
235 lines
7.8 KiB
Python
"""Command module for basic-memory sync operations."""
|
|
|
|
import asyncio
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import List, Dict
|
|
|
|
import typer
|
|
from loguru import logger
|
|
from rich.console import Console
|
|
from rich.tree import Tree
|
|
|
|
from basic_memory import db
|
|
from basic_memory.cli.app import app
|
|
from basic_memory.config import config
|
|
from basic_memory.markdown import EntityParser
|
|
from basic_memory.markdown.markdown_processor import MarkdownProcessor
|
|
from basic_memory.models import Project
|
|
from basic_memory.repository import (
|
|
EntityRepository,
|
|
ObservationRepository,
|
|
RelationRepository,
|
|
ProjectRepository,
|
|
)
|
|
from basic_memory.repository.search_repository import SearchRepository
|
|
from basic_memory.services import EntityService, FileService
|
|
from basic_memory.services.link_resolver import LinkResolver
|
|
from basic_memory.services.search_service import SearchService
|
|
from basic_memory.sync import SyncService
|
|
from basic_memory.sync.sync_service import SyncReport
|
|
from basic_memory.config import app_config
|
|
|
|
console = Console()
|
|
|
|
|
|
@dataclass
|
|
class ValidationIssue:
|
|
file_path: str
|
|
error: str
|
|
|
|
|
|
async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
|
|
"""Get sync service instance with all dependencies."""
|
|
_, session_maker = await db.get_or_create_db(
|
|
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
|
)
|
|
|
|
project_path = Path(project.path)
|
|
entity_parser = EntityParser(project_path)
|
|
markdown_processor = MarkdownProcessor(entity_parser)
|
|
file_service = FileService(project_path, markdown_processor)
|
|
|
|
# Initialize repositories
|
|
entity_repository = EntityRepository(session_maker, project_id=project.id)
|
|
observation_repository = ObservationRepository(session_maker, project_id=project.id)
|
|
relation_repository = RelationRepository(session_maker, project_id=project.id)
|
|
search_repository = SearchRepository(session_maker, project_id=project.id)
|
|
|
|
# Initialize services
|
|
search_service = SearchService(search_repository, entity_repository, file_service)
|
|
link_resolver = LinkResolver(entity_repository, search_service)
|
|
|
|
# Initialize services
|
|
entity_service = EntityService(
|
|
entity_parser,
|
|
entity_repository,
|
|
observation_repository,
|
|
relation_repository,
|
|
file_service,
|
|
link_resolver,
|
|
)
|
|
|
|
# Create sync service
|
|
sync_service = SyncService(
|
|
app_config=app_config,
|
|
entity_service=entity_service,
|
|
entity_parser=entity_parser,
|
|
entity_repository=entity_repository,
|
|
relation_repository=relation_repository,
|
|
search_service=search_service,
|
|
file_service=file_service,
|
|
)
|
|
|
|
return sync_service
|
|
|
|
|
|
def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[ValidationIssue]]:
|
|
"""Group validation issues by directory."""
|
|
grouped = defaultdict(list)
|
|
for issue in issues:
|
|
dir_name = Path(issue.file_path).parent.name
|
|
grouped[dir_name].append(issue)
|
|
return dict(grouped)
|
|
|
|
|
|
def display_sync_summary(knowledge: SyncReport):
|
|
"""Display a one-line summary of sync changes."""
|
|
total_changes = knowledge.total
|
|
project_name = config.project
|
|
|
|
if total_changes == 0:
|
|
console.print(f"[green]Project '{project_name}': Everything up to date[/green]")
|
|
return
|
|
|
|
# Format as: "Synced X files (A new, B modified, C moved, D deleted)"
|
|
changes = []
|
|
new_count = len(knowledge.new)
|
|
mod_count = len(knowledge.modified)
|
|
move_count = len(knowledge.moves)
|
|
del_count = len(knowledge.deleted)
|
|
|
|
if new_count:
|
|
changes.append(f"[green]{new_count} new[/green]")
|
|
if mod_count:
|
|
changes.append(f"[yellow]{mod_count} modified[/yellow]")
|
|
if move_count:
|
|
changes.append(f"[blue]{move_count} moved[/blue]")
|
|
if del_count:
|
|
changes.append(f"[red]{del_count} deleted[/red]")
|
|
|
|
console.print(f"Project '{project_name}': Synced {total_changes} files ({', '.join(changes)})")
|
|
|
|
|
|
def display_detailed_sync_results(knowledge: SyncReport):
|
|
"""Display detailed sync results with trees."""
|
|
project_name = config.project
|
|
|
|
if knowledge.total == 0:
|
|
console.print(f"\n[green]Project '{project_name}': Everything up to date[/green]")
|
|
return
|
|
|
|
console.print(f"\n[bold]Sync Results for Project '{project_name}'[/bold]")
|
|
|
|
if knowledge.total > 0:
|
|
knowledge_tree = Tree("[bold]Knowledge Files[/bold]")
|
|
if knowledge.new:
|
|
created = knowledge_tree.add("[green]Created[/green]")
|
|
for path in sorted(knowledge.new):
|
|
checksum = knowledge.checksums.get(path, "")
|
|
created.add(f"[green]{path}[/green] ({checksum[:8]})")
|
|
if knowledge.modified:
|
|
modified = knowledge_tree.add("[yellow]Modified[/yellow]")
|
|
for path in sorted(knowledge.modified):
|
|
checksum = knowledge.checksums.get(path, "")
|
|
modified.add(f"[yellow]{path}[/yellow] ({checksum[:8]})")
|
|
if knowledge.moves:
|
|
moved = knowledge_tree.add("[blue]Moved[/blue]")
|
|
for old_path, new_path in sorted(knowledge.moves.items()):
|
|
checksum = knowledge.checksums.get(new_path, "")
|
|
moved.add(f"[blue]{old_path}[/blue] → [blue]{new_path}[/blue] ({checksum[:8]})")
|
|
if knowledge.deleted:
|
|
deleted = knowledge_tree.add("[red]Deleted[/red]")
|
|
for path in sorted(knowledge.deleted):
|
|
deleted.add(f"[red]{path}[/red]")
|
|
console.print(knowledge_tree)
|
|
|
|
|
|
async def run_sync(verbose: bool = False):
|
|
"""Run sync operation."""
|
|
_, session_maker = await db.get_or_create_db(
|
|
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
|
)
|
|
project_repository = ProjectRepository(session_maker)
|
|
project = await project_repository.get_by_name(config.project)
|
|
if not project: # pragma: no cover
|
|
raise Exception(f"Project '{config.project}' not found")
|
|
|
|
import time
|
|
|
|
start_time = time.time()
|
|
|
|
logger.info(
|
|
"Sync command started",
|
|
project=config.project,
|
|
verbose=verbose,
|
|
directory=str(config.home),
|
|
)
|
|
|
|
sync_service = await get_sync_service(project)
|
|
|
|
logger.info("Running one-time sync")
|
|
knowledge_changes = await sync_service.sync(config.home, project_name=project.name)
|
|
|
|
# Log results
|
|
duration_ms = int((time.time() - start_time) * 1000)
|
|
logger.info(
|
|
"Sync command completed",
|
|
project=config.project,
|
|
total_changes=knowledge_changes.total,
|
|
new_files=len(knowledge_changes.new),
|
|
modified_files=len(knowledge_changes.modified),
|
|
deleted_files=len(knowledge_changes.deleted),
|
|
moved_files=len(knowledge_changes.moves),
|
|
duration_ms=duration_ms,
|
|
)
|
|
|
|
# Display results
|
|
if verbose:
|
|
display_detailed_sync_results(knowledge_changes)
|
|
else:
|
|
display_sync_summary(knowledge_changes) # pragma: no cover
|
|
|
|
|
|
@app.command()
|
|
def sync(
|
|
verbose: bool = typer.Option(
|
|
False,
|
|
"--verbose",
|
|
"-v",
|
|
help="Show detailed sync information.",
|
|
),
|
|
) -> None:
|
|
"""Sync knowledge files with the database."""
|
|
try:
|
|
# Show which project we're syncing
|
|
typer.echo(f"Syncing project: {config.project}")
|
|
typer.echo(f"Project path: {config.home}")
|
|
|
|
# Run sync
|
|
asyncio.run(run_sync(verbose=verbose))
|
|
|
|
except Exception as e: # pragma: no cover
|
|
if not isinstance(e, typer.Exit):
|
|
logger.exception(
|
|
"Sync command failed",
|
|
f"project={config.project},"
|
|
f"error={str(e)},"
|
|
f"error_type={type(e).__name__},"
|
|
f"directory={str(config.home)}",
|
|
)
|
|
typer.echo(f"Error during sync: {e}", err=True)
|
|
raise typer.Exit(1)
|
|
raise
|