mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: Beta work (#17)
feat: Add multiple projects support feat: enhanced read_note for when initial result is not found fix: merge frontmatter when updating note fix: handle directory removed on sync watch
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
"""Service for syncing files between filesystem and database."""
|
||||
|
||||
# Suppress logfire warnings
|
||||
import os
|
||||
|
||||
os.environ["LOGFIRE_IGNORE_NO_CONFIG"] = "1"
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Set, Dict
|
||||
from typing import Tuple
|
||||
from typing import Dict, Optional, Set, Tuple
|
||||
|
||||
import logfire
|
||||
from loguru import logger
|
||||
@@ -78,22 +81,126 @@ class SyncService:
|
||||
self.search_service = search_service
|
||||
self.file_service = file_service
|
||||
|
||||
async def sync(self, directory: Path) -> SyncReport:
|
||||
@logfire.instrument(extract_args=False)
|
||||
async def sync(self, directory: Path, show_progress: bool = True) -> SyncReport:
|
||||
"""Sync all files with database."""
|
||||
import time
|
||||
from rich.progress import Progress, TextColumn, BarColumn, TaskProgressColumn
|
||||
|
||||
with logfire.span(f"sync {directory}", directory=directory): # pyright: ignore [reportGeneralTypeIssues]
|
||||
# initial paths from db to sync
|
||||
# path -> checksum
|
||||
report = await self.scan(directory)
|
||||
start_time = time.time()
|
||||
console = None
|
||||
progress = None # Will be initialized if show_progress is True
|
||||
|
||||
# order of sync matters to resolve relations effectively
|
||||
logger.info("Sync operation started", directory=str(directory))
|
||||
|
||||
# initial paths from db to sync
|
||||
# path -> checksum
|
||||
if show_progress:
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
console.print(f"Scanning directory: {directory}")
|
||||
|
||||
report = await self.scan(directory)
|
||||
|
||||
# Initialize progress tracking if requested
|
||||
if show_progress and report.total > 0:
|
||||
progress = Progress(
|
||||
TextColumn("[bold blue]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
console=console,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
# order of sync matters to resolve relations effectively
|
||||
logger.info(
|
||||
"Sync changes detected",
|
||||
new_files=len(report.new),
|
||||
modified_files=len(report.modified),
|
||||
deleted_files=len(report.deleted),
|
||||
moved_files=len(report.moves),
|
||||
)
|
||||
|
||||
if show_progress and report.total > 0:
|
||||
with progress: # pyright: ignore
|
||||
# Track each category separately
|
||||
move_task = None
|
||||
if report.moves: # pragma: no cover
|
||||
move_task = progress.add_task("[blue]Moving files...", total=len(report.moves)) # pyright: ignore
|
||||
|
||||
delete_task = None
|
||||
if report.deleted: # pragma: no cover
|
||||
delete_task = progress.add_task( # pyright: ignore
|
||||
"[red]Deleting files...", total=len(report.deleted)
|
||||
)
|
||||
|
||||
new_task = None
|
||||
if report.new:
|
||||
new_task = progress.add_task( # pyright: ignore
|
||||
"[green]Adding new files...", total=len(report.new)
|
||||
)
|
||||
|
||||
modify_task = None
|
||||
if report.modified: # pragma: no cover
|
||||
modify_task = progress.add_task( # pyright: ignore
|
||||
"[yellow]Updating modified files...", total=len(report.modified)
|
||||
)
|
||||
|
||||
# sync moves first
|
||||
for i, (old_path, new_path) in enumerate(report.moves.items()):
|
||||
# in the case where a file has been deleted and replaced by another file
|
||||
# it will show up in the move and modified lists, so handle it in modified
|
||||
if new_path in report.modified: # pragma: no cover
|
||||
report.modified.remove(new_path)
|
||||
logger.debug(
|
||||
"File marked as moved and modified",
|
||||
old_path=old_path,
|
||||
new_path=new_path,
|
||||
action="processing as modified",
|
||||
)
|
||||
else: # pragma: no cover
|
||||
await self.handle_move(old_path, new_path)
|
||||
|
||||
if move_task is not None: # pragma: no cover
|
||||
progress.update(move_task, advance=1) # pyright: ignore
|
||||
|
||||
# deleted next
|
||||
for i, path in enumerate(report.deleted): # pragma: no cover
|
||||
await self.handle_delete(path)
|
||||
if delete_task is not None: # pragma: no cover
|
||||
progress.update(delete_task, advance=1) # pyright: ignore
|
||||
|
||||
# then new and modified
|
||||
for i, path in enumerate(report.new):
|
||||
await self.sync_file(path, new=True)
|
||||
if new_task is not None:
|
||||
progress.update(new_task, advance=1) # pyright: ignore
|
||||
|
||||
for i, path in enumerate(report.modified): # pragma: no cover
|
||||
await self.sync_file(path, new=False)
|
||||
if modify_task is not None: # pragma: no cover
|
||||
progress.update(modify_task, advance=1) # pyright: ignore
|
||||
|
||||
# Final step - resolving relations
|
||||
if report.total > 0:
|
||||
relation_task = progress.add_task("[cyan]Resolving relations...", total=1) # pyright: ignore
|
||||
await self.resolve_relations()
|
||||
progress.update(relation_task, advance=1) # pyright: ignore
|
||||
else:
|
||||
# No progress display - proceed with normal sync
|
||||
# sync moves first
|
||||
for old_path, new_path in report.moves.items():
|
||||
# in the case where a file has been deleted and replaced by another file
|
||||
# it will show up in the move and modified lists, so handle it in modified
|
||||
if new_path in report.modified:
|
||||
report.modified.remove(new_path)
|
||||
logger.debug(
|
||||
"File marked as moved and modified",
|
||||
old_path=old_path,
|
||||
new_path=new_path,
|
||||
action="processing as modified",
|
||||
)
|
||||
else:
|
||||
await self.handle_move(old_path, new_path)
|
||||
|
||||
@@ -109,7 +216,16 @@ class SyncService:
|
||||
await self.sync_file(path, new=False)
|
||||
|
||||
await self.resolve_relations()
|
||||
return report
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
logger.info(
|
||||
"Sync operation completed",
|
||||
directory=str(directory),
|
||||
total_changes=report.total,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
async def scan(self, directory):
|
||||
"""Scan directory for changes compared to database state."""
|
||||
@@ -167,25 +283,55 @@ class SyncService:
|
||||
db_records = await self.entity_repository.find_all()
|
||||
return {r.file_path: r.checksum or "" for r in db_records}
|
||||
|
||||
async def sync_file(self, path: str, new: bool = True) -> Tuple[Entity, str]:
|
||||
"""Sync a single file."""
|
||||
async def sync_file(
|
||||
self, path: str, new: bool = True
|
||||
) -> Tuple[Optional[Entity], Optional[str]]:
|
||||
"""Sync a single file.
|
||||
|
||||
Args:
|
||||
path: Path to file to sync
|
||||
new: Whether this is a new file
|
||||
|
||||
Returns:
|
||||
Tuple of (entity, checksum) or (None, None) if sync fails
|
||||
"""
|
||||
try:
|
||||
logger.debug(
|
||||
"Syncing file",
|
||||
path=path,
|
||||
is_new=new,
|
||||
is_markdown=self.file_service.is_markdown(path),
|
||||
)
|
||||
|
||||
if self.file_service.is_markdown(path):
|
||||
entity, checksum = await self.sync_markdown_file(path, new)
|
||||
else:
|
||||
entity, checksum = await self.sync_regular_file(path, new)
|
||||
await self.search_service.index_entity(entity)
|
||||
|
||||
if entity is not None:
|
||||
await self.search_service.index_entity(entity)
|
||||
|
||||
logger.debug(
|
||||
"File sync completed", path=path, entity_id=entity.id, checksum=checksum
|
||||
)
|
||||
return entity, checksum
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.exception(f"Failed to sync {path}: {e}")
|
||||
return None, None # pyright: ignore
|
||||
logger.exception("Failed to sync file", path=path, error=str(e))
|
||||
return None, None
|
||||
|
||||
async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Entity, str]:
|
||||
"""Sync a markdown file with full proces sing."""
|
||||
async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]:
|
||||
"""Sync a markdown file with full processing.
|
||||
|
||||
Args:
|
||||
path: Path to markdown file
|
||||
new: Whether this is a new file
|
||||
|
||||
Returns:
|
||||
Tuple of (entity, checksum)
|
||||
"""
|
||||
# Parse markdown first to get any existing permalink
|
||||
logger.debug("Parsing markdown file", path=path)
|
||||
entity_markdown = await self.entity_parser.parse_file(path)
|
||||
|
||||
# Resolve permalink - this handles all the cases including conflicts
|
||||
@@ -193,7 +339,13 @@ class SyncService:
|
||||
|
||||
# If permalink changed, update the file
|
||||
if permalink != entity_markdown.frontmatter.permalink:
|
||||
logger.info(f"Updating permalink in {path}: {permalink}")
|
||||
logger.info(
|
||||
"Updating permalink",
|
||||
path=path,
|
||||
old_permalink=entity_markdown.frontmatter.permalink,
|
||||
new_permalink=permalink,
|
||||
)
|
||||
|
||||
entity_markdown.frontmatter.metadata["permalink"] = permalink
|
||||
checksum = await self.file_service.update_frontmatter(path, {"permalink": permalink})
|
||||
else:
|
||||
@@ -202,12 +354,14 @@ class SyncService:
|
||||
# if the file is new, create an entity
|
||||
if new:
|
||||
# Create entity with final permalink
|
||||
logger.debug(f"Creating new entity from markdown: {path}")
|
||||
logger.debug("Creating new entity from markdown", path=path, permalink=permalink)
|
||||
|
||||
await self.entity_service.create_entity_from_markdown(Path(path), entity_markdown)
|
||||
|
||||
# otherwise we need to update the entity and observations
|
||||
else:
|
||||
logger.debug(f"Updating entity from markdown: {path}")
|
||||
logger.debug("Updating entity from markdown", path=path, permalink=permalink)
|
||||
|
||||
await self.entity_service.update_entity_and_observations(Path(path), entity_markdown)
|
||||
|
||||
# Update relations and search index
|
||||
@@ -215,11 +369,27 @@ class SyncService:
|
||||
|
||||
# set checksum
|
||||
await self.entity_repository.update(entity.id, {"checksum": checksum})
|
||||
|
||||
logger.debug(
|
||||
"Markdown sync completed",
|
||||
path=path,
|
||||
entity_id=entity.id,
|
||||
observation_count=len(entity.observations),
|
||||
relation_count=len(entity.relations),
|
||||
)
|
||||
|
||||
return entity, checksum
|
||||
|
||||
async def sync_regular_file(self, path: str, new: bool = True) -> Tuple[Entity, str]:
|
||||
"""Sync a non-markdown file with basic tracking."""
|
||||
async def sync_regular_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]:
|
||||
"""Sync a non-markdown file with basic tracking.
|
||||
|
||||
Args:
|
||||
path: Path to file
|
||||
new: Whether this is a new file
|
||||
|
||||
Returns:
|
||||
Tuple of (entity, checksum)
|
||||
"""
|
||||
checksum = await self.file_service.compute_checksum(path)
|
||||
if new:
|
||||
# Generate permalink from path
|
||||
@@ -248,11 +418,18 @@ class SyncService:
|
||||
return entity, checksum
|
||||
else:
|
||||
entity = await self.entity_repository.get_by_file_path(path)
|
||||
assert entity is not None, "entity should not be None for existing file"
|
||||
if entity is None: # pragma: no cover
|
||||
logger.error("Entity not found for existing file", path=path)
|
||||
raise ValueError(f"Entity not found for existing file: {path}")
|
||||
|
||||
updated = await self.entity_repository.update(
|
||||
entity.id, {"file_path": path, "checksum": checksum}
|
||||
)
|
||||
assert updated is not None, "entity should be updated"
|
||||
|
||||
if updated is None: # pragma: no cover
|
||||
logger.error("Failed to update entity", entity_id=entity.id, path=path)
|
||||
raise ValueError(f"Failed to update entity with ID {entity.id}")
|
||||
|
||||
return updated, checksum
|
||||
|
||||
async def handle_delete(self, file_path: str):
|
||||
@@ -261,7 +438,12 @@ class SyncService:
|
||||
# First get entity to get permalink before deletion
|
||||
entity = await self.entity_repository.get_by_file_path(file_path)
|
||||
if entity:
|
||||
logger.debug(f"Deleting entity and cleaning up search index: {file_path}")
|
||||
logger.info(
|
||||
"Deleting entity",
|
||||
file_path=file_path,
|
||||
entity_id=entity.id,
|
||||
permalink=entity.permalink,
|
||||
)
|
||||
|
||||
# Delete from db (this cascades to observations/relations)
|
||||
await self.entity_service.delete_entity_by_file_path(file_path)
|
||||
@@ -272,7 +454,14 @@ class SyncService:
|
||||
+ [o.permalink for o in entity.observations]
|
||||
+ [r.permalink for r in entity.relations]
|
||||
)
|
||||
logger.debug(f"Deleting from search index: {permalinks}")
|
||||
|
||||
logger.debug(
|
||||
"Cleaning up search index",
|
||||
entity_id=entity.id,
|
||||
file_path=file_path,
|
||||
index_entries=len(permalinks),
|
||||
)
|
||||
|
||||
for permalink in permalinks:
|
||||
if permalink:
|
||||
await self.search_service.delete_by_permalink(permalink)
|
||||
@@ -280,12 +469,30 @@ class SyncService:
|
||||
await self.search_service.delete_by_entity_id(entity.id)
|
||||
|
||||
async def handle_move(self, old_path, new_path):
|
||||
logger.debug(f"Moving entity: {old_path} -> {new_path}")
|
||||
logger.info("Moving entity", old_path=old_path, new_path=new_path)
|
||||
|
||||
entity = await self.entity_repository.get_by_file_path(old_path)
|
||||
if entity:
|
||||
# Update file_path but keep the same permalink for link stability
|
||||
updated = await self.entity_repository.update(entity.id, {"file_path": new_path})
|
||||
assert updated is not None, "entity should be updated"
|
||||
|
||||
if updated is None: # pragma: no cover
|
||||
logger.error(
|
||||
"Failed to update entity path",
|
||||
entity_id=entity.id,
|
||||
old_path=old_path,
|
||||
new_path=new_path,
|
||||
)
|
||||
raise ValueError(f"Failed to update entity path for ID {entity.id}")
|
||||
|
||||
logger.debug(
|
||||
"Entity path updated",
|
||||
entity_id=entity.id,
|
||||
permalink=entity.permalink,
|
||||
old_path=old_path,
|
||||
new_path=new_path,
|
||||
)
|
||||
|
||||
# update search index
|
||||
await self.search_service.index_entity(updated)
|
||||
|
||||
@@ -293,14 +500,28 @@ class SyncService:
|
||||
"""Try to resolve any unresolved relations"""
|
||||
|
||||
unresolved_relations = await self.relation_repository.find_unresolved_relations()
|
||||
logger.debug(f"Attempting to resolve {len(unresolved_relations)} forward references")
|
||||
|
||||
logger.info("Resolving forward references", count=len(unresolved_relations))
|
||||
|
||||
for relation in unresolved_relations:
|
||||
logger.debug(
|
||||
"Attempting to resolve relation",
|
||||
relation_id=relation.id,
|
||||
from_id=relation.from_id,
|
||||
to_name=relation.to_name,
|
||||
)
|
||||
|
||||
resolved_entity = await self.entity_service.link_resolver.resolve_link(relation.to_name)
|
||||
|
||||
# ignore reference to self
|
||||
if resolved_entity and resolved_entity.id != relation.from_id:
|
||||
logger.debug(
|
||||
f"Resolved forward reference: {relation.to_name} -> {resolved_entity.title}"
|
||||
"Resolved forward reference",
|
||||
relation_id=relation.id,
|
||||
from_id=relation.from_id,
|
||||
to_name=relation.to_name,
|
||||
resolved_id=resolved_entity.id,
|
||||
resolved_title=resolved_entity.title,
|
||||
)
|
||||
try:
|
||||
await self.relation_repository.update(
|
||||
@@ -311,7 +532,12 @@ class SyncService:
|
||||
},
|
||||
)
|
||||
except IntegrityError: # pragma: no cover
|
||||
logger.debug(f"Ignoring duplicate relation {relation}")
|
||||
logger.debug(
|
||||
"Ignoring duplicate relation",
|
||||
relation_id=relation.id,
|
||||
from_id=relation.from_id,
|
||||
to_name=relation.to_name,
|
||||
)
|
||||
|
||||
# update search index
|
||||
await self.search_service.index_entity(resolved_entity)
|
||||
@@ -326,8 +552,11 @@ class SyncService:
|
||||
Returns:
|
||||
ScanResult containing found files and any errors
|
||||
"""
|
||||
import time
|
||||
|
||||
logger.debug(f"Scanning directory: {directory}")
|
||||
start_time = time.time()
|
||||
|
||||
logger.debug("Scanning directory", directory=str(directory))
|
||||
result = ScanResult()
|
||||
|
||||
for root, dirnames, filenames in os.walk(str(directory)):
|
||||
@@ -344,6 +573,15 @@ class SyncService:
|
||||
checksum = await self.file_service.compute_checksum(rel_path)
|
||||
result.files[rel_path] = checksum
|
||||
result.checksums[checksum] = rel_path
|
||||
logger.debug(f"Found file: {rel_path} with checksum: {checksum}")
|
||||
|
||||
logger.debug("Found file", path=rel_path, checksum=checksum)
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
logger.debug(
|
||||
"Directory scan completed",
|
||||
directory=str(directory),
|
||||
files_found=len(result.files),
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Watch service for Basic Memory."""
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -29,8 +28,8 @@ class WatchEvent(BaseModel):
|
||||
class WatchServiceState(BaseModel):
|
||||
# Service status
|
||||
running: bool = False
|
||||
start_time: datetime = dataclasses.field(default_factory=datetime.now)
|
||||
pid: int = dataclasses.field(default_factory=os.getpid)
|
||||
start_time: datetime = datetime.now() # Use directly with Pydantic model
|
||||
pid: int = os.getpid() # Use directly with Pydantic model
|
||||
|
||||
# Stats
|
||||
error_count: int = 0
|
||||
@@ -41,7 +40,7 @@ class WatchServiceState(BaseModel):
|
||||
synced_files: int = 0
|
||||
|
||||
# Recent activity
|
||||
recent_events: List[WatchEvent] = dataclasses.field(default_factory=list)
|
||||
recent_events: List[WatchEvent] = [] # Use directly with Pydantic model
|
||||
|
||||
def add_event(
|
||||
self,
|
||||
@@ -81,10 +80,17 @@ class WatchService:
|
||||
|
||||
async def run(self): # pragma: no cover
|
||||
"""Watch for file changes and sync them"""
|
||||
logger.info("Watching for sync changes")
|
||||
logger.info(
|
||||
"Watch service started",
|
||||
directory=str(self.config.home),
|
||||
debounce_ms=self.config.sync_delay,
|
||||
pid=os.getpid(),
|
||||
)
|
||||
|
||||
self.state.running = True
|
||||
self.state.start_time = datetime.now()
|
||||
await self.write_status()
|
||||
|
||||
try:
|
||||
async for changes in awatch(
|
||||
self.config.home,
|
||||
@@ -95,14 +101,23 @@ class WatchService:
|
||||
await self.handle_changes(self.config.home, changes)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Watch service error", error=str(e), directory=str(self.config.home))
|
||||
|
||||
self.state.record_error(str(e))
|
||||
await self.write_status()
|
||||
raise
|
||||
|
||||
finally:
|
||||
logger.info(
|
||||
"Watch service stopped",
|
||||
directory=str(self.config.home),
|
||||
runtime_seconds=int((datetime.now() - self.state.start_time).total_seconds()),
|
||||
)
|
||||
|
||||
self.state.running = False
|
||||
await self.write_status()
|
||||
|
||||
def filter_changes(self, change: Change, path: str) -> bool:
|
||||
def filter_changes(self, change: Change, path: str) -> bool: # pragma: no cover
|
||||
"""Filter to only watch non-hidden files and directories.
|
||||
|
||||
Returns:
|
||||
@@ -112,6 +127,7 @@ class WatchService:
|
||||
try:
|
||||
relative_path = Path(path).relative_to(self.config.home)
|
||||
except ValueError:
|
||||
# This is a defensive check for paths outside our home directory
|
||||
return False
|
||||
|
||||
# Skip hidden directories and files
|
||||
@@ -128,12 +144,17 @@ class WatchService:
|
||||
|
||||
async def handle_changes(self, directory: Path, changes: Set[FileChange]):
|
||||
"""Process a batch of file changes"""
|
||||
logger.debug(f"handling {len(changes)} changes in directory: {directory} ...")
|
||||
import time
|
||||
from typing import List, Set
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
logger.info("Processing file changes", change_count=len(changes), directory=str(directory))
|
||||
|
||||
# Group changes by type
|
||||
adds = []
|
||||
deletes = []
|
||||
modifies = []
|
||||
adds: List[str] = []
|
||||
deletes: List[str] = []
|
||||
modifies: List[str] = []
|
||||
|
||||
for change, path in changes:
|
||||
# convert to relative path
|
||||
@@ -145,25 +166,44 @@ class WatchService:
|
||||
elif change == Change.modified:
|
||||
modifies.append(relative_path)
|
||||
|
||||
logger.debug(
|
||||
"Grouped file changes", added=len(adds), deleted=len(deletes), modified=len(modifies)
|
||||
)
|
||||
|
||||
# Track processed files to avoid duplicates
|
||||
processed = set()
|
||||
processed: Set[str] = set()
|
||||
|
||||
# First handle potential moves
|
||||
for added_path in adds:
|
||||
if added_path in processed:
|
||||
continue # pragma: no cover
|
||||
|
||||
# Skip directories for added paths
|
||||
# We don't need to process directories, only the files inside them
|
||||
# This prevents errors when trying to compute checksums or read directories as files
|
||||
added_full_path = directory / added_path
|
||||
if added_full_path.is_dir():
|
||||
logger.debug("Skipping directory for move detection", path=added_path)
|
||||
processed.add(added_path)
|
||||
continue
|
||||
|
||||
for deleted_path in deletes:
|
||||
if deleted_path in processed:
|
||||
continue # pragma: no cover
|
||||
|
||||
# Skip directories for deleted paths (based on entity type in db)
|
||||
deleted_entity = await self.sync_service.entity_repository.get_by_file_path(
|
||||
deleted_path
|
||||
)
|
||||
if deleted_entity is None:
|
||||
# If this was a directory, it wouldn't have an entity
|
||||
logger.debug("Skipping unknown path for move detection", path=deleted_path)
|
||||
continue
|
||||
|
||||
if added_path != deleted_path:
|
||||
# Compare checksums to detect moves
|
||||
try:
|
||||
added_checksum = await self.file_service.compute_checksum(added_path)
|
||||
deleted_entity = await self.sync_service.entity_repository.get_by_file_path(
|
||||
deleted_path
|
||||
)
|
||||
|
||||
if deleted_entity and deleted_entity.checksum == added_checksum:
|
||||
await self.sync_service.handle_move(deleted_path, added_path)
|
||||
@@ -172,48 +212,131 @@ class WatchService:
|
||||
action="moved",
|
||||
status="success",
|
||||
)
|
||||
self.console.print(
|
||||
f"[blue]→[/blue] Moved: {deleted_path} → {added_path}"
|
||||
)
|
||||
self.console.print(f"[blue]→[/blue] {deleted_path} → {added_path}")
|
||||
processed.add(added_path)
|
||||
processed.add(deleted_path)
|
||||
break
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(f"Error checking for move: {e}")
|
||||
logger.warning(
|
||||
"Error checking for move",
|
||||
old_path=deleted_path,
|
||||
new_path=added_path,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# Handle remaining changes
|
||||
# Handle remaining changes - group them by type for concise output
|
||||
moved_count = len([p for p in processed if p in deletes or p in adds])
|
||||
delete_count = 0
|
||||
add_count = 0
|
||||
modify_count = 0
|
||||
|
||||
# Process deletes
|
||||
for path in deletes:
|
||||
if path not in processed:
|
||||
logger.debug("Processing deleted file", path=path)
|
||||
await self.sync_service.handle_delete(path)
|
||||
self.state.add_event(path=path, action="deleted", status="success")
|
||||
self.console.print(f"[red]✕[/red] Deleted: {path}")
|
||||
self.console.print(f"[red]✕[/red] {path}")
|
||||
processed.add(path)
|
||||
delete_count += 1
|
||||
|
||||
# Process adds
|
||||
for path in adds:
|
||||
if path not in processed:
|
||||
_, checksum = await self.sync_service.sync_file(path, new=True)
|
||||
# Skip directories - only process files
|
||||
full_path = directory / path
|
||||
if full_path.is_dir(): # pragma: no cover
|
||||
logger.debug("Skipping directory", path=path)
|
||||
processed.add(path)
|
||||
continue
|
||||
|
||||
logger.debug("Processing new file", path=path)
|
||||
entity, checksum = await self.sync_service.sync_file(path, new=True)
|
||||
if checksum:
|
||||
self.state.add_event(
|
||||
path=path, action="new", status="success", checksum=checksum
|
||||
)
|
||||
self.console.print(f"[green]✓[/green] Added: {path}")
|
||||
self.console.print(f"[green]✓[/green] {path}")
|
||||
logger.debug(
|
||||
"Added file processed",
|
||||
path=path,
|
||||
entity_id=entity.id if entity else None,
|
||||
checksum=checksum,
|
||||
)
|
||||
processed.add(path)
|
||||
else:
|
||||
self.console.print(f"[orange]?[/orange] Error syncing: {path}")
|
||||
add_count += 1
|
||||
else: # pragma: no cover
|
||||
logger.warning("Error syncing new file", path=path) # pragma: no cover
|
||||
self.console.print(
|
||||
f"[orange]?[/orange] Error syncing: {path}"
|
||||
) # pragma: no cover
|
||||
|
||||
# Process modifies - detect repeats
|
||||
last_modified_path = None
|
||||
repeat_count = 0
|
||||
|
||||
for path in modifies:
|
||||
if path not in processed:
|
||||
_, checksum = await self.sync_service.sync_file(path, new=False)
|
||||
# Skip directories - only process files
|
||||
full_path = directory / path
|
||||
if full_path.is_dir():
|
||||
logger.debug("Skipping directory", path=path)
|
||||
processed.add(path)
|
||||
continue
|
||||
|
||||
logger.debug("Processing modified file", path=path)
|
||||
entity, checksum = await self.sync_service.sync_file(path, new=False)
|
||||
self.state.add_event(
|
||||
path=path, action="modified", status="success", checksum=checksum
|
||||
)
|
||||
self.console.print(f"[yellow]✎[/yellow] Modified: {path}")
|
||||
|
||||
# Check if this is a repeat of the last modified file
|
||||
if path == last_modified_path: # pragma: no cover
|
||||
repeat_count += 1 # pragma: no cover
|
||||
# Only show a message for the first repeat
|
||||
if repeat_count == 1: # pragma: no cover
|
||||
self.console.print(
|
||||
f"[yellow]...[/yellow] Repeated changes to {path}"
|
||||
) # pragma: no cover
|
||||
else:
|
||||
# New file being modified
|
||||
self.console.print(f"[yellow]✎[/yellow] {path}")
|
||||
last_modified_path = path
|
||||
repeat_count = 0
|
||||
modify_count += 1
|
||||
|
||||
logger.debug(
|
||||
"Modified file processed",
|
||||
path=path,
|
||||
entity_id=entity.id if entity else None,
|
||||
checksum=checksum,
|
||||
)
|
||||
processed.add(path)
|
||||
|
||||
# Add a divider if we processed any files
|
||||
# Add a concise summary instead of a divider
|
||||
if processed:
|
||||
self.console.print("─" * 80, style="dim")
|
||||
changes = [] # pyright: ignore
|
||||
if add_count > 0:
|
||||
changes.append(f"[green]{add_count} added[/green]") # pyright: ignore
|
||||
if modify_count > 0:
|
||||
changes.append(f"[yellow]{modify_count} modified[/yellow]") # pyright: ignore
|
||||
if moved_count > 0:
|
||||
changes.append(f"[blue]{moved_count} moved[/blue]") # pyright: ignore
|
||||
if delete_count > 0:
|
||||
changes.append(f"[red]{delete_count} deleted[/red]") # pyright: ignore
|
||||
|
||||
if changes:
|
||||
self.console.print(f"{', '.join(changes)}", style="dim") # pyright: ignore
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
self.state.last_scan = datetime.now()
|
||||
self.state.synced_files += len(processed)
|
||||
|
||||
logger.info(
|
||||
"File change processing completed",
|
||||
processed_files=len(processed),
|
||||
total_synced_files=self.state.synced_files,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
await self.write_status()
|
||||
|
||||
Reference in New Issue
Block a user