1210 lines
43 KiB
Python
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
|