diff --git a/.gitignore b/.gitignore index 7dc3dd95..3d5912ad 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ build_x64/ .cache # IDE files compile_commands.json +# Claude Code project-local settings +.claude/ diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md index 414b4105..c4d444c9 100644 --- a/docs/_Sidebar.md +++ b/docs/_Sidebar.md @@ -15,6 +15,7 @@ - [Battery](./(Widget)-Battery) - [Bluetooth](./(Widget)-Bluetooth) - [Brightness](./(Widget)-Brightness) + - [Calendar](./(Widget)-Calendar) - [Cava](./(Widget)-Cava) - [Copilot](./(Widget)-Copilot) - [CPU](./(Widget)-CPU) diff --git a/docs/widgets/(Widget)-Calendar.md b/docs/widgets/(Widget)-Calendar.md new file mode 100644 index 00000000..a7540766 --- /dev/null +++ b/docs/widgets/(Widget)-Calendar.md @@ -0,0 +1,175 @@ +# Calendar Widget Options + +Shows the next upcoming Google Calendar event in the bar. Left-click opens a popup menu listing the next several events with one-click join buttons; middle-click joins the very next meeting (Google Meet, Zoom, Teams) directly. + +| Option | Type | Default | Description | +|-------------------------|---------|----------------------------------------------------------------------|-------------| +| `label` | string | `'{icon} {title} {countdown}'` | Format string for the bar label. Tokens: `{icon}`, `{title}`, `{start_time}`, `{countdown}`, `{status}`, `{meeting_kind}`. | +| `label_alt` | string | `'{icon} {title} at {start_time}'` | Alternative label, swapped via `toggle_label`. | +| `class_name` | string | `''` | Extra CSS class appended to the widget frame. | +| `update_interval` | integer | `60` | Seconds between Google Calendar API polls. Range 15–3600. | +| `tick_interval` | integer | `1000` | Milliseconds between countdown re-renders (no API call). Range 250–60000. | +| `calendar_ids` | list | `['primary']` | Calendar IDs to read. Events from all listed calendars are merged and sorted by start time. Use `primary`, another email, or any calendar ID from your Google Calendar settings. | +| `look_ahead_minutes` | integer | `0` | If > 0, only show events starting within this many minutes. `0` = always show the next event regardless of how far away. | +| `grace_period_minutes` | integer | `5` | Keep showing an in-progress event until this many minutes after its start. Range 0–120. | +| `skip_all_day` | boolean | `true` | Skip all-day events when picking the "next" event. | +| `max_title_length` | integer | `30` | Truncate event titles longer than this. | +| `hide_when_empty` | boolean | `true` | Hide the widget when there are no upcoming events; otherwise show `empty_label`. | +| `empty_label` | string | `'No upcoming events'` | Shown when there is no upcoming event (only if `hide_when_empty` is false). | +| `auth_label` | string | `'Calendar: sign in'` | Bar text shown when sign-in is needed. Click the widget to open the auth dialog. | +| `tooltip` | boolean | `true` | Show a tooltip on hover with full title, time range, and location. | +| `tooltip_event_count` | integer | `1` | Number of upcoming events to list in the tooltip (1–20). | +| `icons` | dict | `{'meet': '', 'zoom': '', 'teams': '', 'other': '', 'none': '', 'calendar': ''}` | Per-platform icon glyphs. Set to whichever Nerd Font codepoints you prefer. | +| `menu` | dict | See [Menu options](#menu-options) | Popup configuration. | +| `notification_dot` | dict | See [Notification dot](#notification-dot) | Coloured dot painted on the icon when an event is live or imminent. | +| `callbacks` | dict | `{'on_left': 'toggle_menu', 'on_middle': 'join_meeting', 'on_right': 'toggle_label'}` | Mouse callbacks. See [Callbacks](#callbacks). | + +## Menu options + +The popup that opens on left-click. Mirrors the GitHub widget's menu config. + +| Option | Type | Default | Description | +|---------------------|---------|-------------|-------------| +| `blur` | boolean | `true` | Apply Mica/acrylic blur behind the popup. | +| `round_corners` | boolean | `true` | Round the popup's corners. | +| `round_corners_type`| string | `'normal'` | `normal` or `small`. | +| `border_color` | string | `'System'` | Border colour. `System` follows the OS accent. | +| `alignment` | string | `'right'` | `left`, `center`, or `right` relative to the bar widget. | +| `direction` | string | `'down'` | `down` or `up`. | +| `offset_top` | integer | `6` | Pixel offset from the bar edge. | +| `offset_left` | integer | `0` | Horizontal offset. | +| `event_count` | integer | `5` | Number of upcoming events to show. Range 1–20. | + +## Notification dot + +A coloured dot painted on the icon to flag a live or imminent meeting. + +| Option | Type | Default | Description | +|-----------------------|---------|----------------|-------------| +| `enabled` | boolean | `true` | Master switch. | +| `corner` | string | `'bottom_left'`| `top_left`, `top_right`, `bottom_left`, `bottom_right`. | +| `color` | string | `'red'` | Any CSS colour. | +| `margin` | list | `[1, 1]` | `[x, y]` margin in pixels. | +| `threshold_minutes` | integer | `10` | Show the dot when the next event starts within this many minutes (or is live). Range 0–240. | + +## Example configuration + +```yaml +calendar: + type: "yasb.calendar.CalendarWidget" + options: + label: "{icon} {title} {countdown}" + label_alt: "{icon} {title} at {start_time}" + update_interval: 60 + tick_interval: 1000 + calendar_ids: + - "primary" + - "you@example.com" + look_ahead_minutes: 120 + grace_period_minutes: 5 + skip_all_day: true + max_title_length: 30 + hide_when_empty: true + icons: + meet: "󰼺" + zoom: "󰹅" + teams: "󰁳" + other: "" + none: "" + calendar: "" + menu: + alignment: "right" + direction: "down" + offset_top: 6 + event_count: 5 + notification_dot: + enabled: true + corner: "bottom_left" + color: "#f5a" + threshold_minutes: 5 + callbacks: + on_left: "toggle_menu" + on_middle: "join_meeting" + on_right: "toggle_label" +``` + +## One-time Google Calendar setup + +The widget reads your calendar via the Google Calendar API. You only have to do this once. + +1. Open the [Google Cloud Console](https://console.cloud.google.com/) and create (or pick) a project. +2. Enable the **Google Calendar API** for that project (APIs & Services → Library). +3. Configure the OAuth consent screen as **External**, add your own Google account as a test user, and request scope `https://www.googleapis.com/auth/calendar.readonly`. +4. Create credentials → **OAuth client ID** → application type **Desktop app**. Download the JSON file. +5. Save it as `%LOCALAPPDATA%\YASB\google_calendar_credentials.json`. +6. Start YASB. The bar widget shows `Calendar: sign in`. Click it — an auth dialog opens, then your browser. After you authorise, the dialog closes and your events appear. + +The auth dialog has an **Open Folder** button that takes you straight to `%LOCALAPPDATA%\YASB\` so you can drop the credentials file in. + +The token only grants read access. To revoke it, delete `%LOCALAPPDATA%\YASB\google_calendar_token.json` and remove the app from . + +## Tokens + +Tokens you can use in `label` / `label_alt`: + +- `{icon}` — picked from `icons` based on the meeting platform (`meet`/`zoom`/`teams`/`other`/`none`). +- `{title}` — event title, truncated to `max_title_length`. +- `{start_time}` — local time of the event start in `HH:MM`. +- `{countdown}` — `in 12m`, `in 1h 20m`, `now`, `started 3m ago`. +- `{status}` — `upcoming`, `live`, `ended` (also applied as a CSS class). +- `{meeting_kind}` — `meet`, `zoom`, `teams`, `other`, or `none`. + +## Callbacks + +| Name | Behaviour | +|----------------|-----------| +| `toggle_menu` | Open or close the popup of upcoming events. If sign-in is needed, opens the auth dialog instead. | +| `join_meeting` | Open the meeting join URL of the very next event. Falls back to the calendar event page if no URL is found. If sign-in is needed, opens the auth dialog. | +| `open_event` | Open the next event's `htmlLink` (Google Calendar web view). | +| `toggle_label` | Swap between `label` and `label_alt`. | +| `refresh` | Force a re-poll of the API (skipped if a poll is already in flight). | + +## How the meeting URL is detected + +In priority order: + +1. `event.hangoutLink` — Google Meet links auto-attached to the event. +2. `event.conferenceData.entryPoints[]` — first entry with `entryPointType: video`. Classified by host (`zoom.us`, `teams.microsoft.com`, etc.). +3. Regex over the event's `location` and `description` for `https://*.zoom.us/...`, `https://teams.microsoft.com/l/meetup-join/...`, or `https://meet.google.com/xxx-xxxx-xxx`. + +If nothing matches, `join_meeting` falls back to opening the event in Google Calendar. + +## Style example + +The widget frame gets state classes you can target from `styles.css`: + +```css +.calendar-widget { + padding: 0 8px; +} +.calendar-widget.live { + color: #f5a; + font-weight: 600; +} +.calendar-widget.upcoming.meet { color: #00897b; } +.calendar-widget.zoom { color: #2d8cff; } +.calendar-widget.teams { color: #6264a7; } +.calendar-widget.setup, +.calendar-widget.error { color: #ff8a65; } + +.calendar-menu { + background: rgba(20, 20, 20, 0.85); + color: #fff; + min-width: 320px; + padding: 8px; +} +.calendar-menu .header { font-size: 14px; padding: 6px 8px; } +.calendar-menu .item { padding: 6px 8px; border-radius: 4px; } +.calendar-menu .item.live { color: #f5a; font-weight: 600; } +.calendar-menu .item .title { font-weight: 600; } +.calendar-menu .item .description { color: rgba(255,255,255,0.6); font-size: 11px; } +.calendar-menu .item .join, +.calendar-menu .item .open { padding: 2px 8px; border-radius: 3px; background: #2d8cff; color: #fff; } +``` + +State classes added to the frame: one of `loading`, `ok`, `empty`, `setup`, `error`, plus when state is `ok`: the meeting kind (`meet`/`zoom`/`teams`/`other`/`none`) and the status (`upcoming`/`live`/`ended`). diff --git a/pyproject.toml b/pyproject.toml index c3db743e..f17e958e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,9 @@ dependencies = [ "winrt.windows.devices.wifi==3.2.1", "winrt.windows.security.credentials==3.2.1", "qt-css-engine @ git+https://github.com/Video-Nomad/qt-css-engine@dev", + "google-api-python-client>=2.130", + "google-auth-oauthlib>=1.2", + "google-auth-httplib2>=0.2", ] [project.urls] diff --git a/src/core/validation/widgets/yasb/calendar.py b/src/core/validation/widgets/yasb/calendar.py new file mode 100644 index 00000000..80e52427 --- /dev/null +++ b/src/core/validation/widgets/yasb/calendar.py @@ -0,0 +1,74 @@ +from enum import StrEnum + +from pydantic import Field + +from core.validation.widgets.base_model import ( + CallbacksConfig, + CustomBaseModel, + KeybindingConfig, +) + + +class Corner(StrEnum): + TOP_LEFT = "top_left" + TOP_RIGHT = "top_right" + BOTTOM_LEFT = "bottom_left" + BOTTOM_RIGHT = "bottom_right" + + +class CalendarIconsConfig(CustomBaseModel): + meet: str = "" + zoom: str = "" + teams: str = "" + other: str = "" + none: str = "" + calendar: str = "" + + +class CalendarMenuConfig(CustomBaseModel): + blur: bool = True + round_corners: bool = True + round_corners_type: str = "normal" + border_color: str = "System" + alignment: str = "right" + direction: str = "down" + offset_top: int = 6 + offset_left: int = 0 + event_count: int = Field(default=5, ge=1, le=20) + + +class CalendarNotificationDotConfig(CustomBaseModel): + enabled: bool = True + corner: Corner = Corner.BOTTOM_LEFT + color: str = "red" + margin: list[int] = [1, 1] + threshold_minutes: int = Field(default=10, ge=0, le=240) + + +class CalendarCallbacksConfig(CallbacksConfig): + on_left: str = "toggle_menu" + on_middle: str = "join_meeting" + on_right: str = "toggle_label" + + +class CalendarConfig(CustomBaseModel): + label: str = "{icon} {title} {countdown}" + label_alt: str = "{icon} {title} at {start_time}" + class_name: str = "" + update_interval: int = Field(default=60, ge=15, le=3600) + tick_interval: int = Field(default=1000, ge=250, le=60000) + calendar_ids: list[str] = Field(default_factory=lambda: ["primary"]) + look_ahead_minutes: int = Field(default=0, ge=0, le=10080) + grace_period_minutes: int = Field(default=5, ge=0, le=120) + skip_all_day: bool = True + max_title_length: int = Field(default=30, ge=5, le=200) + hide_when_empty: bool = True + empty_label: str = "No upcoming events" + auth_label: str = "Calendar: sign in" + tooltip: bool = True + tooltip_event_count: int = Field(default=1, ge=1, le=20) + icons: CalendarIconsConfig = CalendarIconsConfig() + menu: CalendarMenuConfig = CalendarMenuConfig() + notification_dot: CalendarNotificationDotConfig = CalendarNotificationDotConfig() + keybindings: list[KeybindingConfig] = [] + callbacks: CalendarCallbacksConfig = CalendarCallbacksConfig() diff --git a/src/core/widgets/services/google_calendar/__init__.py b/src/core/widgets/services/google_calendar/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/core/widgets/services/google_calendar/auth.py b/src/core/widgets/services/google_calendar/auth.py new file mode 100644 index 00000000..2741e758 --- /dev/null +++ b/src/core/widgets/services/google_calendar/auth.py @@ -0,0 +1,117 @@ +"""OAuth helpers for the Google Calendar widget. + +Uses Google's installed-app flow — `InstalledAppFlow.run_local_server` opens the +user's browser, runs a localhost HTTP listener for the redirect, and returns +credentials. Calendar scope has no device-flow equivalent for installed apps. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from core.utils.system import app_data_path + +if TYPE_CHECKING: + from collections.abc import Callable + + from google.oauth2.credentials import Credentials + +SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"] + + +def credentials_path() -> Path: + """Path where the user drops the OAuth client secrets JSON from Google Cloud Console.""" + return app_data_path("google_calendar_credentials.json") + + +def token_path() -> Path: + """Path where the authorised user token is persisted.""" + return app_data_path("google_calendar_token.json") + + +def get_creds() -> Credentials | None: + """Load saved credentials, refreshing them if expired. Returns None when missing/invalid. + + Caller is expected to invoke `run_install_flow` if this returns None and a + credentials JSON exists at `credentials_path()`. + """ + from google.auth.transport.requests import Request + from google.oauth2.credentials import Credentials + + token = token_path() + if not token.exists(): + return None + + try: + creds = Credentials.from_authorized_user_file(str(token), SCOPES) + except Exception as e: + logging.warning("GoogleCalendarAuth: ignoring unreadable token at %s: %s", token, e) + return None + + if creds.valid: + return creds + + if creds.expired and creds.refresh_token: + try: + creds.refresh(Request()) + save_creds(creds) + return creds + except Exception as e: + logging.warning("GoogleCalendarAuth: token refresh failed: %s", e) + return None + + return None + + +def save_creds(creds: Credentials) -> None: + """Persist credentials JSON to `token_path()`.""" + path = token_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(creds.to_json(), encoding="utf-8") + except Exception as e: + logging.error("GoogleCalendarAuth: failed to save token: %s", e) + + +def run_install_flow(on_url: Callable[[str], None] | None = None) -> Credentials: + """Run Google's installed-app OAuth flow. + + Listens on a random localhost port for the redirect and blocks until the + user completes (or cancels) sign-in. When ``on_url`` is provided, the auth + URL is forwarded to it and the default browser is NOT opened automatically + — the caller is responsible for surfacing the URL to the user. Otherwise + the default browser is opened to Google's consent page as before. + """ + import webbrowser + + from google_auth_oauthlib.flow import InstalledAppFlow + + creds_file = credentials_path() + if not creds_file.exists(): + raise FileNotFoundError(f"OAuth client secrets missing at {creds_file}") + + flow = InstalledAppFlow.from_client_secrets_file(str(creds_file), SCOPES) + + if on_url is None: + creds = flow.run_local_server(port=0, open_browser=True) + else: + # google_auth_oauthlib computes the auth URL inside run_local_server + # (it depends on the random port) and reaches a browser via + # webbrowser.get(name).open(url). Intercept webbrowser.get so we can + # capture the URL and skip the auto-open. + class _CaptureBrowser: + def open(self, url: str, *_args, **_kwargs) -> bool: + on_url(url) + return True + + original_get = webbrowser.get + webbrowser.get = lambda *_a, **_kw: _CaptureBrowser() + try: + creds = flow.run_local_server(port=0, open_browser=True, authorization_prompt_message="") + finally: + webbrowser.get = original_get + + save_creds(creds) + return creds diff --git a/src/core/widgets/services/google_calendar/auth_dialog.py b/src/core/widgets/services/google_calendar/auth_dialog.py new file mode 100644 index 00000000..f4e935c4 --- /dev/null +++ b/src/core/widgets/services/google_calendar/auth_dialog.py @@ -0,0 +1,247 @@ +"""Auth dialog for the Google Calendar widget. + +Mirrors the shape of `services/github/auth_dialog.py` but drives Google's +installed-app flow (browser + localhost redirect) instead of device flow. +""" + +from __future__ import annotations + +import os +import subprocess +import threading + +from PyQt6.QtCore import Qt, QTimer, QUrl, pyqtSignal +from PyQt6.QtGui import QDesktopServices +from PyQt6.QtWidgets import ( + QApplication, + QDialog, + QFrame, + QHBoxLayout, + QLineEdit, + QVBoxLayout, +) + +from core.ui.components.button import Button +from core.ui.components.loader import Spinner +from core.ui.components.text_block import TextBlock +from core.ui.views.view_base import ViewBase +from core.utils.system import app_data_path +from core.widgets.services.google_calendar import auth as gcal_auth + + +class GoogleCalendarAuthDialog(ViewBase, QDialog): + auth_completed = pyqtSignal() + _auth_success = pyqtSignal() + _auth_error = pyqtSignal(str) + _auth_url = pyqtSignal(str) + + # state ∈ {"missing", "ready", "running", "done"} + def __init__(self, parent=None): + super().__init__(parent) + self._stop = False + self._state = "missing" + self._flow_thread: threading.Thread | None = None + self._auth_url_value = "" + + self._build_window() + self._build_ui() + + self._auth_success.connect(self._finish_success) + self._auth_error.connect(self._finish_error) + self._auth_url.connect(self._show_url) + + QTimer.singleShot(0, self._render_initial_state) + + def _build_window(self) -> None: + self.setWindowTitle("Google Calendar Authorization - YASB") + self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False) + self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True) + self.setWindowFlag(Qt.WindowType.Window, True) + self.setWindowFlag(Qt.WindowType.CustomizeWindowHint, True) + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self.setFixedSize(480, 280) + self.build_view() + self.build_app_icon() + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 20, 24, 16) + layout.setSpacing(8) + + self._title_label = TextBlock("Sign in to Google Calendar", variant="subtitle", parent=self) + layout.addWidget(self._title_label) + + self._instructions = TextBlock("", variant="caption", parent=self) + self._instructions.setWordWrap(True) + layout.addWidget(self._instructions) + + url_row = QHBoxLayout() + url_row.setContentsMargins(0, 6, 0, 0) + url_row.setSpacing(6) + self._url_field = QLineEdit(self) + self._url_field.setReadOnly(True) + self._url_field.setPlaceholderText("Authorization URL will appear here…") + self._url_field.hide() + url_row.addWidget(self._url_field, 1) + self._copy_btn = Button("Copy", font_size=12, font_weight="demibold", parent=self) + self._copy_btn.clicked.connect(self._copy_url) + self._copy_btn.hide() + url_row.addWidget(self._copy_btn) + self._open_url_btn = Button("Open", font_size=12, font_weight="demibold", parent=self) + self._open_url_btn.clicked.connect(self._open_url) + self._open_url_btn.hide() + url_row.addWidget(self._open_url_btn) + layout.addLayout(url_row) + + spinner_row = QHBoxLayout() + spinner_row.setContentsMargins(0, 4, 0, 0) + self._spinner = Spinner(size=24, parent=self) + self._spinner.hide() + spinner_row.addWidget(self._spinner) + spinner_row.addStretch() + layout.addLayout(spinner_row) + + layout.addStretch() + + self._separator = QFrame() + self._separator.setFixedHeight(1) + self._separator.setStyleSheet("background-color: rgba(255,255,255,0.08);") + layout.addWidget(self._separator) + layout.addSpacing(4) + + btn_row = QHBoxLayout() + btn_row.setSpacing(8) + btn_row.addStretch() + + self._secondary_btn = Button("Open Folder", font_size=12, font_weight="demibold", parent=self) + self._secondary_btn.clicked.connect(self._open_folder) + btn_row.addWidget(self._secondary_btn) + + self._primary_btn = Button("Sign In", variant="accent", font_size=12, font_weight="demibold", parent=self) + self._primary_btn.clicked.connect(self._on_primary_clicked) + btn_row.addWidget(self._primary_btn) + + self._cancel_btn = Button("Cancel", font_size=12, font_weight="demibold", parent=self) + self._cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(self._cancel_btn) + + layout.addLayout(btn_row) + + def _render_initial_state(self) -> None: + if not gcal_auth.credentials_path().exists(): + self._render_missing() + else: + self._render_ready() + + def _render_missing(self) -> None: + self._state = "missing" + self._instructions.setText( + "OAuth client secrets file not found. Place your credentials JSON " + f"from Google Cloud Console at:\n{gcal_auth.credentials_path()}" + ) + self._spinner.hide() + self._primary_btn.setText("Recheck") + self._primary_btn.setEnabled(True) + self._secondary_btn.show() + + def _render_ready(self) -> None: + self._state = "ready" + self._instructions.setText( + "Click Sign In to generate an authorisation URL. You can copy it into " + "an incognito window if your default browser session is blocking sign-in." + ) + self._spinner.hide() + self._url_field.hide() + self._copy_btn.hide() + self._open_url_btn.hide() + self._primary_btn.setText("Sign In") + self._primary_btn.setEnabled(True) + self._secondary_btn.hide() + + def _render_running(self) -> None: + self._state = "running" + self._instructions.setText("Generating authorisation URL…") + self._spinner.show() + self._url_field.hide() + self._copy_btn.hide() + self._open_url_btn.hide() + self._primary_btn.setEnabled(False) + self._secondary_btn.hide() + + def _on_primary_clicked(self) -> None: + if self._state == "missing": + self._render_initial_state() + elif self._state == "ready": + self._start_flow() + + def _open_folder(self) -> None: + folder = app_data_path() + try: + os.startfile(str(folder)) # type: ignore[attr-defined] # Windows-only + except Exception: + subprocess.Popen(["explorer", str(folder)]) + + def _start_flow(self) -> None: + self._render_running() + + def _run() -> None: + try: + gcal_auth.run_install_flow(on_url=self._auth_url.emit) + if not self._stop: + self._auth_success.emit() + except Exception as exc: + if not self._stop: + self._auth_error.emit(str(exc)) + + self._flow_thread = threading.Thread(target=_run, daemon=True) + self._flow_thread.start() + + def _show_url(self, url: str) -> None: + self._auth_url_value = url + self._instructions.setText( + "Open this URL to sign in. Use an incognito window if your default " + "browser session is causing problems. This dialog will close once " + "sign-in completes." + ) + self._url_field.setText(url) + self._url_field.setCursorPosition(0) + self._url_field.show() + self._copy_btn.show() + self._open_url_btn.show() + + def _copy_url(self) -> None: + if not self._auth_url_value: + return + QApplication.clipboard().setText(self._auth_url_value) + self._copy_btn.setText("Copied") + QTimer.singleShot(1500, lambda: self._copy_btn.setText("Copy")) + + def _open_url(self) -> None: + if not self._auth_url_value: + return + QDesktopServices.openUrl(QUrl(self._auth_url_value)) + + def _finish_success(self) -> None: + self._state = "done" + self._show_result_page("Signed in successfully.") + self.auth_completed.emit() + QTimer.singleShot(1200, self.accept) + + def _finish_error(self, message: str) -> None: + self._state = "done" + self._show_result_page(f"Sign-in failed:\n{message}") + + def _show_result_page(self, message: str) -> None: + self._title_label.hide() + self._instructions.setText(message) + self._spinner.hide() + self._url_field.hide() + self._copy_btn.hide() + self._open_url_btn.hide() + self._primary_btn.hide() + self._secondary_btn.hide() + self._cancel_btn.setText("Close") + + def closeEvent(self, event): + self._stop = True + super().closeEvent(event) diff --git a/src/core/widgets/yasb/calendar.py b/src/core/widgets/yasb/calendar.py new file mode 100644 index 00000000..e592a244 --- /dev/null +++ b/src/core/widgets/yasb/calendar.py @@ -0,0 +1,685 @@ +import logging +import re +from datetime import UTC, datetime, timedelta +from typing import Any + +from PyQt6.QtCore import QObject, QPoint, QRunnable, Qt, QThreadPool, QTimer, QUrl, pyqtSignal +from PyQt6.QtGui import QColor, QDesktopServices, QPainter, QPaintEvent +from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QScrollArea, QVBoxLayout, QWidget + +from core.ui.components.loader import LoaderLine +from core.utils.tooltip import set_tooltip +from core.utils.utilities import PopupWidget, refresh_widget_style +from core.validation.widgets.yasb.calendar import CalendarConfig, Corner +from core.widgets.base import BaseWidget +from core.widgets.services.google_calendar import auth as gcal_auth +from core.widgets.services.google_calendar.auth_dialog import GoogleCalendarAuthDialog + +ZOOM_RE = re.compile(r"https://[\w.-]+\.zoom\.us/(?:j|my|w)/[^\s<>\"'\)]+") +TEAMS_RE = re.compile(r"https://teams\.microsoft\.com/l/meetup-join/[^\s<>\"'\)]+") +MEET_RE = re.compile(r"https://meet\.google\.com/[a-z]{3}-[a-z]{4}-[a-z]{3}", re.I) + + +def _classify_url(url: str) -> str: + if "meet.google.com" in url: + return "meet" + if "zoom.us" in url: + return "zoom" + if "teams.microsoft.com" in url or "teams.live.com" in url: + return "teams" + return "other" + + +def extract_meeting_url(event: dict[str, Any]) -> tuple[str | None, str]: + """Pick the best join URL out of a Google Calendar event payload. + + Priority: hangoutLink → conferenceData video entry point → regex over + location + description. Returns (url, kind) where kind ∈ + {"meet","zoom","teams","other","none"}. + """ + hangout = event.get("hangoutLink") + if hangout: + return hangout, "meet" + + conf = event.get("conferenceData") or {} + for ep in conf.get("entryPoints") or []: + if ep.get("entryPointType") == "video": + uri = ep.get("uri") + if uri: + return uri, _classify_url(uri) + + haystack = (event.get("location") or "") + "\n" + (event.get("description") or "") + for rx, kind in ((MEET_RE, "meet"), (ZOOM_RE, "zoom"), (TEAMS_RE, "teams")): + m = rx.search(haystack) + if m: + return m.group(0), kind + + return None, "none" + + +def _parse_event_time(slot: dict[str, Any] | None) -> datetime | None: + if not slot: + return None + if "dateTime" in slot: + return datetime.fromisoformat(slot["dateTime"].replace("Z", "+00:00")) + if "date" in slot: + return datetime.fromisoformat(slot["date"]).replace(tzinfo=UTC) + return None + + +def _format_countdown(now: datetime, start: datetime, end: datetime) -> tuple[str, str]: + """Return (countdown_text, status). status ∈ {upcoming, live, ended}.""" + if now >= end: + return "ended", "ended" + if now >= start: + elapsed = int((now - start).total_seconds() // 60) + if elapsed <= 0: + return "now", "live" + return f"started {elapsed}m ago", "live" + delta = start - now + secs = int(delta.total_seconds()) + if secs < 60: + return "in <1m", "upcoming" + mins = secs // 60 + if mins < 60: + return f"in {mins}m", "upcoming" + hours, rem = divmod(mins, 60) + if rem == 0: + return f"in {hours}h", "upcoming" + return f"in {hours}h {rem}m", "upcoming" + + +class _NotificationLabel(QLabel): + """QLabel that can paint a coloured dot in any corner.""" + + def __init__(self, *args: Any, color: str, corner: Corner, margin: list[int], **kwargs: Any): + super().__init__(*args, **kwargs) + self._show_dot = False + self._color = color + self._corner = corner + self._margin = margin + + def show_dot(self, enabled: bool) -> None: + if enabled == self._show_dot: + return + self._show_dot = enabled + self.update() + + def paintEvent(self, a0: QPaintEvent | None) -> None: + super().paintEvent(a0) + if not self._show_dot: + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setBrush(QColor(self._color)) + painter.setPen(Qt.PenStyle.NoPen) + radius = 6 + mx, my = self._margin[0], self._margin[1] + if self._corner == Corner.TOP_LEFT: + x, y = mx, my + elif self._corner == Corner.TOP_RIGHT: + x, y = self.width() - radius - mx, my + elif self._corner == Corner.BOTTOM_LEFT: + x, y = mx, self.height() - radius - my + else: # BOTTOM_RIGHT + x, y = self.width() - radius - mx, self.height() - radius - my + painter.drawEllipse(QPoint(x + radius // 2, y + radius // 2), radius // 2, radius // 2) + + +class _FetchSignals(QObject): + events_ready = pyqtSignal(list) + no_event = pyqtSignal() + needs_setup = pyqtSignal() + error = pyqtSignal(str) + + +class _FetchTask(QRunnable): + def __init__(self, config: CalendarConfig, signals: _FetchSignals): + super().__init__() + self._config = config + self._signals = signals + self.setAutoDelete(True) + + def run(self) -> None: + try: + try: + from googleapiclient.discovery import build + except ImportError as e: + self._signals.error.emit(f"Missing google API deps: {e}") + return + + creds = gcal_auth.get_creds() + if creds is None: + self._signals.needs_setup.emit() + return + + service = build( + "calendar", + "v3", + credentials=creds, + cache_discovery=False, + static_discovery=False, + ) + now = datetime.now(UTC) + grace = timedelta(minutes=self._config.grace_period_minutes) + time_min = (now - grace).isoformat() + cutoff = None + if self._config.look_ahead_minutes > 0: + cutoff = now + timedelta(minutes=self._config.look_ahead_minutes) + + collected: list[tuple[datetime, dict[str, Any]]] = [] + for cid in self._config.calendar_ids: + list_kwargs: dict[str, Any] = dict( + calendarId=cid, + timeMin=time_min, + maxResults=10, + singleEvents=True, + orderBy="startTime", + ) + if cutoff is not None: + list_kwargs["timeMax"] = cutoff.isoformat() + try: + events = service.events().list(**list_kwargs).execute() + except Exception as e: + logging.warning("CalendarWidget: list failed for %s: %s", cid, e) + continue + for ev in events.get("items", []): + start_slot = ev.get("start") or {} + end_slot = ev.get("end") or {} + if self._config.skip_all_day and "dateTime" not in start_slot: + continue + start = _parse_event_time(start_slot) + end = _parse_event_time(end_slot) + if not start or not end or end <= now: + continue + url, kind = extract_meeting_url(ev) + collected.append( + ( + start, + { + "title": ev.get("summary", "(no title)"), + "start": start.isoformat(), + "end": end.isoformat(), + "meeting_url": url, + "meeting_kind": kind, + "html_link": ev.get("htmlLink", ""), + "location": ev.get("location", "") or "", + "calendar_id": cid, + }, + ) + ) + + collected.sort(key=lambda pair: pair[0]) + top = [ev for _, ev in collected[: self._config.menu.event_count]] + if top: + self._signals.events_ready.emit(top) + else: + self._signals.no_event.emit() + except Exception as e: + logging.exception("CalendarWidget: fetch failed") + self._signals.error.emit(str(e)) + + +class CalendarWidget(BaseWidget): + validation_schema = CalendarConfig + + def __init__(self, config: CalendarConfig): + super().__init__( + timer_interval=config.update_interval * 1000, + class_name=f"calendar-widget {config.class_name}".strip(), + ) + self.config = config + self._label_content = config.label + self._label_alt_content = config.label_alt + self._show_alt_label = False + self._upcoming_events: list[dict[str, Any]] = [] + self._state: str = "loading" + self._error: str | None = None + self._fetch_in_flight = False + self._auth_dialog: GoogleCalendarAuthDialog | None = None + self._menu: PopupWidget | None = None + self._dot_labels: list[_NotificationLabel] = [] + self._dot_labels_alt: list[_NotificationLabel] = [] + + self._init_container() + self._build_labels(self._label_content, self._label_alt_content) + + self._loader_line = LoaderLine(self) + self._loader_line.attach_to_widget(self._widget_frame) + + self._signals = _FetchSignals() + self._signals.events_ready.connect(self._on_events_ready) + self._signals.no_event.connect(self._on_no_event) + self._signals.needs_setup.connect(self._on_needs_setup) + self._signals.error.connect(self._on_error) + self._pool = QThreadPool.globalInstance() + + self._tick_timer = QTimer(self) + self._tick_timer.timeout.connect(self._on_tick) + self._tick_timer.start(config.tick_interval) + + self.register_callback("join_meeting", self._cb_join_meeting) + self.register_callback("open_event", self._cb_open_event) + self.register_callback("toggle_label", self._cb_toggle_label) + self.register_callback("refresh", self._cb_refresh) + self.register_callback("toggle_menu", self._cb_toggle_menu) + self.callback_left = config.callbacks.on_left + self.callback_middle = config.callbacks.on_middle + self.callback_right = config.callbacks.on_right + self.callback_timer = "refresh" + + self.start_timer() + + # ---- label construction -------------------------------------------- + + def _build_labels(self, content: str, content_alt: str) -> None: + self._widgets = self._make_label_row(content, is_alt=False) + self._widgets_alt = self._make_label_row(content_alt, is_alt=True) + + def _make_label_row(self, content: str, is_alt: bool) -> list[QLabel]: + widgets: list[QLabel] = [] + for raw in re.split(r"(.*?)", content): + part = raw.strip() + if not part: + continue + if "" in part: + m = re.search(r'class=(["\'])([^"\']+?)\1', part) + cls = m.group(2) if m else "icon" + inner = re.sub(r"|", "", part).strip() + lbl = _NotificationLabel( + inner, + color=self.config.notification_dot.color, + corner=self.config.notification_dot.corner, + margin=self.config.notification_dot.margin, + ) + lbl.setProperty("class", cls) + if is_alt: + self._dot_labels_alt.append(lbl) + else: + self._dot_labels.append(lbl) + else: + lbl = QLabel(part) + lbl.setProperty("class", "label alt" if is_alt else "label") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._widget_container_layout.addWidget(lbl) + widgets.append(lbl) + lbl.setVisible(not is_alt) + return widgets + + # ---- fetch lifecycle ------------------------------------------------ + + def _cb_refresh(self) -> None: + if self._fetch_in_flight: + return + self._fetch_in_flight = True + self._loader_line.start() + self._pool.start(_FetchTask(self.config, self._signals)) + + def _on_events_ready(self, events: list[dict[str, Any]]) -> None: + self._fetch_in_flight = False + self._loader_line.stop() + self._upcoming_events = events + self._state = "ok" + self._error = None + self._update_label() + + def _on_no_event(self) -> None: + self._fetch_in_flight = False + self._loader_line.stop() + self._upcoming_events = [] + self._state = "empty" + self._error = None + self._update_label() + + def _on_needs_setup(self) -> None: + self._fetch_in_flight = False + self._loader_line.stop() + self._upcoming_events = [] + self._state = "setup" + self._error = None + self._update_label() + + def _on_error(self, msg: str) -> None: + self._fetch_in_flight = False + self._loader_line.stop() + self._error = msg + if not self._upcoming_events: + self._state = "error" + self._update_label() + + def _on_tick(self) -> None: + if self._state == "ok" and self._upcoming_events: + self._update_label() + + # ---- rendering ------------------------------------------------------ + + def _build_tokens(self) -> dict[str, str]: + if not self._upcoming_events: + return {} + ev = self._upcoming_events[0] + now = datetime.now(UTC) + start = datetime.fromisoformat(ev["start"]) + end = datetime.fromisoformat(ev["end"]) + countdown, status = _format_countdown(now, start, end) + kind = ev["meeting_kind"] + icons = self.config.icons.model_dump() + icon = icons.get(kind) or icons.get("calendar") or "" + title = ev["title"] or "(no title)" + max_len = self.config.max_title_length + if len(title) > max_len: + title = title[: max_len - 1] + "…" + try: + start_time = start.astimezone().strftime("%H:%M") + except Exception: + start_time = "??:??" + return { + "{title}": title, + "{start_time}": start_time, + "{countdown}": countdown, + "{status}": status, + "{meeting_kind}": kind, + "{icon}": icon, + } + + def _frame_classes(self) -> str: + parts = ["widget", "calendar-widget", self.config.class_name, self._state] + if self._state == "ok" and self._upcoming_events: + ev = self._upcoming_events[0] + parts.append(ev.get("meeting_kind", "none")) + try: + now = datetime.now(UTC) + start = datetime.fromisoformat(ev["start"]) + end = datetime.fromisoformat(ev["end"]) + _, status = _format_countdown(now, start, end) + parts.append(status) + except Exception: + pass + if self._error and self._state == "ok": + parts.append("stale") + return " ".join(p for p in parts if p) + + def _should_show_dot(self) -> bool: + if not self.config.notification_dot.enabled: + return False + if self._state != "ok" or not self._upcoming_events: + return False + ev = self._upcoming_events[0] + try: + now = datetime.now(UTC) + start = datetime.fromisoformat(ev["start"]) + end = datetime.fromisoformat(ev["end"]) + except Exception: + return False + if now >= start and now < end: + return True + threshold = timedelta(minutes=self.config.notification_dot.threshold_minutes) + return start - now <= threshold + + def _update_label(self) -> None: + if self._state == "empty" and self.config.hide_when_empty: + self.hide() + return + if not self.isVisible(): + self.show() + + if self._state == "ok": + tpl = self._label_alt_content if self._show_alt_label else self._label_content + tokens = self._build_tokens() + elif self._state == "setup": + tpl, tokens = self.config.auth_label, {} + elif self._state == "empty": + tpl, tokens = self.config.empty_label, {} + else: # loading / error + tpl, tokens = self.config.empty_label, {} + + rendered = tpl + for k, v in tokens.items(): + rendered = rendered.replace(k, str(v)) + + active_widgets = ( + self._widgets_alt if self._show_alt_label and self._widgets_alt and self._state == "ok" else self._widgets + ) + if active_widgets: + parts = re.split(r"(.*?)", rendered) + parts = [p for p in parts if p and p.strip()] + for i, part in enumerate(parts): + if i >= len(active_widgets): + break + if "" in part: + inner = re.sub(r"|", "", part).strip() + active_widgets[i].setText(inner) + else: + active_widgets[i].setText(part) + for j in range(len(parts), len(active_widgets)): + active_widgets[j].setText("") + + show_dot = self._should_show_dot() + active_dots = self._dot_labels_alt if self._show_alt_label else self._dot_labels + for dot in active_dots: + dot.show_dot(show_dot) + + self._widget_frame.setProperty("class", self._frame_classes()) + refresh_widget_style(self._widget_frame) + + if self.config.tooltip: + self._update_tooltip() + + def _update_tooltip(self) -> None: + if self._state == "setup": + set_tooltip(self, "Google Calendar: click to sign in") + return + if self._state == "error": + set_tooltip(self, f"Calendar error:
{self._error or 'unknown'}") + return + if not self._upcoming_events: + set_tooltip(self, self.config.empty_label) + return + blocks: list[str] = [] + for ev in self._upcoming_events[: self.config.tooltip_event_count]: + try: + start = datetime.fromisoformat(ev["start"]).astimezone() + end = datetime.fromisoformat(ev["end"]).astimezone() + when = f"{start.strftime('%a %b %d, %H:%M')} – {end.strftime('%H:%M')}" + except Exception: + when = "" + lines = [f"{ev['title']}", when] + if ev.get("location"): + lines.append(f"@ {ev['location']}") + blocks.append("
".join(line for line in lines if line)) + set_tooltip(self, "

".join(blocks)) + + # ---- click handlers ------------------------------------------------- + + def _cb_toggle_label(self) -> None: + self._show_alt_label = not self._show_alt_label + for w in self._widgets: + w.setVisible(not self._show_alt_label) + for w in self._widgets_alt: + w.setVisible(self._show_alt_label) + self._update_label() + + def _cb_join_meeting(self) -> None: + if self._state == "setup": + self._open_auth_dialog() + return + if not self._upcoming_events: + return + ev = self._upcoming_events[0] + url = ev.get("meeting_url") or ev.get("html_link") + if url: + QDesktopServices.openUrl(QUrl(url)) + + def _cb_open_event(self) -> None: + if not self._upcoming_events: + return + ev = self._upcoming_events[0] + if ev.get("html_link"): + QDesktopServices.openUrl(QUrl(ev["html_link"])) + + def _cb_toggle_menu(self) -> None: + if self._menu is not None and self._menu.isVisible(): + self._menu.hide() + return + if self._state == "setup": + self._open_auth_dialog() + return + self._show_menu() + + # ---- auth dialog ---------------------------------------------------- + + def _open_auth_dialog(self) -> None: + if self._auth_dialog is not None: + self._auth_dialog.raise_() + self._auth_dialog.activateWindow() + return + self._auth_dialog = GoogleCalendarAuthDialog() + self._auth_dialog.auth_completed.connect(self._on_auth_completed) + self._auth_dialog.finished.connect(self._on_auth_dialog_closed) + self._auth_dialog.show() + + def _on_auth_completed(self) -> None: + self._cb_refresh() + + def _on_auth_dialog_closed(self, *_: Any) -> None: + self._auth_dialog = None + + # ---- popup menu ----------------------------------------------------- + + def _show_menu(self) -> None: + self._menu = PopupWidget( + self, + self.config.menu.blur, + self.config.menu.round_corners, + self.config.menu.round_corners_type, + self.config.menu.border_color, + ) + self._menu.setProperty("class", "calendar-menu") + + main_layout = QVBoxLayout(self._menu) + main_layout.setSpacing(0) + main_layout.setContentsMargins(0, 0, 0, 0) + + header = QLabel("Google Calendar") + header.setProperty("class", "header") + main_layout.addWidget(header) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll.setStyleSheet( + """ + QScrollArea { background: transparent; border: none; border-radius:0; } + QScrollBar:vertical { border: none; background: transparent; width: 4px; } + QScrollBar::handle:vertical { background: rgba(255,255,255,0.2); min-height: 10px; border-radius: 2px; } + QScrollBar::handle:vertical:hover { background: rgba(255,255,255,0.35); } + QScrollBar::sub-line:vertical, QScrollBar::add-line:vertical { height: 0; } + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; } + """ + ) + main_layout.addWidget(scroll) + + body = QWidget() + body.setProperty("class", "contents") + body_layout = QVBoxLayout(body) + body_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + body_layout.setContentsMargins(0, 0, 0, 0) + body_layout.setSpacing(0) + + events = self._upcoming_events + if events: + section = QFrame() + section.setProperty("class", "section") + section_layout = QVBoxLayout(section) + section_layout.setContentsMargins(0, 0, 0, 0) + section_layout.setSpacing(0) + count = len(events) + for index, ev in enumerate(events): + pos: list[str] = [] + if index == 0: + pos.append("first") + if index == count - 1: + pos.append("last") + section_layout.addWidget(self._build_menu_row(ev, pos, parent=section)) + body_layout.addWidget(section) + else: + empty = QLabel("No upcoming events") + empty.setProperty("class", "empty") + empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + body_layout.addWidget(empty) + + scroll.setWidget(body) + + self._menu.adjustSize() + self._menu.setPosition( + alignment=self.config.menu.alignment, + direction=self.config.menu.direction, + offset_left=self.config.menu.offset_left, + offset_top=self.config.menu.offset_top, + ) + self._menu.show() + + def _build_menu_row(self, ev: dict[str, Any], position_classes: list[str], parent: QWidget) -> QFrame: + kind = ev.get("meeting_kind", "none") + icons = self.config.icons.model_dump() + icon_text = icons.get(kind) or icons.get("calendar") or "" + + try: + now = datetime.now(UTC) + start = datetime.fromisoformat(ev["start"]) + end = datetime.fromisoformat(ev["end"]) + when = f"{start.astimezone().strftime('%a %H:%M')} – {end.astimezone().strftime('%H:%M')}" + countdown, status = _format_countdown(now, start, end) + except Exception: + when = "" + countdown, status = "", "upcoming" + + classes = ["item", kind, status, *position_classes] + container = QFrame(parent) + container.setProperty("class", " ".join(dict.fromkeys(c for c in classes if c))) + + row = QHBoxLayout(container) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(0) + + if icon_text: + icon_label = QLabel(icon_text) + icon_label.setProperty("class", f"icon {kind}") + row.addWidget(icon_label) + + text = QWidget() + text_layout = QVBoxLayout(text) + text_layout.setContentsMargins(0, 0, 0, 0) + text_layout.setSpacing(0) + title_label = QLabel(ev["title"] or "(no title)") + title_label.setProperty("class", "title") + text_layout.addWidget(title_label) + meta_label = QLabel(f"{when} • {countdown}".strip(" •")) + meta_label.setProperty("class", "description") + text_layout.addWidget(meta_label) + row.addWidget(text, 1) + + url = ev.get("meeting_url") + link = ev.get("html_link") or "" + if url: + join = QLabel("Join") + join.setProperty("class", "join") + join.setCursor(Qt.CursorShape.PointingHandCursor) + join.mousePressEvent = lambda _e, u=url: self._on_menu_link(u) + row.addWidget(join) + elif link: + open_lbl = QLabel("Open") + open_lbl.setProperty("class", "open") + open_lbl.setCursor(Qt.CursorShape.PointingHandCursor) + open_lbl.mousePressEvent = lambda _e, u=link: self._on_menu_link(u) + row.addWidget(open_lbl) + + # Click anywhere on the row opens the event in Google Calendar + if link: + container.mousePressEvent = lambda _e, u=link: self._on_menu_link(u) + + return container + + def _on_menu_link(self, url: str) -> None: + QDesktopServices.openUrl(QUrl(url)) + if self._menu is not None: + self._menu.hide()