Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/smorg/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
from __future__ import annotations

from smorg.core.contract import Integration
from smorg.integrations import github, linear, spotify
from smorg.integrations import gcal, github, linear, spotify

INTEGRATIONS: tuple[Integration, ...] = (
gcal.INTEGRATION,
github.INTEGRATION,
linear.INTEGRATION,
spotify.INTEGRATION,
Expand Down
3 changes: 3 additions & 0 deletions src/smorg/integrations/gcal/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from smorg.integrations.gcal.manifest import INTEGRATION

__all__ = ["INTEGRATION"]
54 changes: 54 additions & 0 deletions src/smorg/integrations/gcal/manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Google Calendar's declaration; connects with the OAuth app smorg registered itself, and reads
the REST API.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import timedelta

import httpx

from smorg.auth.oauth import BundledProvider, OAuthMethod, ServerMetadata
from smorg.auth.store import Credentials
from smorg.core.contract import AuthPath, Item, Manifest
from smorg.integrations.gcal.panel import CalendarPanel
from smorg.integrations.gcal.source import fetch

# The app lives in the Google Cloud project "smorg", owned by the maintainer. Google treats an
# installed app's secret as public; it still never reaches output.
CLIENT_ID = "REPLACE-ME.apps.googleusercontent.com"
CLIENT_SECRET = "REPLACE-ME"

METHOD = OAuthMethod(
provider=BundledProvider(
metadata=ServerMetadata(
authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
token_endpoint="https://oauth2.googleapis.com/token",
revocation_endpoint="https://oauth2.googleapis.com/revoke",
),
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
),
scopes=("https://www.googleapis.com/auth/calendar.readonly",),
)

MANIFEST = Manifest(
id="gcal",
display_name="Google Calendar",
connections=(AuthPath(id="oauth", method=METHOD),),
stale_after=timedelta(minutes=5),
actions=(),
)


@dataclass(frozen=True)
class CalendarIntegration:
manifest: Manifest = MANIFEST
panel_class: type[CalendarPanel] = CalendarPanel

def fetch(self, credentials: Credentials, http: httpx.Client) -> tuple[Item, ...]:
return fetch(credentials, http)


INTEGRATION = CalendarIntegration()
10 changes: 10 additions & 0 deletions src/smorg/integrations/gcal/palette.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Google's colours, spent sparingly: blue for today, red for whatever wants attention."""

from __future__ import annotations

TODAY_BLUE = "#1a73e8"
BRAND_BLUE = "#4285f4"
RED = "#ea4335"
YELLOW = "#fbbc04"
GREEN = "#34a853"
BRAND_DOTS = (BRAND_BLUE, RED, YELLOW, GREEN)
155 changes: 155 additions & 0 deletions src/smorg/integrations/gcal/panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Google Calendar's tab: a host panel that swaps between the landing and the calendar views."""

from __future__ import annotations

from collections.abc import Callable
from datetime import date, datetime, timedelta, tzinfo
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from textual.app import ComposeResult

from smorg.core.contract import Item
from smorg.integrations.gcal.source import (
Calendar,
Calendars,
Event,
EventKind,
Response,
window_for,
)
from smorg.integrations.gcal.views import CalendarView
from smorg.integrations.gcal.views.menu import CalendarMenu
from smorg.shell.animation import FrameClock
from smorg.shell.view_host import HostedView, ViewHostPanel

# One repaint every ten seconds moves the now-line and the countdown without a fetch.
_CLOCK_FPS = 0.1


class CalendarPanel(ViewHostPanel[CalendarView]):
DEFAULT_CSS = """
CalendarPanel { align-horizontal: center; }
"""

def __init__(self) -> None:
super().__init__(CalendarView.MENU)
self.now_provider: Callable[[], datetime] | None = None
self.focused_day: date | None = None
self.viewed: Event | None = None
self.return_view: CalendarView = CalendarView.DAY
self.clock = FrameClock(self, _CLOCK_FPS, self._tick)

def compose(self) -> ComposeResult:
yield CalendarMenu(self)

def view_classes(self) -> dict[CalendarView, type[HostedView]]:
return {CalendarView.MENU: CalendarMenu}

def on_mount(self) -> None:
super().on_mount()
self.clock.start()

def _tick(self, elapsed: float) -> None:
self.refresh()

def zone(self) -> tzinfo:
calendars = self.calendars()
if calendars is None:
local_zone = datetime.now().astimezone().tzinfo
if local_zone is not None:
return local_zone
return ZoneInfo("UTC")
try:
return ZoneInfo(calendars.timezone)
except ZoneInfoNotFoundError:
return ZoneInfo("UTC")

def now(self) -> datetime:
if self.now_provider is not None:
return self.now_provider()
return datetime.now(self.zone())

def today(self) -> date:
return self.now().date()

def focused(self) -> date:
if self.focused_day is None:
return self.today()
return self.focused_day

def window(self) -> tuple[date, date]:
start, end = window_for(self.now())
last = end.date() - timedelta(days=1)
return start.date(), last

def set_focused(self, day: date) -> None:
first, last = self.window()
if day < first:
day = first
if day > last:
day = last
self.focused_day = day
self.refresh()

def calendars(self) -> Calendars | None:
for item in self.items:
if isinstance(item, Calendars):
return item
return None

def calendar_for(self, calendar_id: str) -> Calendar | None:
calendars = self.calendars()
if calendars is None:
return None
for calendar in calendars.calendars:
if calendar.id == calendar_id:
return calendar
return None

def events(self) -> tuple[Event, ...]:
events = [item for item in self.items if isinstance(item, Event)]
return tuple(events)

def events_on(self, day: date) -> tuple[Event, ...]:
day_start = datetime.combine(day, datetime.min.time(), tzinfo=self.zone())
day_end = day_start + timedelta(days=1)
on_day = [
event for event in self.events() if event.start < day_end and event.end > day_start
]
return tuple(on_day)

def next_meeting(self) -> Event | None:
now = self.now()
for event in self.events():
if event.all_day or event.kind is not EventKind.MEETING:
continue
if event.start >= now:
return event
return None

def invites(self) -> tuple[Event, ...]:
now = self.now()
pending = [
event
for event in self.events()
if event.my_response is Response.NEEDS_ACTION and event.end >= now
]
return tuple(pending)

def open_event(self, event: Event, return_view: CalendarView) -> None:
self.viewed = event
self.return_view = return_view
self.mark_seen(event)
self.show_view(CalendarView.EVENT)

def close_event(self) -> None:
self.viewed = None
self.show_view(self.return_view)

def seen_items(self) -> tuple[Item, ...]:
return self.events()

def selected_item(self) -> Item | None:
if self.active_view is CalendarView.EVENT:
return self.viewed
return None
Loading