"""TUI dashboard for sync progress with scrollable logs.""" from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, Vertical from textual.widgets import ( Header, Footer, Static, ProgressBar, Log, ListView, ListItem, Label, ) from textual.reactive import reactive from textual.binding import Binding from rich.text import Text from datetime import datetime, timedelta import asyncio import os import sys import time from typing import Dict, Any, Optional, List, Callable from pathlib import Path # Default sync interval in seconds (5 minutes) DEFAULT_SYNC_INTERVAL = 300 # Futuristic spinner frames # SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] # Alternative spinners you could use: # SPINNER_FRAMES = ["◢", "◣", "◤", "◥"] # Rotating triangle SPINNER_FRAMES = [ "▰▱▱▱▱", "▰▰▱▱▱", "▰▰▰▱▱", "▰▰▰▰▱", "▰▰▰▰▰", "▱▰▰▰▰", "▱▱▰▰▰", "▱▱▱▰▰", "▱▱▱▱▰", ] # Loading bar # SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"] # Braille dots # SPINNER_FRAMES = ["◐", "◓", "◑", "◒"] # Circle quarters # SPINNER_FRAMES = ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"] # Braille orbit # Sync configuration defaults DEFAULT_SYNC_CONFIG = { "dry_run": False, "vdir": "~/Calendar", "icsfile": None, "org": "corteva", "days_back": 1, "days_forward": 30, "continue_iteration": False, "download_attachments": False, "two_way_calendar": False, "notify": True, } class TaskStatus: """Status constants for tasks.""" PENDING = "pending" RUNNING = "running" COMPLETED = "completed" ERROR = "error" class TaskListItem(ListItem): """A list item representing a sync task.""" def __init__(self, task_id: str, task_name: str, *args, **kwargs): super().__init__(*args, **kwargs) self.task_id = task_id self.task_name = task_name self.status = TaskStatus.PENDING self.progress = 0 self.total = 100 self.spinner_frame = 0 def compose(self) -> ComposeResult: """Compose the task item layout.""" yield Static(self._build_content_text(), id=f"task-content-{self.task_id}") def _get_status_icon(self) -> str: """Get icon based on status.""" if self.status == TaskStatus.RUNNING: return SPINNER_FRAMES[self.spinner_frame % len(SPINNER_FRAMES)] icons = { TaskStatus.PENDING: "○", TaskStatus.COMPLETED: "✓", TaskStatus.ERROR: "✗", } return icons.get(self.status, "○") def advance_spinner(self) -> None: """Advance the spinner to the next frame.""" self.spinner_frame = (self.spinner_frame + 1) % len(SPINNER_FRAMES) def _get_status_color(self) -> str: """Get color based on status.""" colors = { TaskStatus.PENDING: "dim", TaskStatus.RUNNING: "cyan", TaskStatus.COMPLETED: "bright_white", TaskStatus.ERROR: "red", } return colors.get(self.status, "white") def _build_content_text(self) -> Text: """Build the task content text.""" icon = self._get_status_icon() color = self._get_status_color() # Use green checkmark for completed, but white text for readability if self.status == TaskStatus.RUNNING: progress_pct = ( int((self.progress / self.total) * 100) if self.total > 0 else 0 ) text = Text() text.append(f"{icon} ", style="cyan") text.append(f"{self.task_name} [{progress_pct}%]", style=color) return text elif self.status == TaskStatus.COMPLETED: text = Text() text.append(f"{icon} ", style="green") # Green checkmark text.append(f"{self.task_name} [Done]", style=color) return text elif self.status == TaskStatus.ERROR: text = Text() text.append(f"{icon} ", style="red") text.append(f"{self.task_name} [Error]", style=color) return text else: return Text(f"{icon} {self.task_name}", style=color) def update_display(self) -> None: """Update the display of this item.""" try: content = self.query_one(f"#task-content-{self.task_id}", Static) content.update(self._build_content_text()) except Exception: pass class SyncDashboard(App): """TUI dashboard for sync operations.""" BINDINGS = [ Binding("q", "quit", "Quit"), Binding("ctrl+c", "quit", "Quit"), Binding("s", "sync_now", "Sync Now"), Binding("d", "daemonize", "Daemonize"), Binding("r", "refresh", "Refresh"), Binding("+", "increase_interval", "+Interval"), Binding("-", "decrease_interval", "-Interval"), Binding("up", "cursor_up", "Up", show=False), Binding("down", "cursor_down", "Down", show=False), ] CSS = """ .dashboard { height: 100%; layout: horizontal; } .sidebar { width: 30; height: 100%; border: solid $primary; padding: 0; } .sidebar-title { text-style: bold; padding: 1; background: $primary-darken-2; } .countdown-container { height: 5; padding: 0 1; border-top: solid $primary; background: $surface; } .countdown-text { text-align: center; } .daemon-status { text-align: center; color: $text-muted; } .daemon-running { color: $success; } .main-panel { width: 1fr; height: 100%; padding: 0; } .task-header { height: 5; padding: 1; border-bottom: solid $primary; } .task-name { text-style: bold; } .progress-row { height: 3; padding: 0 1; } .log-container { height: 1fr; border: solid $primary; padding: 0; } .log-title { padding: 0 1; background: $primary-darken-2; } ListView { height: 1fr; } ListItem { padding: 0 1; } ListItem:hover { background: $primary-darken-1; } Log { height: 1fr; border: none; } ProgressBar { width: 1fr; padding: 0 1; } """ selected_task: reactive[str] = reactive("archive") sync_interval: reactive[int] = reactive(DEFAULT_SYNC_INTERVAL) next_sync_time: reactive[float] = reactive(0.0) def __init__( self, sync_interval: int = DEFAULT_SYNC_INTERVAL, notify: bool = True, sync_config: Optional[Dict[str, Any]] = None, demo_mode: bool = False, ): super().__init__() self._mounted: asyncio.Event = asyncio.Event() self._task_logs: Dict[str, List[str]] = {} self._task_items: Dict[str, TaskListItem] = {} self._sync_callback: Optional[Callable] = None self._countdown_task: Optional[asyncio.Task] = None self._spinner_task: Optional[asyncio.Task] = None self._initial_sync_interval = sync_interval self._notify = notify self._demo_mode = demo_mode # Merge provided config with defaults self._sync_config = {**DEFAULT_SYNC_CONFIG, **(sync_config or {})} self._sync_config["notify"] = notify def compose(self) -> ComposeResult: """Compose the dashboard layout.""" yield Header() with Horizontal(classes="dashboard"): # Sidebar with task list with Vertical(classes="sidebar"): yield Static("Tasks", classes="sidebar-title") yield ListView( # Stage 1: Sync local changes to server TaskListItem("archive", "Archive Mail", id="task-archive"), TaskListItem("outbox", "Outbox Send", id="task-outbox"), # Stage 2: Fetch from server TaskListItem("inbox", "Inbox Sync", id="task-inbox"), TaskListItem("calendar", "Calendar Sync", id="task-calendar"), # Stage 3: Task management TaskListItem("godspeed", "Godspeed Sync", id="task-godspeed"), TaskListItem("dstask", "dstask Sync", id="task-dstask"), TaskListItem("sweep", "Task Sweep", id="task-sweep"), id="task-list", ) # Countdown timer at bottom of sidebar with Vertical(classes="countdown-container"): yield Static( "Next sync: --:--", id="countdown", classes="countdown-text" ) yield Static( "Daemon: --", id="daemon-status", classes="daemon-status" ) # Main panel with selected task details with Vertical(classes="main-panel"): # Task header with name and progress with Vertical(classes="task-header"): yield Static( "Archive Mail", id="selected-task-name", classes="task-name" ) with Horizontal(classes="progress-row"): yield Static("Progress:", id="progress-label") yield ProgressBar(total=100, id="task-progress") yield Static("0%", id="progress-percent") # Log for selected task with Vertical(classes="log-container"): yield Static("Activity Log", classes="log-title") yield Log(id="task-log") yield Footer() def on_mount(self) -> None: """Initialize the dashboard.""" # Store references to task items task_list = self.query_one("#task-list", ListView) for item in task_list.children: if isinstance(item, TaskListItem): self._task_items[item.task_id] = item self._task_logs[item.task_id] = [] # Initialize sync interval self.sync_interval = self._initial_sync_interval self.schedule_next_sync() # Start countdown timer and spinner animation self._countdown_task = asyncio.create_task(self._update_countdown()) self._spinner_task = asyncio.create_task(self._animate_spinners()) self._log_to_task("archive", "Dashboard initialized. Waiting to start sync...") self._mounted.set() def on_unmount(self) -> None: """Clean up when the dashboard is unmounted.""" if self._countdown_task: self._countdown_task.cancel() if self._spinner_task: self._spinner_task.cancel() def on_list_view_selected(self, event: ListView.Selected) -> None: """Handle task selection from the list.""" if isinstance(event.item, TaskListItem): self.selected_task = event.item.task_id self._update_main_panel() def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: """Handle task highlight from the list.""" if isinstance(event.item, TaskListItem): self.selected_task = event.item.task_id self._update_main_panel() def _update_main_panel(self) -> None: """Update the main panel to show selected task details.""" task_item = self._task_items.get(self.selected_task) if not task_item: return # Update task name try: name_widget = self.query_one("#selected-task-name", Static) name_widget.update(Text(task_item.task_name, style="bold")) except Exception: pass # Update progress bar try: progress_bar = self.query_one("#task-progress", ProgressBar) progress_bar.total = task_item.total progress_bar.progress = task_item.progress percent_widget = self.query_one("#progress-percent", Static) pct = ( int((task_item.progress / task_item.total) * 100) if task_item.total > 0 else 0 ) percent_widget.update(f"{pct}%") except Exception: pass # Update log with task-specific logs try: log_widget = self.query_one("#task-log", Log) log_widget.clear() for entry in self._task_logs.get(self.selected_task, []): log_widget.write_line(entry) except Exception: pass def _log_to_task(self, task_id: str, message: str, level: str = "INFO") -> None: """Add a log entry to a specific task.""" timestamp = datetime.now().strftime("%H:%M:%S") formatted = f"[{timestamp}] {level}: {message}" if task_id not in self._task_logs: self._task_logs[task_id] = [] self._task_logs[task_id].append(formatted) # If this is the selected task, also write to the visible log if task_id == self.selected_task: try: log_widget = self.query_one("#task-log", Log) log_widget.write_line(formatted) except Exception: pass def start_task(self, task_id: str, total: int = 100) -> None: """Start a task.""" if task_id in self._task_items: item = self._task_items[task_id] item.status = TaskStatus.RUNNING item.progress = 0 item.total = total item.update_display() self._log_to_task(task_id, f"Starting {item.task_name}...") if task_id == self.selected_task: self._update_main_panel() def update_task(self, task_id: str, progress: int, message: str = "") -> None: """Update task progress.""" if task_id in self._task_items: item = self._task_items[task_id] item.progress = progress item.update_display() if message: self._log_to_task(task_id, message) if task_id == self.selected_task: self._update_main_panel() def complete_task(self, task_id: str, message: str = "") -> None: """Mark a task as complete.""" if task_id in self._task_items: item = self._task_items[task_id] item.status = TaskStatus.COMPLETED item.progress = item.total item.update_display() self._log_to_task( task_id, f"Completed: {message}" if message else "Completed successfully", ) if task_id == self.selected_task: self._update_main_panel() def error_task(self, task_id: str, error: str) -> None: """Mark a task as errored.""" if task_id in self._task_items: item = self._task_items[task_id] item.status = TaskStatus.ERROR item.update_display() self._log_to_task(task_id, f"ERROR: {error}", "ERROR") if task_id == self.selected_task: self._update_main_panel() def skip_task(self, task_id: str, reason: str = "") -> None: """Mark a task as skipped (completed with no work).""" if task_id in self._task_items: item = self._task_items[task_id] item.status = TaskStatus.COMPLETED item.update_display() self._log_to_task(task_id, f"Skipped: {reason}" if reason else "Skipped") if task_id == self.selected_task: self._update_main_panel() def action_refresh(self) -> None: """Refresh the dashboard.""" self._update_main_panel() def action_cursor_up(self) -> None: """Move cursor up in task list.""" task_list = self.query_one("#task-list", ListView) task_list.action_cursor_up() def action_cursor_down(self) -> None: """Move cursor down in task list.""" task_list = self.query_one("#task-list", ListView) task_list.action_cursor_down() def action_sync_now(self) -> None: """Trigger an immediate sync.""" if self._sync_callback: asyncio.create_task(self._run_sync_callback()) else: self._log_to_task("archive", "No sync callback configured") async def _run_sync_callback(self) -> None: """Run the sync callback if set.""" if self._sync_callback: if asyncio.iscoroutinefunction(self._sync_callback): await self._sync_callback() else: self._sync_callback() def action_increase_interval(self) -> None: """Increase sync interval by 1 minute.""" self.sync_interval = min(self.sync_interval + 60, 3600) # Max 1 hour self._update_countdown_display() self._log_to_task( self.selected_task, f"Sync interval: {self.sync_interval // 60} min", ) def action_decrease_interval(self) -> None: """Decrease sync interval by 1 minute.""" self.sync_interval = max(self.sync_interval - 60, 60) # Min 1 minute self._update_countdown_display() self._log_to_task( self.selected_task, f"Sync interval: {self.sync_interval // 60} min", ) def action_daemonize(self) -> None: """Start sync daemon in background and exit TUI.""" import subprocess from src.cli.sync_daemon import SyncDaemon, create_daemon_config # Build config from sync_config, adding sync_interval daemon_config = { **self._sync_config, "sync_interval": self.sync_interval, } # Check if daemon is already running config = create_daemon_config(**daemon_config) daemon = SyncDaemon(config) if daemon.is_running(): self._log_to_task( self.selected_task, f"Daemon already running (PID {daemon.get_pid()})", ) return # Start daemon and exit self._log_to_task(self.selected_task, "Starting background daemon...") # Use subprocess to start the daemon via CLI command # This properly handles daemonization without conflicting with TUI try: # Build the command with current config cmd = [ sys.executable, "-m", "src.cli", "sync", "run", "--daemon", "--org", self._sync_config.get("org", "corteva"), "--vdir", self._sync_config.get("vdir", "~/Calendar"), ] if self._sync_config.get("notify", True): cmd.append("--notify") if self._sync_config.get("dry_run", False): cmd.append("--dry-run") if self._sync_config.get("two_way_calendar", False): cmd.append("--two-way-calendar") if self._sync_config.get("download_attachments", False): cmd.append("--download-attachments") # Start the daemon process detached subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, start_new_session=True, ) # Give it a moment to start time.sleep(0.5) # Verify it started if daemon.is_running(): self.exit( message=f"Daemon started (PID {daemon.get_pid()}). Sync continues in background." ) else: self._log_to_task(self.selected_task, "Failed to start daemon") except Exception as e: self._log_to_task(self.selected_task, f"Failed to daemonize: {e}") def set_sync_callback(self, callback: Callable) -> None: """Set the callback to run when sync is triggered.""" self._sync_callback = callback def schedule_next_sync(self) -> None: """Schedule the next sync time.""" import time self.next_sync_time = time.time() + self.sync_interval def reset_all_tasks(self) -> None: """Reset all tasks to pending state.""" for task_id, item in self._task_items.items(): item.status = TaskStatus.PENDING item.progress = 0 item.update_display() self._update_main_panel() async def _update_countdown(self) -> None: """Update the countdown timer every second.""" import time while True: try: self._update_countdown_display() await asyncio.sleep(1) except asyncio.CancelledError: break except Exception: await asyncio.sleep(1) def _update_countdown_display(self) -> None: """Update the countdown display widget.""" try: countdown_widget = self.query_one("#countdown", Static) remaining = max(0, self.next_sync_time - time.time()) if remaining <= 0: countdown_widget.update(f"Syncing... ({self.sync_interval // 60}m)") else: minutes = int(remaining // 60) seconds = int(remaining % 60) countdown_widget.update( f"Next: {minutes:02d}:{seconds:02d} ({self.sync_interval // 60}m)" ) except Exception: pass # Update daemon status self._update_daemon_status() def _update_daemon_status(self) -> None: """Update the daemon status indicator.""" try: daemon_widget = self.query_one("#daemon-status", Static) pid_file = Path(os.path.expanduser("~/.config/luk/luk.pid")) if pid_file.exists(): try: with open(pid_file, "r") as f: pid = int(f.read().strip()) # Check if process is running os.kill(pid, 0) daemon_widget.update(Text(f"Daemon: PID {pid}", style="green")) except (ValueError, ProcessLookupError, OSError): daemon_widget.update(Text("Daemon: stopped", style="dim")) else: daemon_widget.update(Text("Daemon: stopped", style="dim")) except Exception: pass async def _animate_spinners(self) -> None: """Animate spinners for running tasks.""" while True: try: # Update all running task spinners for task_id, item in self._task_items.items(): if item.status == TaskStatus.RUNNING: item.advance_spinner() item.update_display() await asyncio.sleep(0.08) # ~12 FPS for smooth animation except asyncio.CancelledError: break except Exception: await asyncio.sleep(0.08) class DashboardProgressAdapter: """Adapter to make dashboard tracker work with functions expecting Rich Progress.""" def __init__(self, tracker: "SyncProgressTracker", task_id: str): self.tracker = tracker self.task_id = task_id self.console = DashboardConsoleAdapter(tracker, task_id) self._total = 100 self._completed = 0 def update(self, task_id=None, total=None, completed=None, advance=None): """Update progress (mimics Rich Progress.update).""" if total is not None: self._total = total if completed is not None: self._completed = completed if advance is not None: self._completed += advance # Convert to percentage for dashboard if self._total > 0: pct = int((self._completed / self._total) * 100) self.tracker.update_task(self.task_id, pct) def advance(self, task_id=None, advance: int = 1): """Advance progress by a given amount (mimics Rich Progress.advance).""" self._completed += advance if self._total > 0: pct = int((self._completed / self._total) * 100) self.tracker.update_task(self.task_id, pct) def add_task(self, description: str, total: int = 100): """Mimics Rich Progress.add_task (no-op, we use existing tasks).""" self._total = total return None class DashboardConsoleAdapter: """Adapter that logs console prints to dashboard task log.""" def __init__(self, tracker: "SyncProgressTracker", task_id: str): self.tracker = tracker self.task_id = task_id def print(self, message: str = "", **kwargs): """Log a message to the task's activity log. Accepts **kwargs to handle Rich console.print() arguments like 'end', 'style', etc. """ # Strip Rich markup for cleaner logs import re clean_message = re.sub(r"\[.*?\]", "", str(message)) if clean_message.strip(): self.tracker.dashboard._log_to_task(self.task_id, clean_message.strip()) class SyncProgressTracker: """Track sync progress and update the dashboard.""" def __init__(self, dashboard: SyncDashboard): self.dashboard = dashboard def start_task(self, task_id: str, total: int = 100) -> None: """Start tracking a task.""" self.dashboard.start_task(task_id, total) def update_task(self, task_id: str, progress: int, message: str = "") -> None: """Update task progress.""" self.dashboard.update_task(task_id, progress, message) def complete_task(self, task_id: str, message: str = "") -> None: """Mark a task as complete.""" self.dashboard.complete_task(task_id, message) def error_task(self, task_id: str, error: str) -> None: """Mark a task as failed.""" self.dashboard.error_task(task_id, error) def skip_task(self, task_id: str, reason: str = "") -> None: """Mark a task as skipped.""" self.dashboard.skip_task(task_id, reason) # Global dashboard instance _dashboard_instance: Optional[SyncDashboard] = None _progress_tracker: Optional[SyncProgressTracker] = None def get_dashboard() -> Optional[SyncDashboard]: """Get the global dashboard instance.""" global _dashboard_instance return _dashboard_instance def get_progress_tracker() -> Optional[SyncProgressTracker]: """Get the global progress_tracker""" global _progress_tracker return _progress_tracker async def run_dashboard_sync( notify: bool = True, sync_config: Optional[Dict[str, Any]] = None, demo_mode: bool = False, ): """Run sync with dashboard UI. Args: notify: Whether to send notifications for new emails sync_config: Configuration dict for sync operations (vdir, org, etc.) demo_mode: If True, use simulated sync instead of real operations """ global _dashboard_instance, _progress_tracker dashboard = SyncDashboard( notify=notify, sync_config=sync_config, demo_mode=demo_mode, ) tracker = SyncProgressTracker(dashboard) _dashboard_instance = dashboard _progress_tracker = tracker async def do_demo_sync(): """Run simulated sync for demo/testing.""" import random try: # Reset all tasks before starting dashboard.reset_all_tasks() # Stage 1: Sync local changes to server # Archive mail tracker.start_task("archive", 100) tracker.update_task("archive", 50, "Scanning for archived messages...") await asyncio.sleep(0.3) tracker.update_task("archive", 100, "Moving 3 messages to archive...") await asyncio.sleep(0.2) tracker.complete_task("archive", "3 messages archived") # Outbox tracker.start_task("outbox", 100) tracker.update_task("outbox", 50, "Checking outbox...") await asyncio.sleep(0.2) tracker.complete_task("outbox", "No pending emails") # Stage 2: Fetch from server # Inbox sync - simulate finding new messages tracker.start_task("inbox", 100) for i in range(0, 101, 20): tracker.update_task("inbox", i, f"Fetching emails... {i}%") await asyncio.sleep(0.3) new_message_count = random.randint(0, 5) if new_message_count > 0: tracker.complete_task("inbox", f"{new_message_count} new emails") if dashboard._notify: from src.utils.notifications import notify_new_emails notify_new_emails(new_message_count, "") else: tracker.complete_task("inbox", "No new emails") # Calendar sync tracker.start_task("calendar", 100) for i in range(0, 101, 25): tracker.update_task("calendar", i, f"Syncing events... {i}%") await asyncio.sleep(0.3) tracker.complete_task("calendar", "25 events synced") # Stage 3: Task management # Godspeed sync tracker.start_task("godspeed", 100) for i in range(0, 101, 33): tracker.update_task( "godspeed", min(i, 100), f"Syncing tasks... {min(i, 100)}%" ) await asyncio.sleep(0.3) tracker.complete_task("godspeed", "42 tasks synced") # dstask sync tracker.start_task("dstask", 100) tracker.update_task("dstask", 30, "Running dstask sync...") await asyncio.sleep(0.3) tracker.update_task("dstask", 70, "Pushing changes...") await asyncio.sleep(0.2) tracker.complete_task("dstask", "Sync completed") # Task sweep tracker.start_task("sweep") tracker.update_task("sweep", 50, "Scanning notes directory...") await asyncio.sleep(0.2) tracker.skip_task("sweep", "Before 6 PM, skipping daily sweep") # Schedule next sync dashboard.schedule_next_sync() except Exception as e: tracker.error_task("archive", str(e)) async def do_real_sync(): """Run the actual sync process using real sync operations.""" from src.utils.mail_utils.helpers import ensure_directory_exists from src.services.microsoft_graph.auth import get_access_token from src.services.microsoft_graph.mail import ( archive_mail_async, delete_mail_async, synchronize_maildir_async, process_outbox_async, fetch_mail_async, ) from src.services.microsoft_graph.calendar import ( fetch_calendar_events, sync_local_calendar_changes, ) from src.cli.sync import ( should_run_godspeed_sync, should_run_sweep, run_godspeed_sync, run_task_sweep, load_sync_state, save_sync_state, get_godspeed_sync_directory, get_godspeed_credentials, create_maildir_structure, ) from src.utils.calendar_utils import save_events_to_vdir, save_events_to_file from src.utils.notifications import notify_new_emails config = dashboard._sync_config try: # Reset all tasks before starting dashboard.reset_all_tasks() # Setup paths org = config.get("org", "corteva") vdir = os.path.expanduser(config.get("vdir", "~/Calendar")) icsfile = config.get("icsfile") dry_run = config.get("dry_run", False) days_back = config.get("days_back", 1) days_forward = config.get("days_forward", 30) download_attachments = config.get("download_attachments", False) two_way_calendar = config.get("two_way_calendar", False) base_maildir_path = os.getenv("MAILDIR_PATH", os.path.expanduser("~/Mail")) maildir_path = f"{base_maildir_path}/{org}" attachments_dir = os.path.join(maildir_path, "attachments") # Create directory structure ensure_directory_exists(attachments_dir) create_maildir_structure(maildir_path) # Get auth token scopes = [ "https://graph.microsoft.com/Calendars.Read", "https://graph.microsoft.com/Mail.ReadWrite", ] access_token, headers = get_access_token(scopes) # ===== STAGE 1: Sync local changes to server ===== # Archive mail tracker.start_task("archive", 100) tracker.update_task("archive", 10, "Checking for archived messages...") try: archive_progress = DashboardProgressAdapter(tracker, "archive") await archive_mail_async( maildir_path, headers, archive_progress, None, dry_run ) tracker.complete_task("archive", "Archive sync complete") except Exception as e: tracker.error_task("archive", str(e)) # Process outbox (send pending emails) tracker.start_task("outbox", 100) tracker.update_task("outbox", 10, "Checking outbox...") try: outbox_progress = DashboardProgressAdapter(tracker, "outbox") result = await process_outbox_async( base_maildir_path, org, headers, outbox_progress, None, dry_run ) sent_count, failed_count = result if result else (0, 0) if sent_count > 0: tracker.complete_task("outbox", f"{sent_count} emails sent") else: tracker.complete_task("outbox", "No pending emails") except Exception as e: tracker.error_task("outbox", str(e)) # ===== STAGE 2: Fetch from server ===== # Count messages before sync for notification messages_before = 0 new_dir = os.path.join(maildir_path, "new") cur_dir = os.path.join(maildir_path, "cur") if os.path.exists(new_dir): messages_before += len([f for f in os.listdir(new_dir) if ".eml" in f]) if os.path.exists(cur_dir): messages_before += len([f for f in os.listdir(cur_dir) if ".eml" in f]) # Inbox sync tracker.start_task("inbox", 100) tracker.update_task("inbox", 10, "Fetching emails from server...") try: inbox_progress = DashboardProgressAdapter(tracker, "inbox") await fetch_mail_async( maildir_path, attachments_dir, headers, inbox_progress, None, dry_run, download_attachments, ) tracker.update_task("inbox", 80, "Processing messages...") # Count new messages messages_after = 0 if os.path.exists(new_dir): messages_after += len( [f for f in os.listdir(new_dir) if ".eml" in f] ) if os.path.exists(cur_dir): messages_after += len( [f for f in os.listdir(cur_dir) if ".eml" in f] ) new_message_count = messages_after - messages_before if new_message_count > 0: tracker.complete_task("inbox", f"{new_message_count} new emails") if dashboard._notify and not dry_run: notify_new_emails(new_message_count, org) else: tracker.complete_task("inbox", "No new emails") except Exception as e: tracker.error_task("inbox", str(e)) # Calendar sync tracker.start_task("calendar", 100) tracker.update_task("calendar", 10, "Fetching calendar events...") try: events, total_events = await fetch_calendar_events( headers=headers, days_back=days_back, days_forward=days_forward ) tracker.update_task( "calendar", 50, f"Processing {len(events)} events..." ) if not dry_run: calendar_progress = DashboardProgressAdapter(tracker, "calendar") org_vdir_path = os.path.join(vdir, org) if vdir else None if vdir and org_vdir_path: save_events_to_vdir( events, org_vdir_path, calendar_progress, None, dry_run ) elif icsfile: save_events_to_file( events, f"{icsfile}/events_latest.ics", calendar_progress, None, dry_run, ) tracker.complete_task("calendar", f"{len(events)} events synced") except Exception as e: tracker.error_task("calendar", str(e)) # ===== STAGE 3: Godspeed operations ===== # Godspeed sync (runs every 15 minutes) tracker.start_task("godspeed", 100) if should_run_godspeed_sync(): tracker.update_task("godspeed", 10, "Syncing with Godspeed...") try: email, password, token = get_godspeed_credentials() if token or (email and password): from src.services.godspeed.client import GodspeedClient from src.services.godspeed.sync import GodspeedSync sync_dir = get_godspeed_sync_directory() client = GodspeedClient( email=email, password=password, token=token ) sync_engine = GodspeedSync(client, sync_dir) sync_engine.sync_bidirectional() state = load_sync_state() state["last_godspeed_sync"] = time.time() save_sync_state(state) tracker.complete_task("godspeed", "Sync completed") else: tracker.skip_task("godspeed", "No credentials configured") except Exception as e: tracker.error_task("godspeed", str(e)) else: tracker.skip_task("godspeed", "Not due yet (every 15 min)") # dstask sync tracker.start_task("dstask", 100) try: from src.services.dstask.client import DstaskClient dstask_client = DstaskClient() if dstask_client.is_available(): tracker.update_task("dstask", 30, "Running dstask sync...") success = dstask_client.sync() if success: tracker.complete_task("dstask", "Sync completed") else: tracker.error_task("dstask", "Sync failed") else: tracker.skip_task("dstask", "dstask not installed") except Exception as e: tracker.error_task("dstask", str(e)) # Task sweep (runs once daily after 6 PM) tracker.start_task("sweep", 100) if should_run_sweep(): tracker.update_task("sweep", 10, "Sweeping tasks from notes...") try: from src.cli.godspeed import TaskSweeper from datetime import datetime notes_dir_env = os.getenv("NOTES_DIR") if notes_dir_env and Path(notes_dir_env).exists(): godspeed_dir = get_godspeed_sync_directory() sweeper = TaskSweeper( Path(notes_dir_env), godspeed_dir, dry_run=dry_run ) result = sweeper.sweep_tasks() state = load_sync_state() state["last_sweep_date"] = datetime.now().strftime("%Y-%m-%d") save_sync_state(state) swept = result.get("swept_tasks", 0) if swept > 0: tracker.complete_task("sweep", f"{swept} tasks swept") else: tracker.complete_task("sweep", "No tasks to sweep") else: tracker.skip_task("sweep", "$NOTES_DIR not configured") except Exception as e: tracker.error_task("sweep", str(e)) else: from datetime import datetime current_hour = datetime.now().hour if current_hour < 18: tracker.skip_task("sweep", "Before 6 PM") else: tracker.skip_task("sweep", "Already completed today") # Schedule next sync dashboard.schedule_next_sync() except Exception as e: # If we fail early (e.g., auth), log to the first pending task for task_id in [ "archive", "outbox", "inbox", "calendar", "godspeed", "dstask", "sweep", ]: if task_id in dashboard._task_items: item = dashboard._task_items[task_id] if item.status == TaskStatus.PENDING: tracker.error_task(task_id, str(e)) break # Choose sync function based on mode do_sync = do_demo_sync if demo_mode else do_real_sync # Set the sync callback so 's' key triggers it dashboard.set_sync_callback(do_sync) async def sync_loop(): """Run sync on interval.""" # Wait for the dashboard to be mounted before updating widgets await dashboard._mounted.wait() # Run initial sync await do_sync() # Then loop waiting for next sync time while True: try: remaining = dashboard.next_sync_time - time.time() if remaining <= 0: await do_sync() else: await asyncio.sleep(1) except asyncio.CancelledError: break except Exception: await asyncio.sleep(1) # Run dashboard and sync loop concurrently # When dashboard exits, cancel the sync loop sync_task = asyncio.create_task(sync_loop()) try: await dashboard.run_async() finally: sync_task.cancel() try: await sync_task except asyncio.CancelledError: pass