ccal 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: ccal
3
+ Version: 0.1.4
4
+ Summary: CLI tool for adding calendar events
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: google-api-python-client>=2.0
7
+ Requires-Dist: google-auth-oauthlib>=1.0
8
+ Requires-Dist: icalendar>=6.0
9
+ Requires-Dist: keyring>=25.0
10
+ Requires-Dist: litellm>=1.0
11
+ Requires-Dist: pydantic>=2.13.1
12
+ Requires-Dist: rich>=13.0
13
+ Requires-Dist: typer>=0.24.1
14
+ Provides-Extra: ocr
15
+ Requires-Dist: pillow>=11.0; extra == 'ocr'
16
+ Requires-Dist: pytesseract>=0.3.13; extra == 'ocr'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # ccal
20
+
21
+ [中文文档](README-zh_cn.md)
22
+
23
+ A CLI tool that turns natural language text or images into calendar events. Powered by LLMs.
24
+
25
+ ## Features
26
+
27
+ - **Text input** — describe an event in plain language, ccal parses it into structured fields
28
+ - **Image input** — extract text from screenshots/photos via OCR, then parse
29
+ - **Multi-LLM support** — works with OpenAI, Anthropic, Gemini, OpenRouter, Deepseek, Groq, Mistral, and more (via [litellm](https://github.com/BerriAI/litellm))
30
+ - **ICS export** — generate standard `.ics` files importable by any calendar app
31
+ - **Google Calendar sync** — create events directly on Google Calendar via API
32
+ - **Apple Calendar sync** — add events via AppleScript (macOS only, auto-fallback to ICS on other platforms)
33
+ - **Secure key storage** — API keys stored in your system keyring, never in plain text
34
+ - **Geolocation** — auto-detect timezone and location for accurate event scheduling
35
+ - **Stdin support** — pipe text from other commands
36
+
37
+ ## Requirements
38
+
39
+ - Python 3.12+
40
+ - [uv](https://docs.astral.sh/uv/) (recommended) or pip
41
+ - [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) (for image input)
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ git clone https://github.com/your-username/ccal.git
47
+ cd ccal
48
+ uv sync
49
+ ```
50
+
51
+ ## Quick Start
52
+
53
+ ### 1. Setup
54
+
55
+ ```bash
56
+ ccal setup
57
+ ```
58
+
59
+ This will walk you through configuring:
60
+ - LLM provider and model
61
+ - API key (stored securely in system keyring)
62
+ - Default output method (ICS file, Google Calendar, or Apple Calendar)
63
+ - Google Calendar setup, including client type, auth mode, credentials location, and Calendar ID
64
+
65
+ ### 2. Add an event
66
+
67
+ From text:
68
+
69
+ ```bash
70
+ ccal add "Team meeting tomorrow at 3pm in Conference Room A"
71
+ ```
72
+
73
+ From an image:
74
+
75
+ ```bash
76
+ ccal add flyer.png
77
+ ```
78
+
79
+ From stdin:
80
+
81
+ ```bash
82
+ echo "Lunch with Alice Friday noon" | ccal add
83
+ ```
84
+
85
+ With options:
86
+
87
+ ```bash
88
+ ccal add "Dinner Friday 7pm at Luigi's" -o google # sync to Google Calendar
89
+ ccal add "Weekly standup Mon 9am" -o apple # add to Apple Calendar (macOS)
90
+ ccal add "Weekly standup Mon 9am" -o ics # export as .ics file
91
+ ccal add "会议明天下午两点" -m anthropic/claude-sonnet-4-20250514 # use a specific model
92
+ ccal add screenshot.png -l chi_sim # OCR with Chinese language
93
+ ccal add "Demo at 2pm" -y # skip confirmation
94
+ ccal add "Demo at 2pm" --json # output as JSON
95
+ ```
96
+
97
+ ### 3. Parse only (no save)
98
+
99
+ ```bash
100
+ ccal parse "Workshop next Wednesday 10am-12pm, Room 301"
101
+ ccal parse "下周一上午10点团队周会" --json
102
+ ```
103
+
104
+ ### 4. View config
105
+
106
+ ```bash
107
+ ccal config
108
+ ```
109
+
110
+ ## Commands
111
+
112
+ | Command | Description |
113
+ |---------|-------------|
114
+ | `ccal add [text\|image]` | Parse input and create a calendar event |
115
+ | `ccal parse [text\|image]` | Parse and display event fields without saving |
116
+ | `ccal setup` | Interactive configuration wizard |
117
+ | `ccal config` | Show current configuration and platform info |
118
+
119
+ ### `ccal add` options
120
+
121
+ | Option | Description |
122
+ |--------|-------------|
123
+ | `-o`, `--output` | Output method: `ics`, `google`, or `apple` |
124
+ | `-p`, `--provider` | LLM provider name |
125
+ | `-m`, `--model` | LLM model (e.g. `openai/gpt-4o`) |
126
+ | `-y`, `--yes` | Skip confirmation, output directly |
127
+ | `-l`, `--language` | OCR language (e.g. `chi_sim`, `eng+chi_sim`) |
128
+ | `--json` | Output parsed event as JSON |
129
+
130
+ ## Platform Support
131
+
132
+ | Feature | macOS | Linux | Windows |
133
+ |---------|-------|-------|---------|
134
+ | ICS export | ✅ | ✅ | ✅ |
135
+ | Google Calendar | ✅ | ✅ | ✅ |
136
+ | Apple Calendar | ✅ | ❌ fallback to ICS | ❌ fallback to ICS |
137
+
138
+ ## Configuration
139
+
140
+ Config is stored at `~/.config/ccal/config.toml`. API keys are stored in your system's native keyring (macOS Keychain / Linux Secret Service / Windows Credential Locker).
141
+
142
+ For Google Calendar integration, ccal uses two different local files:
143
+
144
+ - `google_credentials.json`: the OAuth client credentials JSON downloaded from Google Cloud Console. This contains the client id and client secret. Keep this file around after setup.
145
+ - `google_token_*.json`: the cached login token created after the first successful authorization. The access token inside can expire and refresh automatically, so you usually do not need to touch it. ccal picks the cache file based on the current credentials path and auth mode.
146
+
147
+ During `ccal setup`, place the credentials JSON in the configured directory, or point setup directly at the JSON file. The setup tutorial also explains:
148
+
149
+ - `Desktop app` vs `TVs and Limited Input devices`
150
+ - `External` vs `Internal`
151
+ - `Testing` status and `Test users`
152
+ - how to find a Google Calendar ID
153
+ - setup-time validation of the selected Calendar ID
154
+
155
+ You can also configure Google Calendar during `ccal setup` through the dedicated middle step, even if your default output is not Google.
156
+
157
+ ## Project Structure
158
+
159
+ ```
160
+ src/
161
+ ├── main.py # CLI entry point (Typer)
162
+ ├── config.py # Configuration & keyring management
163
+ ├── models/
164
+ │ ├── model.py # CalendarEvent Pydantic model
165
+ │ └── llm.py # LLM parsing via litellm
166
+ ├── input/
167
+ │ ├── ocr.py # Image text extraction (pytesseract)
168
+ │ └── geo.py # IP-based geolocation for timezone
169
+ └── connections/
170
+ ├── google_calendar.py # Google Calendar API integration
171
+ ├── apple_calendar.py # Apple Calendar via AppleScript (macOS)
172
+ └── ics.py # ICS file export
173
+ ```
174
+
175
+ ## License
176
+
177
+ MIT
@@ -0,0 +1,19 @@
1
+ src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ src/config.py,sha256=vHVr4HOrkm8sMPzrB4nnZZ91CeOGKk4cF2ZtkDSls9c,4227
3
+ src/event_workflow.py,sha256=8KzuAVtFhNSVo1QMa1oOQkuTFUdCTtV07qY2jtbdsJY,15978
4
+ src/google_setup.py,sha256=h98BqPPl-P_gs59x2bQcw5XbOmVsqpI00z7YJi_Jr_w,7686
5
+ src/main.py,sha256=T34bivnMx1QGLh34InMvZ3Y57mmKb4GIL8RUZrQ8szA,15826
6
+ src/connections/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ src/connections/apple_calendar.py,sha256=PTlBtjU2jNLc-8WXurdYtxXgUJEdF0gM4tdncnjLPAw,2819
8
+ src/connections/google_calendar.py,sha256=Vk60-t2oWnLZfcjgYcRJD8-iXtVqO-U_Ru2SrAOCzLg,8727
9
+ src/connections/ics.py,sha256=R_xwaSofFBz-FCKtal0pCQtylvPgywGxrPozfAbuCdw,524
10
+ src/input/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ src/input/geo.py,sha256=cT6EPeFlGGJ-D6w86OxGm796Abh0zEL1-qYukdanBm4,1356
12
+ src/input/ocr.py,sha256=Ev83FoBFzcsQmWCyANYR2mhks3l60w0r30OAop_4s58,1402
13
+ src/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ src/models/llm.py,sha256=T6OZHlb2OzNp3GDriSLbwHmuOzIuoR-1mmTl5OXaA_Y,5445
15
+ src/models/model.py,sha256=jI1p79nIuyssy91CslCzz1RDw8W_yO7V20_Kv9bS7bU,6427
16
+ ccal-0.1.4.dist-info/METADATA,sha256=ojQcGeMcEldLOeWjmYM_Oql008KCSy3jAOl1Zgbwy8I,6083
17
+ ccal-0.1.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
18
+ ccal-0.1.4.dist-info/entry_points.txt,sha256=71MfNIq9WuIQmTlEy_N8TOBL3EfEq4FW1Ps_1Tf2uW8,39
19
+ ccal-0.1.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ccal = src.main:main
src/__init__.py ADDED
File without changes
src/config.py ADDED
@@ -0,0 +1,115 @@
1
+ import hashlib
2
+ import os
3
+ import tomllib
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import keyring
8
+
9
+ APP_NAME = "ccal"
10
+ CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
11
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
12
+ KEYRING_SERVICE = "ccal"
13
+
14
+ DEFAULT_CONFIG: dict[str, Any] = {
15
+ "llm": {
16
+ "provider": "openai",
17
+ "model": "openai/gpt-4o",
18
+ },
19
+ "output": {
20
+ "default": "ics", # "ics" or "google"
21
+ },
22
+ "google": {
23
+ "calendar_id": "primary",
24
+ "credentials_path": str(CONFIG_DIR / "google_credentials.json"),
25
+ "auth_mode": "desktop",
26
+ },
27
+ }
28
+
29
+
30
+ def load_config() -> dict[str, Any]:
31
+ """Load config from ~/.config/ccal/config.toml, falling back to defaults."""
32
+ if CONFIG_FILE.exists():
33
+ with open(CONFIG_FILE, "rb") as f:
34
+ user_config = tomllib.load(f)
35
+ # Merge with defaults
36
+ config = DEFAULT_CONFIG.copy()
37
+ for section, values in user_config.items():
38
+ if section in config and isinstance(config[section], dict):
39
+ config[section] = {**config[section], **values}
40
+ else:
41
+ config[section] = values
42
+ return config
43
+ return DEFAULT_CONFIG.copy()
44
+
45
+
46
+ def save_config(config: dict[str, Any]) -> None:
47
+ """Save config to ~/.config/ccal/config.toml."""
48
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
49
+ lines: list[str] = []
50
+ for section, values in config.items():
51
+ if isinstance(values, dict):
52
+ lines.append(f"[{section}]")
53
+ for key, val in values.items():
54
+ if isinstance(val, str):
55
+ lines.append(f'{key} = "{val}"')
56
+ elif isinstance(val, bool):
57
+ lines.append(f"{key} = {'true' if val else 'false'}")
58
+ elif isinstance(val, int | float):
59
+ lines.append(f"{key} = {val}")
60
+ lines.append("")
61
+ CONFIG_FILE.write_text("\n".join(lines))
62
+
63
+
64
+ def get_api_key(provider: str) -> str | None:
65
+ """Retrieve an API key from the system keyring."""
66
+ return keyring.get_password(KEYRING_SERVICE, f"{provider}_api_key")
67
+
68
+
69
+ def set_api_key(provider: str, key: str) -> None:
70
+ """Store an API key in the system keyring."""
71
+ keyring.set_password(KEYRING_SERVICE, f"{provider}_api_key", key)
72
+
73
+
74
+ def get_google_token_path(config: dict[str, Any] | None = None) -> Path:
75
+ """Path for cached Google OAuth token."""
76
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
77
+ cache_key = _google_token_cache_key(config)
78
+ return CONFIG_DIR / f"google_token_{cache_key}.json"
79
+
80
+
81
+ def get_google_credentials_dir(config: dict[str, Any] | None = None) -> Path:
82
+ """Directory containing Google OAuth client credentials."""
83
+ if config:
84
+ google_config = config.get("google", {})
85
+ credentials_path = google_config.get("credentials_path")
86
+ if credentials_path:
87
+ return Path(credentials_path).expanduser().parent
88
+ credentials_dir = google_config.get("credentials_dir")
89
+ if credentials_dir:
90
+ return Path(credentials_dir).expanduser()
91
+ return CONFIG_DIR
92
+
93
+
94
+ def get_google_credentials_path(config: dict[str, Any] | None = None) -> Path:
95
+ """Path for Google OAuth client credentials JSON."""
96
+ if config:
97
+ google_config = config.get("google", {})
98
+ credentials_path = google_config.get("credentials_path")
99
+ if credentials_path:
100
+ return Path(credentials_path).expanduser()
101
+ credentials_dir = google_config.get("credentials_dir")
102
+ if credentials_dir:
103
+ return Path(credentials_dir).expanduser() / "google_credentials.json"
104
+ return CONFIG_DIR / "google_credentials.json"
105
+
106
+
107
+ def _google_token_cache_key(config: dict[str, Any] | None = None) -> str:
108
+ """Derive a stable cache key from the selected Google credentials and auth mode."""
109
+ credentials_path = get_google_credentials_path(config)
110
+ auth_mode = "desktop"
111
+ if config:
112
+ auth_mode = config.get("google", {}).get("auth_mode", auth_mode)
113
+ canonical_path = str(credentials_path.expanduser().resolve(strict=False))
114
+ raw_key = f"{canonical_path}|{auth_mode}"
115
+ return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()[:16]
File without changes
@@ -0,0 +1,92 @@
1
+ import platform
2
+ import subprocess
3
+ from datetime import datetime, timedelta
4
+
5
+ from src.models.model import CalendarEvent
6
+
7
+
8
+ def is_macos() -> bool:
9
+ return platform.system() == "Darwin"
10
+
11
+
12
+ def create_event(event: CalendarEvent, calendar_name: str = "Home") -> None:
13
+ """Create an event in Apple Calendar via AppleScript. macOS only."""
14
+ if not is_macos():
15
+ raise RuntimeError(
16
+ "Apple Calendar integration is only available on macOS. "
17
+ "Use 'ics' output instead (the .ics file can be opened by any calendar app)."
18
+ )
19
+
20
+ start = _format_applescript_date(event.start_time)
21
+
22
+ if event.end_time:
23
+ end = _format_applescript_date(event.end_time)
24
+ else:
25
+ end = _format_applescript_date(event.start_time + timedelta(hours=1))
26
+
27
+ props = f'summary:"{_escape(event.title)}", start date:{start}, end date:{end}'
28
+ if event.location:
29
+ props += f', location:"{_escape(event.location)}"'
30
+ if event.description:
31
+ props += f', description:"{_escape(event.description)}"'
32
+
33
+ script = f'''
34
+ tell application "Calendar"
35
+ tell calendar "{_escape(calendar_name)}"
36
+ make new event at end with properties {{{props}}}
37
+ end tell
38
+ reload calendars
39
+ end tell
40
+ '''
41
+
42
+ result = subprocess.run(
43
+ ["osascript", "-e", script],
44
+ capture_output=True,
45
+ text=True,
46
+ timeout=15,
47
+ )
48
+
49
+ if result.returncode != 0:
50
+ error = result.stderr.strip()
51
+ if "Calendar" in error and "doesn't understand" in error:
52
+ raise RuntimeError("Apple Calendar app is not available or not responding.")
53
+ raise RuntimeError(f"AppleScript error: {error}")
54
+
55
+
56
+ def list_calendars() -> list[str]:
57
+ """List available Apple Calendar names. macOS only."""
58
+ if not is_macos():
59
+ raise RuntimeError("Apple Calendar is only available on macOS.")
60
+
61
+ script = '''
62
+ tell application "Calendar"
63
+ set calNames to {}
64
+ repeat with c in calendars
65
+ set end of calNames to name of c
66
+ end repeat
67
+ return calNames
68
+ end tell
69
+ '''
70
+ result = subprocess.run(
71
+ ["osascript", "-e", script],
72
+ capture_output=True,
73
+ text=True,
74
+ timeout=10,
75
+ )
76
+ if result.returncode != 0:
77
+ raise RuntimeError(f"Failed to list calendars: {result.stderr.strip()}")
78
+
79
+ raw = result.stdout.strip()
80
+ if not raw:
81
+ return []
82
+ return [name.strip() for name in raw.split(",")]
83
+
84
+
85
+ def _format_applescript_date(dt: datetime) -> str:
86
+ """Format a datetime for AppleScript: date "April 16, 2026 3:00:00 PM"."""
87
+ return f'date "{dt.strftime("%B %d, %Y %I:%M:%S %p")}"'
88
+
89
+
90
+ def _escape(s: str) -> str:
91
+ """Escape a string for use inside AppleScript double quotes."""
92
+ return s.replace("\\", "\\\\").replace('"', '\\"')
@@ -0,0 +1,215 @@
1
+ import json
2
+ import os
3
+ import time
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+
8
+ from google.auth.transport.requests import Request
9
+ from google.oauth2.credentials import Credentials
10
+ from google_auth_oauthlib.flow import InstalledAppFlow
11
+ from googleapiclient.discovery import build
12
+
13
+ from src.config import get_google_credentials_path, get_google_token_path, load_config
14
+ from src.models.model import CalendarEvent
15
+
16
+ SCOPES = ["https://www.googleapis.com/auth/calendar"]
17
+
18
+
19
+ def authenticate(config: dict | None = None):
20
+ """Authenticate with Google Calendar API and return the service object."""
21
+ if config is None:
22
+ config = load_config()
23
+ token_path = get_google_token_path(config)
24
+ creds_path = get_google_credentials_path(config)
25
+ auth_mode = config.get("google", {}).get("auth_mode", "desktop")
26
+
27
+ creds = None
28
+ if token_path.exists():
29
+ creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
30
+
31
+ if not creds or not creds.valid:
32
+ if creds and creds.expired and creds.refresh_token:
33
+ creds.refresh(Request())
34
+ else:
35
+ if not creds_path.exists():
36
+ raise FileNotFoundError(
37
+ f"Google OAuth credentials not found at {creds_path}. "
38
+ "Download your OAuth client credentials JSON from Google Cloud Console "
39
+ "and save it there, then run 'ccal setup' again."
40
+ )
41
+ client_config = _load_client_config(creds_path)
42
+ if auth_mode == "device":
43
+ creds = _run_device_flow(client_config)
44
+ else:
45
+ if _should_use_device_flow():
46
+ raise RuntimeError(
47
+ "This machine has no browser available for Google desktop OAuth. "
48
+ "Either run setup on a machine with a browser, or create a "
49
+ "'TVs and Limited Input devices' OAuth client and use device mode."
50
+ )
51
+ flow = InstalledAppFlow.from_client_secrets_file(str(creds_path), SCOPES)
52
+ try:
53
+ creds = flow.run_local_server(port=0)
54
+ except Exception as e:
55
+ if _looks_like_browser_error(e):
56
+ raise RuntimeError(
57
+ "No runnable browser detected for Google desktop OAuth. "
58
+ "Use a machine with a browser, or switch to device mode with a "
59
+ "'TVs and Limited Input devices' OAuth client."
60
+ ) from e
61
+ else:
62
+ raise
63
+
64
+ token_path.write_text(creds.to_json())
65
+
66
+ return build("calendar", "v3", credentials=creds)
67
+
68
+
69
+ def create_event(service, event: CalendarEvent, calendar_id: str | None = None) -> dict:
70
+ """Create an event on Google Calendar. Returns the created event."""
71
+ if calendar_id is None:
72
+ config = load_config()
73
+ calendar_id = config["google"]["calendar_id"]
74
+
75
+ google_event = event.to_google_event()
76
+ result = service.events().insert(calendarId=calendar_id, body=google_event).execute()
77
+ return result
78
+
79
+
80
+ def list_calendars(service) -> list[dict]:
81
+ """List all calendars accessible by the authenticated user."""
82
+ result = service.calendarList().list().execute()
83
+ return result.get("items", [])
84
+
85
+
86
+ def _load_client_config(creds_path) -> dict:
87
+ """Load the OAuth client configuration from the downloaded JSON file."""
88
+ with open(creds_path) as f:
89
+ data = json.load(f)
90
+ if "installed" in data:
91
+ return data["installed"]
92
+ if "web" in data:
93
+ return data["web"]
94
+ raise ValueError("Google credentials JSON must contain an 'installed' or 'web' client configuration.")
95
+
96
+
97
+ def _should_use_device_flow() -> bool:
98
+ """Prefer device flow on headless Linux environments."""
99
+ return not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
100
+
101
+
102
+ def _looks_like_browser_error(error: Exception) -> bool:
103
+ """Detect browser-launch errors from the OAuth helper."""
104
+ message = str(error).lower()
105
+ return "browser" in message or "xdg-open" in message or "open a web browser" in message
106
+
107
+
108
+ def _extract_error_name(exc: urllib.error.HTTPError) -> str | None:
109
+ """Best-effort extraction of Google OAuth error names from an HTTPError body."""
110
+ try:
111
+ body = exc.read().decode()
112
+ if not body:
113
+ return None
114
+ payload = json.loads(body)
115
+ if isinstance(payload, dict):
116
+ return payload.get("error")
117
+ except Exception:
118
+ return None
119
+ return None
120
+
121
+
122
+ def _run_device_flow(client_config: dict) -> Credentials:
123
+ """Authenticate using Google's device authorization flow."""
124
+ client_id = client_config.get("client_id")
125
+ client_secret = client_config.get("client_secret")
126
+ token_uri = client_config.get("token_uri", "https://oauth2.googleapis.com/token")
127
+
128
+ if not client_id:
129
+ raise ValueError("Google credentials JSON does not include a client_id.")
130
+
131
+ request_data = urllib.parse.urlencode({
132
+ "client_id": client_id,
133
+ "scope": " ".join(SCOPES),
134
+ }).encode()
135
+ request = urllib.request.Request(
136
+ "https://oauth2.googleapis.com/device/code",
137
+ data=request_data,
138
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
139
+ )
140
+
141
+ try:
142
+ with urllib.request.urlopen(request) as response:
143
+ payload = json.load(response)
144
+ except urllib.error.HTTPError as exc:
145
+ error_name = _extract_error_name(exc)
146
+ if exc.code == 401:
147
+ raise RuntimeError(
148
+ "Google rejected the device authorization request. "
149
+ "This usually means the JSON came from a Desktop app OAuth client, "
150
+ "but device mode requires a 'TVs and Limited Input devices' OAuth client."
151
+ ) from exc
152
+ if exc.code == 403 and error_name == "org_internal":
153
+ raise RuntimeError(
154
+ "Google blocked this OAuth client because the project is restricted to an organization. "
155
+ "Set the OAuth consent screen user type to External, or sign in with an account in that organization."
156
+ ) from exc
157
+ raise
158
+
159
+ device_code = payload["device_code"]
160
+ user_code = payload["user_code"]
161
+ verification_url = payload.get("verification_url") or payload.get("verification_uri")
162
+ interval = int(payload.get("interval", 5))
163
+ expires_in = int(payload.get("expires_in", 1800))
164
+
165
+ print("\n[yellow]Headless login mode:[/yellow]")
166
+ print(f" Visit: {verification_url}")
167
+ print(f" Code: {user_code}")
168
+
169
+ deadline = time.time() + expires_in
170
+ poll_data = {
171
+ "client_id": client_id,
172
+ "client_secret": client_secret or "",
173
+ "device_code": device_code,
174
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
175
+ }
176
+
177
+ while time.time() < deadline:
178
+ time.sleep(interval)
179
+ token_request = urllib.request.Request(
180
+ token_uri,
181
+ data=urllib.parse.urlencode(poll_data).encode(),
182
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
183
+ )
184
+ try:
185
+ with urllib.request.urlopen(token_request) as response:
186
+ token_payload = json.load(response)
187
+ access_token = token_payload["access_token"]
188
+ refresh_token = token_payload.get("refresh_token")
189
+ token = Credentials(
190
+ token=access_token,
191
+ refresh_token=refresh_token,
192
+ token_uri=token_uri,
193
+ client_id=client_id,
194
+ client_secret=client_secret,
195
+ scopes=SCOPES,
196
+ )
197
+ return token
198
+ except urllib.error.HTTPError as exc:
199
+ error_name = _extract_error_name(exc)
200
+
201
+ if error_name in {"authorization_pending", "slow_down"}:
202
+ if error_name == "slow_down":
203
+ interval += 5
204
+ continue
205
+ if error_name == "access_denied":
206
+ raise RuntimeError(
207
+ "Google blocked the authorization request. "
208
+ "If the OAuth consent screen is in Testing, add this Google account to the Test users list. "
209
+ "If the project is Internal, switch it to External or use an account inside the organization."
210
+ ) from exc
211
+ if error_name == "expired_token":
212
+ raise RuntimeError("Google device authorization expired. Please run setup again.")
213
+ raise RuntimeError(f"Google device authorization failed: {error_name or exc}")
214
+
215
+ raise RuntimeError("Google device authorization timed out. Please run setup again.")
src/connections/ics.py ADDED
@@ -0,0 +1,16 @@
1
+ from pathlib import Path
2
+
3
+ from src.models.model import CalendarEvent
4
+
5
+
6
+ def export_to_ics(event: CalendarEvent, output_path: str | None = None) -> str:
7
+ """Export a CalendarEvent to an .ics file. Returns the output file path."""
8
+ cal = event.to_ical()
9
+
10
+ if output_path is None:
11
+ safe_title = "".join(c if c.isalnum() or c in " -_" else "_" for c in event.title)
12
+ output_path = f"{safe_title.strip()}.ics"
13
+
14
+ path = Path(output_path)
15
+ path.write_bytes(cal.to_ical())
16
+ return str(path.resolve())