add sync notifications

This commit is contained in:
Tim Bendt
2025-08-06 15:35:40 -05:00
parent 8881b6933b
commit 5eddddc8ec
3 changed files with 133 additions and 3 deletions

View File

@@ -1,4 +1,5 @@
[tools] [tools]
node = "22.17.1"
uv = 'latest' uv = 'latest'
[settings] [settings]

View File

@@ -1,12 +1,12 @@
import click import click
import asyncio import asyncio
import os import os
import sys
from rich.progress import Progress, SpinnerColumn, MofNCompleteColumn from rich.progress import Progress, SpinnerColumn, MofNCompleteColumn
from datetime import datetime, timedelta from datetime import datetime, timedelta
from src.utils.mail_utils.helpers import ensure_directory_exists from src.utils.mail_utils.helpers import ensure_directory_exists
from src.utils.calendar_utils import save_events_to_vdir, save_events_to_file from src.utils.calendar_utils import save_events_to_vdir, save_events_to_file
from src.utils.notifications import notify_new_emails
from src.services.microsoft_graph.calendar import ( from src.services.microsoft_graph.calendar import (
fetch_calendar_events, fetch_calendar_events,
sync_local_calendar_changes, sync_local_calendar_changes,
@@ -103,7 +103,7 @@ async def fetch_calendar_async(
events, f"{ics_path}/events_latest.ics", progress, task_id, dry_run events, f"{ics_path}/events_latest.ics", progress, task_id, dry_run
) )
progress.console.print( progress.console.print(
f"[green]Finished saving events to ICS file[/green]" "[green]Finished saving events to ICS file[/green]"
) )
else: else:
# No destination specified # No destination specified
@@ -220,6 +220,7 @@ async def _sync_outlook_data(
continue_iteration, continue_iteration,
download_attachments, download_attachments,
two_way_calendar, two_way_calendar,
notify,
): ):
"""Synchronize data from external sources.""" """Synchronize data from external sources."""
@@ -297,6 +298,20 @@ async def _sync_outlook_data(
progress.console.print( progress.console.print(
"\n[bold cyan]Step 2: Fetching new data from server...[/bold cyan]" "\n[bold cyan]Step 2: Fetching new data from server...[/bold cyan]"
) )
# Track messages before sync for notifications
maildir_path = (
os.getenv("MAILDIR_PATH", os.path.expanduser("~/Mail")) + f"/{org}"
)
messages_before = 0
if notify:
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])
await asyncio.gather( await asyncio.gather(
fetch_mail_async( fetch_mail_async(
maildir_path, maildir_path,
@@ -320,6 +335,19 @@ async def _sync_outlook_data(
continue_iteration, continue_iteration,
), ),
) )
# Send notification for new emails if enabled
if notify and not dry_run:
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:
notify_new_emails(new_message_count, org)
progress.console.print("[bold green]Step 2: New data fetched.[/bold green]") progress.console.print("[bold green]Step 2: New data fetched.[/bold green]")
click.echo("Sync complete.") click.echo("Sync complete.")
@@ -380,6 +408,12 @@ async def _sync_outlook_data(
help="Run in daemon mode.", help="Run in daemon mode.",
default=False, default=False,
) )
@click.option(
"--notify",
is_flag=True,
help="Send macOS notifications for new email messages",
default=False,
)
def sync( def sync(
dry_run, dry_run,
vdir, vdir,
@@ -391,6 +425,7 @@ def sync(
download_attachments, download_attachments,
two_way_calendar, two_way_calendar,
daemon, daemon,
notify,
): ):
if daemon: if daemon:
asyncio.run( asyncio.run(
@@ -404,6 +439,7 @@ def sync(
continue_iteration, continue_iteration,
download_attachments, download_attachments,
two_way_calendar, two_way_calendar,
notify,
) )
) )
else: else:
@@ -418,6 +454,7 @@ def sync(
continue_iteration, continue_iteration,
download_attachments, download_attachments,
two_way_calendar, two_way_calendar,
notify,
) )
) )
@@ -498,13 +535,13 @@ async def daemon_mode(
continue_iteration, continue_iteration,
download_attachments, download_attachments,
two_way_calendar, two_way_calendar,
notify,
): ):
""" """
Run the script in daemon mode, periodically syncing emails and calendar. Run the script in daemon mode, periodically syncing emails and calendar.
""" """
from src.services.microsoft_graph.mail import get_inbox_count_async from src.services.microsoft_graph.mail import get_inbox_count_async
from rich.console import Console from rich.console import Console
from rich.live import Live
from rich.panel import Panel from rich.panel import Panel
from rich.text import Text from rich.text import Text
from datetime import datetime from datetime import datetime
@@ -549,6 +586,7 @@ async def daemon_mode(
continue_iteration, continue_iteration,
download_attachments, download_attachments,
two_way_calendar, two_way_calendar,
notify,
) )
last_sync_time = time.time() last_sync_time = time.time()
@@ -625,6 +663,7 @@ async def daemon_mode(
continue_iteration, continue_iteration,
download_attachments, download_attachments,
two_way_calendar, two_way_calendar,
notify,
) )
last_sync_time = time.time() last_sync_time = time.time()
console.print(create_status_display("Sync completed ✅", "green")) console.print(create_status_display("Sync completed ✅", "green"))

View File

@@ -0,0 +1,90 @@
"""
macOS notification utilities for GTD terminal tools.
"""
import subprocess
import platform
from typing import Optional
def send_notification(
title: str,
message: str,
subtitle: Optional[str] = None,
sound: Optional[str] = None,
) -> bool:
"""
Send a macOS notification using osascript.
Args:
title: The notification title
message: The notification message body
subtitle: Optional subtitle
sound: Optional sound name (e.g., "default", "Glass", "Ping")
Returns:
bool: True if notification was sent successfully, False otherwise
"""
if platform.system() != "Darwin":
return False
try:
# Escape quotes for AppleScript string literals
def escape_applescript_string(text: str) -> str:
return text.replace("\\", "\\\\").replace('"', '\\"')
escaped_title = escape_applescript_string(title)
escaped_message = escape_applescript_string(message)
# Build the AppleScript command
script_parts = [
f'display notification "{escaped_message}"',
f'with title "{escaped_title}"',
]
if subtitle:
escaped_subtitle = escape_applescript_string(subtitle)
script_parts.append(f'subtitle "{escaped_subtitle}"')
if sound:
escaped_sound = escape_applescript_string(sound)
script_parts.append(f'sound name "{escaped_sound}"')
script = " ".join(script_parts)
# Execute the notification by passing script through stdin
subprocess.run(
["osascript"], input=script, check=True, capture_output=True, text=True
)
return True
except subprocess.CalledProcessError:
return False
except Exception:
return False
except Exception:
return False
except Exception:
return False
def notify_new_emails(count: int, org: str = ""):
"""
Send notification about new email messages.
Args:
count: Number of new messages
org: Organization name (optional)
"""
if count <= 0:
return
if count == 1:
title = "New Email"
message = "You have 1 new message"
else:
title = "New Emails"
message = f"You have {count} new messages"
subtitle = f"from {org}" if org else None
send_notification(title=title, message=message, subtitle=subtitle, sound="default")