diff --git a/pyproject.toml b/pyproject.toml index ecd76c96..9815bbb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "fastapi[standard]>=0.115.8", "alembic>=1.14.1", "rumps>=0.4.0", + "qasync>=0.27.1", ] [project.urls] diff --git a/src/basic_memory/cli/commands/sync.py b/src/basic_memory/cli/commands/sync.py index 3b2a35cf..c100e75b 100644 --- a/src/basic_memory/cli/commands/sync.py +++ b/src/basic_memory/cli/commands/sync.py @@ -97,51 +97,7 @@ def group_issues_by_directory(issues: List[ValidationIssue]) -> Dict[str, List[V return dict(grouped) -def display_validation_errors(issues: List[ValidationIssue]): - """Display validation errors in a rich, organized format.""" - # Create header - console.print() - console.print( - Panel("[red bold]Error:[/red bold] Invalid frontmatter in knowledge files", expand=False) - ) - console.print() - # Group issues by directory - grouped_issues = group_issues_by_directory(issues) - - # Create tree structure - tree = Tree("Knowledge Files") - for dir_name, dir_issues in sorted(grouped_issues.items()): - # Create branch for directory - branch = tree.add( - f"[bold blue]{dir_name}/[/bold blue] ([yellow]{len(dir_issues)} files[/yellow])" - ) - - # Add each file issue - for issue in sorted(dir_issues, key=lambda x: x.file_path): - file_name = Path(issue.file_path).name - branch.add( - Text.assemble(("└─ ", "dim"), (file_name, "yellow"), ": ", (issue.error, "red")) - ) - - # Display tree - console.print(Padding(tree, (1, 2))) - - # Add help text - console.print() - console.print( - Panel( - Text.assemble( - ("To fix:", "bold"), - "\n1. Add required frontmatter fields to each file", - "\n2. Run ", - ("basic-memory sync", "bold cyan"), - " again", - ), - expand=False, - ) - ) - console.print() def display_sync_summary(knowledge: SyncReport): diff --git a/src/basic_memory/sync/__init__.py b/src/basic_memory/sync/__init__.py index 024c0cc2..c108df5d 100644 --- a/src/basic_memory/sync/__init__.py +++ b/src/basic_memory/sync/__init__.py @@ -1,5 +1,7 @@ from .file_change_scanner import FileChangeScanner from .sync_service import SyncService +from .watch_service import WatchService + +__all__ = ["SyncService", "FileChangeScanner", "WatchService"] -__all__ = ["SyncService", "FileChangeScanner"] diff --git a/src/basic_memory/sync/watch_service.py b/src/basic_memory/sync/watch_service.py index 643e4b0f..728bbec1 100644 --- a/src/basic_memory/sync/watch_service.py +++ b/src/basic_memory/sync/watch_service.py @@ -131,13 +131,36 @@ class WatchService: return table - async def run(self): + async def run(self, console_status: bool = False): """Watch for file changes and sync them""" + logger.info("Watching for sync changes") self.state.running = True self.state.start_time = datetime.now() await self.write_status() - with Live(self.generate_table(), refresh_per_second=4, console=self.console) as live: + if console_status: + with Live(self.generate_table(), refresh_per_second=4, console=self.console) as live: + try: + async for changes in awatch( + self.config.home, + watch_filter=self.filter_changes, + debounce=self.config.sync_delay, + recursive=True, + ): + # Process changes + await self.handle_changes(self.config.home) + # Update display + live.update(self.generate_table()) + + except Exception as e: + self.state.record_error(str(e)) + await self.write_status() + raise + finally: + self.state.running = False + await self.write_status() + + else: try: async for changes in awatch( self.config.home, @@ -148,7 +171,6 @@ class WatchService: # Process changes await self.handle_changes(self.config.home) # Update display - live.update(self.generate_table()) except Exception as e: self.state.record_error(str(e)) diff --git a/src/basic_memory/ui/menubar/app.py b/src/basic_memory/ui/menubar/app.py index 0466740b..8135b2b3 100644 --- a/src/basic_memory/ui/menubar/app.py +++ b/src/basic_memory/ui/menubar/app.py @@ -1,47 +1,115 @@ -"""Basic Memory menubar app using rumps.""" -import rumps +"""Basic Memory menubar app using PyQt.""" +import sys +import asyncio +from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QStyle +from PyQt6.QtCore import QTimer +from PyQt6.QtGui import QIcon from loguru import logger +import qasync -class BasicMemoryApp(rumps.App): +from basic_memory.config import config +from basic_memory.cli.commands.sync import get_sync_service +from basic_memory.sync.watch_service import WatchService + +class BasicMemoryTray(QSystemTrayIcon): def __init__(self): - super().__init__("Basic Memory", "✓") + super().__init__() - # Initialize menu structure - self.menu = [ - rumps.MenuItem("Status: Idle"), - None, # separator - rumps.MenuItem("Recent Events"), - None, - ["Settings", - "Choose Home Directory...", - "Start at Login" - ], - None, - "Start Sync", - ] + # Set default icon + self.setIcon(QApplication.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon)) + + # Create menu + menu = QMenu() + self.status_item = menu.addAction("Status: Idle") + self.status_item.setEnabled(False) + + menu.addSeparator() + self.sync_action = menu.addAction("Start Sync") + self.sync_action.triggered.connect(self.toggle_sync) + + menu.addSeparator() + quit_action = menu.addAction("Quit") + quit_action.triggered.connect(QApplication.instance().quit) + + self.setContextMenu(menu) + self.show() - @rumps.clicked("Start Sync") - def toggle_sync(self, _): - """Toggle sync on/off.""" - sender = self.menu["Start Sync"] - if sender.title == "Start Sync": - sender.title = "Stop Sync" - self.icon = "↻" - # TODO: Start sync process + # Initialize sync state + self.watch_service = None + self.sync_task = None + self.is_syncing = False + + # Update timer for status + self.status_timer = QTimer() + self.status_timer.timeout.connect(self.update_status) + self.status_timer.start(1000) # Update every second + + logger.info("Basic Memory tray initialized") + + def toggle_sync(self): + """Start or stop sync process.""" + if not self.is_syncing: + logger.info("Starting sync") + self.start_sync() + self.sync_action.setText("Stop Sync") + self.is_syncing = True else: - sender.title = "Start Sync" - self.icon = "✓" - # TODO: Stop sync process + logger.info("Stopping sync") + self.stop_sync() + self.sync_action.setText("Start Sync") + self.is_syncing = False - @rumps.clicked("Settings", "Choose Home Directory...") - def choose_home_dir(self, _): - """Open dialog to choose home directory.""" - # TODO: Implement directory chooser - logger.info("Choose home directory clicked") + def start_sync(self): + """Start the sync service.""" + async def run_sync(): + try: + sync_service = await get_sync_service() + self.watch_service = WatchService( + sync_service=sync_service, + file_service=sync_service.entity_service.file_service, + config=config + ) + await self.watch_service.run(console_status=False) + except Exception as e: + logger.exception("Error in sync service") + self.status_item.setText(f"Status: Error - {str(e)}") + + # Start sync using the event loop + loop = asyncio.get_event_loop() + self.sync_task = loop.create_task(run_sync()) + + def stop_sync(self): + """Stop the sync service.""" + if self.watch_service: + self.watch_service.state.running = False + self.watch_service = None + if self.sync_task and not self.sync_task.done(): + self.sync_task.cancel() + + def update_status(self): + """Update status display.""" + if self.watch_service: + state = self.watch_service.state + status = "Running" if state.running else "Stopped" + last_scan = state.last_scan.strftime("%H:%M:%S") if state.last_scan else "-" + self.status_item.setText(f"Status: {status} (Last scan: {last_scan})") + else: + self.status_item.setText("Status: Idle") def main(): """Run the menubar app.""" - BasicMemoryApp().run() + app = QApplication(sys.argv) + + # Create event loop + loop = qasync.QEventLoop(app) + asyncio.set_event_loop(loop) + + # Create tray + tray = BasicMemoryTray() + + # Run event loop + with loop: + loop.run_forever() if __name__ == "__main__": main() diff --git a/uv.lock b/uv.lock index 4c407134..777073f7 100644 --- a/uv.lock +++ b/uv.lock @@ -87,6 +87,7 @@ dependencies = [ { name = "pyright" }, { name = "python-frontmatter" }, { name = "pyyaml" }, + { name = "qasync" }, { name = "rich" }, { name = "rumps" }, { name = "sqlalchemy" }, @@ -123,6 +124,7 @@ requires-dist = [ { name = "pyright", specifier = ">=1.1.390" }, { name = "python-frontmatter", specifier = ">=1.1.0" }, { name = "pyyaml", specifier = ">=6.0.1" }, + { name = "qasync", specifier = ">=0.27.1" }, { name = "rich", specifier = ">=13.9.4" }, { name = "rumps", specifier = ">=0.4.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, @@ -978,6 +980,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] +[[package]] +name = "qasync" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/e0/7c7c973f52e1765d6ddfc41e9272294f65d5d52b8f5f5eae92adf411ad46/qasync-0.27.1.tar.gz", hash = "sha256:8dc768fd1ee5de1044c7c305eccf2d39d24d87803ea71189d4024fb475f4985f", size = 14287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/06/bc628aa2981bcfd452a08ee435b812fd3eee4ada8acb8a76c4a09d1a5a77/qasync-0.27.1-py3-none-any.whl", hash = "sha256:5d57335723bc7d9b328dadd8cb2ed7978640e4bf2da184889ce50ee3ad2602c7", size = 14866 }, +] + [[package]] name = "regex" version = "2024.11.6"