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.
- ccal-0.1.4.dist-info/METADATA +177 -0
- ccal-0.1.4.dist-info/RECORD +19 -0
- ccal-0.1.4.dist-info/WHEEL +4 -0
- ccal-0.1.4.dist-info/entry_points.txt +2 -0
- src/__init__.py +0 -0
- src/config.py +115 -0
- src/connections/__init__.py +0 -0
- src/connections/apple_calendar.py +92 -0
- src/connections/google_calendar.py +215 -0
- src/connections/ics.py +16 -0
- src/event_workflow.py +431 -0
- src/google_setup.py +158 -0
- src/input/__init__.py +0 -0
- src/input/geo.py +41 -0
- src/input/ocr.py +42 -0
- src/main.py +412 -0
- src/models/__init__.py +0 -0
- src/models/llm.py +119 -0
- src/models/model.py +153 -0
|
@@ -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,,
|
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())
|