From 5eddddc8ecd0891986d243472ef7206009ffd8cf Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Wed, 6 Aug 2025 15:35:40 -0500 Subject: [PATCH] add sync notifications --- mise.toml | 1 + src/cli/sync.py | 45 +++++++++++++++++-- src/utils/notifications.py | 90 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 src/utils/notifications.py diff --git a/mise.toml b/mise.toml index 31d9994..3c02372 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,5 @@ [tools] +node = "22.17.1" uv = 'latest' [settings] diff --git a/src/cli/sync.py b/src/cli/sync.py index 6016d52..78b9fb1 100644 --- a/src/cli/sync.py +++ b/src/cli/sync.py @@ -1,12 +1,12 @@ import click import asyncio import os -import sys from rich.progress import Progress, SpinnerColumn, MofNCompleteColumn from datetime import datetime, timedelta 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.notifications import notify_new_emails from src.services.microsoft_graph.calendar import ( fetch_calendar_events, 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 ) progress.console.print( - f"[green]Finished saving events to ICS file[/green]" + "[green]Finished saving events to ICS file[/green]" ) else: # No destination specified @@ -220,6 +220,7 @@ async def _sync_outlook_data( continue_iteration, download_attachments, two_way_calendar, + notify, ): """Synchronize data from external sources.""" @@ -297,6 +298,20 @@ async def _sync_outlook_data( progress.console.print( "\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( fetch_mail_async( maildir_path, @@ -320,6 +335,19 @@ async def _sync_outlook_data( 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]") click.echo("Sync complete.") @@ -380,6 +408,12 @@ async def _sync_outlook_data( help="Run in daemon mode.", default=False, ) +@click.option( + "--notify", + is_flag=True, + help="Send macOS notifications for new email messages", + default=False, +) def sync( dry_run, vdir, @@ -391,6 +425,7 @@ def sync( download_attachments, two_way_calendar, daemon, + notify, ): if daemon: asyncio.run( @@ -404,6 +439,7 @@ def sync( continue_iteration, download_attachments, two_way_calendar, + notify, ) ) else: @@ -418,6 +454,7 @@ def sync( continue_iteration, download_attachments, two_way_calendar, + notify, ) ) @@ -498,13 +535,13 @@ async def daemon_mode( continue_iteration, download_attachments, two_way_calendar, + notify, ): """ Run the script in daemon mode, periodically syncing emails and calendar. """ from src.services.microsoft_graph.mail import get_inbox_count_async from rich.console import Console - from rich.live import Live from rich.panel import Panel from rich.text import Text from datetime import datetime @@ -549,6 +586,7 @@ async def daemon_mode( continue_iteration, download_attachments, two_way_calendar, + notify, ) last_sync_time = time.time() @@ -625,6 +663,7 @@ async def daemon_mode( continue_iteration, download_attachments, two_way_calendar, + notify, ) last_sync_time = time.time() console.print(create_status_display("Sync completed ✅", "green")) diff --git a/src/utils/notifications.py b/src/utils/notifications.py new file mode 100644 index 0000000..7ed250d --- /dev/null +++ b/src/utils/notifications.py @@ -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")