Add calendar invites panel to Calendar TUI sidebar
- Create InvitesPanel widget showing pending invites from Microsoft Graph - Add fetch_pending_invites() and respond_to_invite() API functions - Invites load asynchronously in background on app mount - Display invite subject, date/time, and organizer - Add 'i' keybinding to focus invites panel - Style: tentative invites shown in warning color
This commit is contained in:
@@ -19,6 +19,7 @@ from textual.reactive import reactive
|
|||||||
from src.calendar.backend import CalendarBackend, Event
|
from src.calendar.backend import CalendarBackend, Event
|
||||||
from src.calendar.widgets.WeekGrid import WeekGrid
|
from src.calendar.widgets.WeekGrid import WeekGrid
|
||||||
from src.calendar.widgets.MonthCalendar import MonthCalendar
|
from src.calendar.widgets.MonthCalendar import MonthCalendar
|
||||||
|
from src.calendar.widgets.InvitesPanel import InvitesPanel, CalendarInvite
|
||||||
from src.calendar.widgets.AddEventForm import EventFormData
|
from src.calendar.widgets.AddEventForm import EventFormData
|
||||||
from src.utils.shared_config import get_theme_name
|
from src.utils.shared_config import get_theme_name
|
||||||
|
|
||||||
@@ -73,6 +74,13 @@ class CalendarApp(App):
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#sidebar-invites {
|
||||||
|
height: auto;
|
||||||
|
margin-top: 1;
|
||||||
|
border-top: solid $surface-darken-1;
|
||||||
|
padding-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
#week-grid {
|
#week-grid {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
}
|
}
|
||||||
@@ -120,6 +128,7 @@ class CalendarApp(App):
|
|||||||
Binding("g", "goto_today", "Today", show=True),
|
Binding("g", "goto_today", "Today", show=True),
|
||||||
Binding("w", "toggle_weekends", "Weekends", show=True),
|
Binding("w", "toggle_weekends", "Weekends", show=True),
|
||||||
Binding("s", "toggle_sidebar", "Sidebar", show=True),
|
Binding("s", "toggle_sidebar", "Sidebar", show=True),
|
||||||
|
Binding("i", "focus_invites", "Invites", show=True),
|
||||||
Binding("r", "refresh", "Refresh", show=True),
|
Binding("r", "refresh", "Refresh", show=True),
|
||||||
Binding("enter", "view_event", "View", show=True),
|
Binding("enter", "view_event", "View", show=True),
|
||||||
Binding("a", "add_event", "Add", show=True),
|
Binding("a", "add_event", "Add", show=True),
|
||||||
@@ -132,9 +141,11 @@ class CalendarApp(App):
|
|||||||
|
|
||||||
# Instance attributes
|
# Instance attributes
|
||||||
backend: Optional[CalendarBackend]
|
backend: Optional[CalendarBackend]
|
||||||
|
_invites: list[CalendarInvite]
|
||||||
|
|
||||||
def __init__(self, backend: Optional[CalendarBackend] = None):
|
def __init__(self, backend: Optional[CalendarBackend] = None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self._invites = []
|
||||||
|
|
||||||
if backend:
|
if backend:
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
@@ -150,6 +161,7 @@ class CalendarApp(App):
|
|||||||
with Horizontal(id="main-content"):
|
with Horizontal(id="main-content"):
|
||||||
with Vertical(id="sidebar"):
|
with Vertical(id="sidebar"):
|
||||||
yield MonthCalendar(id="sidebar-calendar")
|
yield MonthCalendar(id="sidebar-calendar")
|
||||||
|
yield InvitesPanel(id="sidebar-invites")
|
||||||
yield WeekGrid(id="week-grid")
|
yield WeekGrid(id="week-grid")
|
||||||
yield Static(id="event-detail", classes="hidden")
|
yield Static(id="event-detail", classes="hidden")
|
||||||
yield CalendarStatusBar(id="status-bar")
|
yield CalendarStatusBar(id="status-bar")
|
||||||
@@ -165,10 +177,75 @@ class CalendarApp(App):
|
|||||||
# Sync sidebar calendar with current week
|
# Sync sidebar calendar with current week
|
||||||
self._sync_sidebar_calendar()
|
self._sync_sidebar_calendar()
|
||||||
|
|
||||||
|
# Load invites in background
|
||||||
|
self.run_worker(self._load_invites_async(), exclusive=True)
|
||||||
|
|
||||||
# Update status bar and title
|
# Update status bar and title
|
||||||
self._update_status()
|
self._update_status()
|
||||||
self._update_title()
|
self._update_title()
|
||||||
|
|
||||||
|
async def _load_invites_async(self) -> None:
|
||||||
|
"""Load pending calendar invites from Microsoft Graph."""
|
||||||
|
try:
|
||||||
|
from src.services.microsoft_graph.auth import get_access_token
|
||||||
|
from src.services.microsoft_graph.calendar import fetch_pending_invites
|
||||||
|
from dateutil import parser as date_parser
|
||||||
|
|
||||||
|
# Get auth token
|
||||||
|
scopes = ["https://graph.microsoft.com/Calendars.Read"]
|
||||||
|
_, headers = get_access_token(scopes)
|
||||||
|
|
||||||
|
# Fetch invites
|
||||||
|
raw_invites = await fetch_pending_invites(headers, days_forward=30)
|
||||||
|
|
||||||
|
# Convert to CalendarInvite objects
|
||||||
|
invites = []
|
||||||
|
for inv in raw_invites:
|
||||||
|
try:
|
||||||
|
start_str = inv.get("start", {}).get("dateTime", "")
|
||||||
|
end_str = inv.get("end", {}).get("dateTime", "")
|
||||||
|
start_dt = (
|
||||||
|
date_parser.parse(start_str) if start_str else datetime.now()
|
||||||
|
)
|
||||||
|
end_dt = date_parser.parse(end_str) if end_str else start_dt
|
||||||
|
|
||||||
|
organizer_data = inv.get("organizer", {}).get("emailAddress", {})
|
||||||
|
organizer_name = organizer_data.get(
|
||||||
|
"name", organizer_data.get("address", "Unknown")
|
||||||
|
)
|
||||||
|
|
||||||
|
invite = CalendarInvite(
|
||||||
|
id=inv.get("id", ""),
|
||||||
|
subject=inv.get("subject", "No Subject"),
|
||||||
|
organizer=organizer_name,
|
||||||
|
start=start_dt,
|
||||||
|
end=end_dt,
|
||||||
|
location=inv.get("location", {}).get("displayName"),
|
||||||
|
is_all_day=inv.get("isAllDay", False),
|
||||||
|
response_status=inv.get("responseStatus", {}).get(
|
||||||
|
"response", "notResponded"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
invites.append(invite)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse invite: {e}")
|
||||||
|
|
||||||
|
# Update the panel
|
||||||
|
self._invites = invites
|
||||||
|
self.call_from_thread(self._update_invites_panel)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load invites: {e}")
|
||||||
|
# Silently fail - invites are optional
|
||||||
|
|
||||||
|
def _update_invites_panel(self) -> None:
|
||||||
|
"""Update the invites panel with loaded invites."""
|
||||||
|
try:
|
||||||
|
panel = self.query_one("#sidebar-invites", InvitesPanel)
|
||||||
|
panel.set_invites(self._invites)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _sync_sidebar_calendar(self) -> None:
|
def _sync_sidebar_calendar(self) -> None:
|
||||||
"""Sync the sidebar calendar with the main week grid."""
|
"""Sync the sidebar calendar with the main week grid."""
|
||||||
try:
|
try:
|
||||||
@@ -443,6 +520,7 @@ Keybindings:
|
|||||||
g - Go to today
|
g - Go to today
|
||||||
w - Toggle weekends (5/7 days)
|
w - Toggle weekends (5/7 days)
|
||||||
s - Toggle sidebar
|
s - Toggle sidebar
|
||||||
|
i - Focus invites panel
|
||||||
Enter - View event details
|
Enter - View event details
|
||||||
a - Add new event
|
a - Add new event
|
||||||
r - Refresh
|
r - Refresh
|
||||||
@@ -450,6 +528,16 @@ Keybindings:
|
|||||||
"""
|
"""
|
||||||
self.notify(help_text.strip(), timeout=10)
|
self.notify(help_text.strip(), timeout=10)
|
||||||
|
|
||||||
|
def action_focus_invites(self) -> None:
|
||||||
|
"""Focus on the invites panel and show invite count."""
|
||||||
|
if not self.show_sidebar:
|
||||||
|
self.action_toggle_sidebar()
|
||||||
|
|
||||||
|
if self._invites:
|
||||||
|
self.notify(f"You have {len(self._invites)} pending invite(s)")
|
||||||
|
else:
|
||||||
|
self.notify("No pending invites")
|
||||||
|
|
||||||
|
|
||||||
def run_app(backend: Optional[CalendarBackend] = None) -> None:
|
def run_app(backend: Optional[CalendarBackend] = None) -> None:
|
||||||
"""Run the Calendar TUI application."""
|
"""Run the Calendar TUI application."""
|
||||||
|
|||||||
209
src/calendar/widgets/InvitesPanel.py
Normal file
209
src/calendar/widgets/InvitesPanel.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""Calendar invites panel widget for Calendar TUI sidebar.
|
||||||
|
|
||||||
|
Displays pending calendar invites that need a response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from rich.segment import Segment
|
||||||
|
from rich.style import Style
|
||||||
|
from textual.message import Message
|
||||||
|
from textual.reactive import reactive
|
||||||
|
from textual.strip import Strip
|
||||||
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CalendarInvite:
|
||||||
|
"""A calendar invite pending response."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
subject: str
|
||||||
|
organizer: str
|
||||||
|
start: datetime
|
||||||
|
end: datetime
|
||||||
|
location: Optional[str] = None
|
||||||
|
is_all_day: bool = False
|
||||||
|
response_status: str = "notResponded" # notResponded, tentativelyAccepted
|
||||||
|
|
||||||
|
|
||||||
|
class InvitesPanel(Widget):
|
||||||
|
"""Panel showing pending calendar invites."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
InvitesPanel {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
min-height: 3;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Reactive attributes
|
||||||
|
invites: reactive[List[CalendarInvite]] = reactive(list)
|
||||||
|
selected_index: reactive[int] = reactive(0)
|
||||||
|
|
||||||
|
class InviteSelected(Message):
|
||||||
|
"""An invite was selected."""
|
||||||
|
|
||||||
|
def __init__(self, invite: CalendarInvite) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.invite = invite
|
||||||
|
|
||||||
|
class InviteRespond(Message):
|
||||||
|
"""User wants to respond to an invite."""
|
||||||
|
|
||||||
|
def __init__(self, invite: CalendarInvite, response: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.invite = invite
|
||||||
|
self.response = response # accept, tentativelyAccept, decline
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
invites: Optional[List[CalendarInvite]] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
id: Optional[str] = None,
|
||||||
|
classes: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(name=name, id=id, classes=classes)
|
||||||
|
if invites:
|
||||||
|
self.invites = invites
|
||||||
|
|
||||||
|
def _get_theme_color(self, color_name: str) -> str:
|
||||||
|
"""Get a color from the current theme."""
|
||||||
|
try:
|
||||||
|
theme = self.app.current_theme
|
||||||
|
color = getattr(theme, color_name, None)
|
||||||
|
if color:
|
||||||
|
return str(color)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback colors
|
||||||
|
fallbacks = {
|
||||||
|
"secondary": "#81A1C1",
|
||||||
|
"primary": "#88C0D0",
|
||||||
|
"accent": "#B48EAD",
|
||||||
|
"foreground": "#D8DEE9",
|
||||||
|
"surface": "#3B4252",
|
||||||
|
"warning": "#EBCB8B",
|
||||||
|
}
|
||||||
|
return fallbacks.get(color_name, "white")
|
||||||
|
|
||||||
|
def get_content_height(self, container, viewport, width: int) -> int:
|
||||||
|
"""Calculate height: header + invite rows."""
|
||||||
|
# Header (1) + invites (2 lines each) + empty message if no invites
|
||||||
|
if not self.invites:
|
||||||
|
return 2 # Header + "No pending invites"
|
||||||
|
return 1 + len(self.invites) * 2 # Header + 2 lines per invite
|
||||||
|
|
||||||
|
def render_line(self, y: int) -> Strip:
|
||||||
|
"""Render a line of the panel."""
|
||||||
|
if y == 0:
|
||||||
|
return self._render_header()
|
||||||
|
|
||||||
|
if not self.invites:
|
||||||
|
if y == 1:
|
||||||
|
return self._render_empty_message()
|
||||||
|
return Strip.blank(self.size.width)
|
||||||
|
|
||||||
|
# Each invite takes 2 lines
|
||||||
|
invite_idx = (y - 1) // 2
|
||||||
|
line_in_invite = (y - 1) % 2
|
||||||
|
|
||||||
|
if 0 <= invite_idx < len(self.invites):
|
||||||
|
return self._render_invite_line(
|
||||||
|
self.invites[invite_idx],
|
||||||
|
line_in_invite,
|
||||||
|
invite_idx == self.selected_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Strip.blank(self.size.width)
|
||||||
|
|
||||||
|
def _render_header(self) -> Strip:
|
||||||
|
"""Render the panel header."""
|
||||||
|
primary_color = self._get_theme_color("primary")
|
||||||
|
count = len(self.invites)
|
||||||
|
header = f"Invites ({count})" if count else "Invites"
|
||||||
|
header = header[: self.size.width].ljust(self.size.width)
|
||||||
|
style = Style(bold=True, color=primary_color)
|
||||||
|
return Strip([Segment(header, style)])
|
||||||
|
|
||||||
|
def _render_empty_message(self) -> Strip:
|
||||||
|
"""Render empty state message."""
|
||||||
|
msg = "No pending invites"
|
||||||
|
msg = msg[: self.size.width].ljust(self.size.width)
|
||||||
|
style = Style(color="bright_black")
|
||||||
|
return Strip([Segment(msg, style)])
|
||||||
|
|
||||||
|
def _render_invite_line(
|
||||||
|
self, invite: CalendarInvite, line: int, is_selected: bool
|
||||||
|
) -> Strip:
|
||||||
|
"""Render a line of an invite."""
|
||||||
|
if line == 0:
|
||||||
|
# First line: subject (truncated)
|
||||||
|
text = invite.subject[: self.size.width - 2]
|
||||||
|
if is_selected:
|
||||||
|
text = "> " + text[: self.size.width - 2]
|
||||||
|
text = text[: self.size.width].ljust(self.size.width)
|
||||||
|
|
||||||
|
if is_selected:
|
||||||
|
style = Style(bold=True, reverse=True)
|
||||||
|
else:
|
||||||
|
style = Style()
|
||||||
|
|
||||||
|
return Strip([Segment(text, style)])
|
||||||
|
else:
|
||||||
|
# Second line: date/time and organizer
|
||||||
|
date_str = invite.start.strftime("%m/%d %H:%M")
|
||||||
|
organizer = invite.organizer[:15] if invite.organizer else ""
|
||||||
|
info = f" {date_str} - {organizer}"
|
||||||
|
info = info[: self.size.width].ljust(self.size.width)
|
||||||
|
|
||||||
|
warning_color = self._get_theme_color("warning")
|
||||||
|
if invite.response_status == "tentativelyAccepted":
|
||||||
|
style = Style(color=warning_color, italic=True)
|
||||||
|
else:
|
||||||
|
style = Style(color="bright_black")
|
||||||
|
|
||||||
|
return Strip([Segment(info, style)])
|
||||||
|
|
||||||
|
def set_invites(self, invites: List[CalendarInvite]) -> None:
|
||||||
|
"""Update the list of invites."""
|
||||||
|
self.invites = invites
|
||||||
|
if self.selected_index >= len(invites):
|
||||||
|
self.selected_index = max(0, len(invites) - 1)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def select_next(self) -> None:
|
||||||
|
"""Select the next invite."""
|
||||||
|
if self.invites and self.selected_index < len(self.invites) - 1:
|
||||||
|
self.selected_index += 1
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def select_previous(self) -> None:
|
||||||
|
"""Select the previous invite."""
|
||||||
|
if self.invites and self.selected_index > 0:
|
||||||
|
self.selected_index -= 1
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def get_selected_invite(self) -> Optional[CalendarInvite]:
|
||||||
|
"""Get the currently selected invite."""
|
||||||
|
if self.invites and 0 <= self.selected_index < len(self.invites):
|
||||||
|
return self.invites[self.selected_index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def on_click(self, event) -> None:
|
||||||
|
"""Handle mouse clicks."""
|
||||||
|
y = event.y
|
||||||
|
|
||||||
|
if y == 0 or not self.invites:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Calculate which invite was clicked
|
||||||
|
invite_idx = (y - 1) // 2
|
||||||
|
if 0 <= invite_idx < len(self.invites):
|
||||||
|
self.selected_index = invite_idx
|
||||||
|
self.post_message(self.InviteSelected(self.invites[invite_idx]))
|
||||||
|
self.refresh()
|
||||||
@@ -3,5 +3,13 @@
|
|||||||
from .WeekGrid import WeekGrid
|
from .WeekGrid import WeekGrid
|
||||||
from .AddEventForm import AddEventForm, EventFormData
|
from .AddEventForm import AddEventForm, EventFormData
|
||||||
from .MonthCalendar import MonthCalendar
|
from .MonthCalendar import MonthCalendar
|
||||||
|
from .InvitesPanel import InvitesPanel, CalendarInvite
|
||||||
|
|
||||||
__all__ = ["WeekGrid", "AddEventForm", "EventFormData", "MonthCalendar"]
|
__all__ = [
|
||||||
|
"WeekGrid",
|
||||||
|
"AddEventForm",
|
||||||
|
"EventFormData",
|
||||||
|
"MonthCalendar",
|
||||||
|
"InvitesPanel",
|
||||||
|
"CalendarInvite",
|
||||||
|
]
|
||||||
|
|||||||
@@ -468,3 +468,76 @@ async def sync_local_calendar_changes(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return created_count, deleted_count
|
return created_count, deleted_count
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_pending_invites(headers, days_forward=30):
|
||||||
|
"""
|
||||||
|
Fetch calendar invites that need a response (pending/tentative).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
headers (dict): Headers including authentication.
|
||||||
|
days_forward (int): Number of days to look forward.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of invite dictionaries with response status info.
|
||||||
|
"""
|
||||||
|
start_date = datetime.now()
|
||||||
|
end_date = start_date + timedelta(days=days_forward)
|
||||||
|
|
||||||
|
start_date_str = start_date.strftime("%Y-%m-%dT00:00:00Z")
|
||||||
|
end_date_str = end_date.strftime("%Y-%m-%dT23:59:59Z")
|
||||||
|
|
||||||
|
# Fetch events with response status
|
||||||
|
calendar_url = (
|
||||||
|
f"https://graph.microsoft.com/v1.0/me/calendarView?"
|
||||||
|
f"startDateTime={start_date_str}&endDateTime={end_date_str}&"
|
||||||
|
f"$select=id,subject,organizer,start,end,location,isAllDay,responseStatus,isCancelled&"
|
||||||
|
f"$filter=responseStatus/response eq 'notResponded' or responseStatus/response eq 'tentativelyAccepted'&"
|
||||||
|
f"$orderby=start/dateTime"
|
||||||
|
)
|
||||||
|
|
||||||
|
invites = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = await fetch_with_aiohttp(calendar_url, headers)
|
||||||
|
invites.extend(response_data.get("value", []))
|
||||||
|
|
||||||
|
# Handle pagination
|
||||||
|
next_link = response_data.get("@odata.nextLink")
|
||||||
|
while next_link:
|
||||||
|
response_data = await fetch_with_aiohttp(next_link, headers)
|
||||||
|
invites.extend(response_data.get("value", []))
|
||||||
|
next_link = response_data.get("@odata.nextLink")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching pending invites: {e}")
|
||||||
|
|
||||||
|
return invites
|
||||||
|
|
||||||
|
|
||||||
|
async def respond_to_invite(headers, event_id, response):
|
||||||
|
"""
|
||||||
|
Respond to a calendar invite.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
headers (dict): Authentication headers
|
||||||
|
event_id (str): The ID of the event to respond to
|
||||||
|
response (str): Response type - 'accept', 'tentativelyAccept', or 'decline'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if response was successful
|
||||||
|
"""
|
||||||
|
valid_responses = ["accept", "tentativelyAccept", "decline"]
|
||||||
|
if response not in valid_responses:
|
||||||
|
print(f"Invalid response type: {response}. Must be one of {valid_responses}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_url = (
|
||||||
|
f"https://graph.microsoft.com/v1.0/me/events/{event_id}/{response}"
|
||||||
|
)
|
||||||
|
status = await post_with_aiohttp(response_url, headers, {})
|
||||||
|
return status in (200, 202)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error responding to invite: {e}")
|
||||||
|
return False
|
||||||
|
|||||||
Reference in New Issue
Block a user