First working, tray version
This commit is contained in:
222
src/core/file_watcher.py
Normal file
222
src/core/file_watcher.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""File watcher module for detecting changes in vault mount point.
|
||||
|
||||
Uses watchdog library with inotify backend on Linux.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from watchdog.events import (
|
||||
DirCreatedEvent,
|
||||
DirDeletedEvent,
|
||||
DirMovedEvent,
|
||||
FileClosedEvent,
|
||||
FileCreatedEvent,
|
||||
FileDeletedEvent,
|
||||
FileModifiedEvent,
|
||||
FileMovedEvent,
|
||||
FileSystemEvent,
|
||||
FileSystemEventHandler,
|
||||
)
|
||||
from watchdog.observers import Observer
|
||||
|
||||
|
||||
class EventType(Enum):
|
||||
"""Types of file system events."""
|
||||
|
||||
CREATED = "created"
|
||||
MODIFIED = "modified"
|
||||
DELETED = "deleted"
|
||||
MOVED = "moved"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileEvent:
|
||||
"""Represents a file system event."""
|
||||
|
||||
event_type: EventType
|
||||
path: str
|
||||
is_directory: bool
|
||||
dest_path: str | None = None # Only for MOVED events
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.event_type == EventType.MOVED:
|
||||
return f"{self.event_type.value}: {self.path} -> {self.dest_path}"
|
||||
return f"{self.event_type.value}: {self.path}"
|
||||
|
||||
|
||||
# Type alias for file event callbacks
|
||||
FileEventCallback = Callable[[FileEvent], None]
|
||||
|
||||
|
||||
def _ensure_str(path: str | bytes) -> str:
|
||||
"""Convert bytes path to str if necessary."""
|
||||
if isinstance(path, bytes):
|
||||
return path.decode("utf-8", errors="replace")
|
||||
return path
|
||||
|
||||
|
||||
class VaultEventHandler(FileSystemEventHandler):
|
||||
"""Handles file system events and converts them to FileEvent objects."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_path: Path,
|
||||
callback: FileEventCallback,
|
||||
ignore_patterns: list[str] | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.base_path = base_path
|
||||
self.callback = callback
|
||||
self.ignore_patterns = ignore_patterns or [".vault"]
|
||||
|
||||
def _should_ignore(self, path: str) -> bool:
|
||||
"""Check if path should be ignored."""
|
||||
rel_path = Path(path).relative_to(self.base_path)
|
||||
for pattern in self.ignore_patterns:
|
||||
if pattern in rel_path.parts:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_relative_path(self, path: str) -> str:
|
||||
"""Convert absolute path to relative path from base."""
|
||||
return str(Path(path).relative_to(self.base_path))
|
||||
|
||||
def _emit_event(
|
||||
self,
|
||||
event_type: EventType,
|
||||
src_path: str,
|
||||
is_directory: bool,
|
||||
dest_path: str | None = None,
|
||||
) -> None:
|
||||
"""Create and emit a FileEvent."""
|
||||
if self._should_ignore(src_path):
|
||||
return
|
||||
|
||||
rel_path = self._get_relative_path(src_path)
|
||||
rel_dest = self._get_relative_path(dest_path) if dest_path else None
|
||||
|
||||
file_event = FileEvent(
|
||||
event_type=event_type,
|
||||
path=rel_path,
|
||||
is_directory=is_directory,
|
||||
dest_path=rel_dest,
|
||||
)
|
||||
|
||||
logger.debug(f"File event: {file_event}")
|
||||
self.callback(file_event)
|
||||
|
||||
def on_created(self, event: FileSystemEvent) -> None:
|
||||
"""Handle file/directory creation."""
|
||||
if isinstance(event, (FileCreatedEvent, DirCreatedEvent)):
|
||||
self._emit_event(
|
||||
EventType.CREATED,
|
||||
_ensure_str(event.src_path),
|
||||
event.is_directory,
|
||||
)
|
||||
|
||||
def on_modified(self, event: FileSystemEvent) -> None:
|
||||
"""Handle file modification."""
|
||||
# Only track file modifications, not directory modifications
|
||||
if isinstance(event, FileModifiedEvent) and not event.is_directory:
|
||||
self._emit_event(
|
||||
EventType.MODIFIED,
|
||||
_ensure_str(event.src_path),
|
||||
is_directory=False,
|
||||
)
|
||||
|
||||
def on_deleted(self, event: FileSystemEvent) -> None:
|
||||
"""Handle file/directory deletion."""
|
||||
if isinstance(event, (FileDeletedEvent, DirDeletedEvent)):
|
||||
self._emit_event(
|
||||
EventType.DELETED,
|
||||
_ensure_str(event.src_path),
|
||||
event.is_directory,
|
||||
)
|
||||
|
||||
def on_moved(self, event: FileSystemEvent) -> None:
|
||||
"""Handle file/directory move/rename."""
|
||||
if isinstance(event, (FileMovedEvent, DirMovedEvent)):
|
||||
self._emit_event(
|
||||
EventType.MOVED,
|
||||
_ensure_str(event.src_path),
|
||||
event.is_directory,
|
||||
dest_path=_ensure_str(event.dest_path),
|
||||
)
|
||||
|
||||
def on_closed(self, event: FileSystemEvent) -> None:
|
||||
"""Handle file close - emit as modified for write operations."""
|
||||
# FileClosedEvent indicates a file was closed after writing
|
||||
# This is more reliable than on_modified for detecting actual saves
|
||||
if isinstance(event, FileClosedEvent) and not event.is_directory:
|
||||
# We emit MODIFIED because the file content was finalized
|
||||
self._emit_event(
|
||||
EventType.MODIFIED,
|
||||
_ensure_str(event.src_path),
|
||||
is_directory=False,
|
||||
)
|
||||
|
||||
|
||||
class FileWatcher:
|
||||
"""Watches a directory for file system changes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
watch_path: Path,
|
||||
callback: FileEventCallback,
|
||||
ignore_patterns: list[str] | None = None,
|
||||
) -> None:
|
||||
self.watch_path = watch_path
|
||||
self.callback = callback
|
||||
self.ignore_patterns = ignore_patterns or [".vault"]
|
||||
self._observer: Any = None
|
||||
self._running = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start watching for file system events."""
|
||||
if self._running:
|
||||
logger.warning("FileWatcher already running")
|
||||
return
|
||||
|
||||
if not self.watch_path.exists():
|
||||
raise FileNotFoundError(f"Watch path does not exist: {self.watch_path}")
|
||||
|
||||
handler = VaultEventHandler(
|
||||
base_path=self.watch_path,
|
||||
callback=self.callback,
|
||||
ignore_patterns=self.ignore_patterns,
|
||||
)
|
||||
|
||||
self._observer = Observer()
|
||||
self._observer.schedule(handler, str(self.watch_path), recursive=True)
|
||||
self._observer.start()
|
||||
self._running = True
|
||||
|
||||
logger.info(f"FileWatcher started for: {self.watch_path}")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop watching for file system events."""
|
||||
if not self._running or self._observer is None:
|
||||
return
|
||||
|
||||
self._observer.stop()
|
||||
self._observer.join(timeout=5.0)
|
||||
self._observer = None
|
||||
self._running = False
|
||||
|
||||
logger.info("FileWatcher stopped")
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if watcher is currently running."""
|
||||
return self._running
|
||||
|
||||
def __enter__(self) -> "FileWatcher":
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: type | None, exc_val: Exception | None, exc_tb: object) -> None:
|
||||
self.stop()
|
||||
Reference in New Issue
Block a user