Files
luk/src/cli/sync_dashboard.py
2025-12-18 14:34:29 -05:00

1210 lines
43 KiB
Python

"""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