remind-plugin-google-tasks 0.1.0__tar.gz
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.
- remind_plugin_google_tasks-0.1.0/.gitignore +71 -0
- remind_plugin_google_tasks-0.1.0/PKG-INFO +82 -0
- remind_plugin_google_tasks-0.1.0/README.md +64 -0
- remind_plugin_google_tasks-0.1.0/pyproject.toml +40 -0
- remind_plugin_google_tasks-0.1.0/src/remind_plugin_google_tasks/__init__.py +7 -0
- remind_plugin_google_tasks-0.1.0/src/remind_plugin_google_tasks/config.py +17 -0
- remind_plugin_google_tasks-0.1.0/src/remind_plugin_google_tasks/oauth.py +165 -0
- remind_plugin_google_tasks-0.1.0/src/remind_plugin_google_tasks/plugin.py +191 -0
- remind_plugin_google_tasks-0.1.0/src/remind_plugin_google_tasks/tasks_client.py +97 -0
- remind_plugin_google_tasks-0.1.0/tests/test_plugin.py +104 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
|
|
6
|
+
# Databases
|
|
7
|
+
*.db
|
|
8
|
+
*.db-wal
|
|
9
|
+
*.db-shm
|
|
10
|
+
*.sqlite
|
|
11
|
+
*.sqlite3
|
|
12
|
+
*.pgsql
|
|
13
|
+
|
|
14
|
+
# Python
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.py[cod]
|
|
17
|
+
*$py.class
|
|
18
|
+
*.so
|
|
19
|
+
.Python
|
|
20
|
+
build/
|
|
21
|
+
develop-eggs/
|
|
22
|
+
dist/
|
|
23
|
+
downloads/
|
|
24
|
+
eggs/
|
|
25
|
+
.eggs/
|
|
26
|
+
lib/
|
|
27
|
+
lib64/
|
|
28
|
+
parts/
|
|
29
|
+
sdist/
|
|
30
|
+
var/
|
|
31
|
+
wheels/
|
|
32
|
+
*.egg-info/
|
|
33
|
+
.installed.cfg
|
|
34
|
+
*.egg
|
|
35
|
+
|
|
36
|
+
# Virtual environments
|
|
37
|
+
venv/
|
|
38
|
+
env/
|
|
39
|
+
ENV/
|
|
40
|
+
.venv
|
|
41
|
+
|
|
42
|
+
# IDE
|
|
43
|
+
.vscode/
|
|
44
|
+
.idea/
|
|
45
|
+
*.swp
|
|
46
|
+
*.swo
|
|
47
|
+
*~
|
|
48
|
+
|
|
49
|
+
# Testing
|
|
50
|
+
.pytest_cache/
|
|
51
|
+
.coverage
|
|
52
|
+
htmlcov/
|
|
53
|
+
.tox/
|
|
54
|
+
|
|
55
|
+
# OS
|
|
56
|
+
.DS_Store
|
|
57
|
+
Thumbs.db
|
|
58
|
+
|
|
59
|
+
# Build artifacts
|
|
60
|
+
dist/
|
|
61
|
+
build/
|
|
62
|
+
*.egg-info/
|
|
63
|
+
|
|
64
|
+
# Logs
|
|
65
|
+
*.log
|
|
66
|
+
logs/
|
|
67
|
+
|
|
68
|
+
# Node (future web app)
|
|
69
|
+
node_modules/
|
|
70
|
+
.next/
|
|
71
|
+
out/
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: remind-plugin-google-tasks
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Google Tasks integration plugin for Remind CLI
|
|
5
|
+
Author-email: Hamza Plojovic <hello@hamzaplojovic.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: google-api-python-client>=2.100.0
|
|
9
|
+
Requires-Dist: google-auth-httplib2>=0.2.0
|
|
10
|
+
Requires-Dist: google-auth-oauthlib>=1.2.0
|
|
11
|
+
Requires-Dist: remind-plugin-base>=0.1.0
|
|
12
|
+
Requires-Dist: typer[all]>=0.9.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest-mock>=3.11.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# remind-plugin-google-tasks
|
|
20
|
+
|
|
21
|
+
Sync reminders from Remind CLI with Google Tasks.
|
|
22
|
+
|
|
23
|
+
When you create a reminder in Remind, it automatically appears in your Google Task list. Keep your tasks organized in one place.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Install the plugin
|
|
29
|
+
pip install remind-plugin-google-tasks
|
|
30
|
+
|
|
31
|
+
# Or with uv
|
|
32
|
+
uv pip install remind-plugin-google-tasks
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Setup
|
|
36
|
+
|
|
37
|
+
1. Go to [Google Cloud Console](https://console.cloud.google.com) and create a new project
|
|
38
|
+
2. Enable **Google Tasks API** for your project
|
|
39
|
+
3. Create an **OAuth 2.0 credential** (Desktop app type)
|
|
40
|
+
4. Download the `client_secrets.json` file
|
|
41
|
+
5. Set the path:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export REMIND_GOOGLE_CLIENT_SECRETS_PATH=~/Downloads/client_secrets.json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
6. Configure the plugin:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
remind plugins add google-tasks
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This will open your browser for OAuth authorization and let you select which Google Task list to sync with.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
Simply use Remind as normal:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
remind add 'Buy groceries' --due 'tomorrow 5pm'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The reminder is instantly synced to your Google Task list.
|
|
64
|
+
|
|
65
|
+
## Status
|
|
66
|
+
|
|
67
|
+
Check plugin status anytime:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
remind plugins
|
|
71
|
+
remind doctor
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Uninstall
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
remind plugins remove google-tasks
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# remind-plugin-google-tasks
|
|
2
|
+
|
|
3
|
+
Sync reminders from Remind CLI with Google Tasks.
|
|
4
|
+
|
|
5
|
+
When you create a reminder in Remind, it automatically appears in your Google Task list. Keep your tasks organized in one place.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Install the plugin
|
|
11
|
+
pip install remind-plugin-google-tasks
|
|
12
|
+
|
|
13
|
+
# Or with uv
|
|
14
|
+
uv pip install remind-plugin-google-tasks
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
1. Go to [Google Cloud Console](https://console.cloud.google.com) and create a new project
|
|
20
|
+
2. Enable **Google Tasks API** for your project
|
|
21
|
+
3. Create an **OAuth 2.0 credential** (Desktop app type)
|
|
22
|
+
4. Download the `client_secrets.json` file
|
|
23
|
+
5. Set the path:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export REMIND_GOOGLE_CLIENT_SECRETS_PATH=~/Downloads/client_secrets.json
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
6. Configure the plugin:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
remind plugins add google-tasks
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This will open your browser for OAuth authorization and let you select which Google Task list to sync with.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
Simply use Remind as normal:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
remind add 'Buy groceries' --due 'tomorrow 5pm'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The reminder is instantly synced to your Google Task list.
|
|
46
|
+
|
|
47
|
+
## Status
|
|
48
|
+
|
|
49
|
+
Check plugin status anytime:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
remind plugins
|
|
53
|
+
remind doctor
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Uninstall
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
remind plugins remove google-tasks
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "remind-plugin-google-tasks"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Google Tasks integration plugin for Remind CLI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Hamza Plojovic", email = "hello@hamzaplojovic.com" }]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"remind-plugin-base>=0.1.0",
|
|
15
|
+
"typer[all]>=0.9.0",
|
|
16
|
+
"google-auth-oauthlib>=1.2.0",
|
|
17
|
+
"google-auth-httplib2>=0.2.0",
|
|
18
|
+
"google-api-python-client>=2.100.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=7.0",
|
|
24
|
+
"pytest-asyncio>=0.21",
|
|
25
|
+
"pytest-mock>=3.11.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.entry-points."remind.plugins"]
|
|
29
|
+
google-tasks = "remind_plugin_google_tasks:GoogleTasksPlugin"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/remind_plugin_google_tasks"]
|
|
33
|
+
|
|
34
|
+
[tool.uv.sources]
|
|
35
|
+
remind-plugin-base = { workspace = true }
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
addopts = "-v --tb=short"
|
|
40
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Configuration models for Google Tasks plugin."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GoogleTasksConfig(BaseModel):
|
|
7
|
+
"""Google Tasks plugin configuration."""
|
|
8
|
+
|
|
9
|
+
task_list_id: str
|
|
10
|
+
"""The ID of the Google Task list to sync with."""
|
|
11
|
+
|
|
12
|
+
task_list_name: str
|
|
13
|
+
"""The human-readable name of the task list (e.g., 'Remind')."""
|
|
14
|
+
|
|
15
|
+
class Config:
|
|
16
|
+
"""Pydantic config."""
|
|
17
|
+
json_file = "config.json"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Google OAuth 2.0 flow for Google Tasks."""
|
|
2
|
+
|
|
3
|
+
import webbrowser
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from threading import Thread
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
|
9
|
+
|
|
10
|
+
from google.auth.transport.requests import Request
|
|
11
|
+
from google.oauth2.credentials import Credentials
|
|
12
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LocalhostServer:
|
|
16
|
+
"""Simple HTTP server to capture OAuth callback on localhost:8080."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, port: int = 8080) -> None:
|
|
19
|
+
"""Initialize server.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
port: Port to listen on (default 8080).
|
|
23
|
+
"""
|
|
24
|
+
self.port = port
|
|
25
|
+
self.auth_code: Optional[str] = None
|
|
26
|
+
self.server: Optional[HTTPServer] = None
|
|
27
|
+
|
|
28
|
+
def start(self) -> None:
|
|
29
|
+
"""Start the server in a background thread."""
|
|
30
|
+
self.server = HTTPServer(("localhost", self.port), self._make_handler())
|
|
31
|
+
|
|
32
|
+
thread = Thread(target=self.server.handle_request, daemon=True)
|
|
33
|
+
thread.start()
|
|
34
|
+
|
|
35
|
+
def _make_handler(self):
|
|
36
|
+
"""Create a request handler that captures the auth code."""
|
|
37
|
+
server = self
|
|
38
|
+
|
|
39
|
+
class Handler(BaseHTTPRequestHandler):
|
|
40
|
+
def do_GET(self):
|
|
41
|
+
"""Handle GET request (OAuth callback)."""
|
|
42
|
+
parsed = urlparse(self.path)
|
|
43
|
+
params = parse_qs(parsed.query)
|
|
44
|
+
|
|
45
|
+
if "code" in params:
|
|
46
|
+
server.auth_code = params["code"][0]
|
|
47
|
+
self.send_response(200)
|
|
48
|
+
self.send_header("Content-type", "text/html")
|
|
49
|
+
self.end_headers()
|
|
50
|
+
html = """
|
|
51
|
+
<html>
|
|
52
|
+
<head><title>Authorized</title></head>
|
|
53
|
+
<body>
|
|
54
|
+
<h1>Authorization Successful</h1>
|
|
55
|
+
<p>You can close this window and return to your terminal.</p>
|
|
56
|
+
</body>
|
|
57
|
+
</html>
|
|
58
|
+
"""
|
|
59
|
+
self.wfile.write(html.encode())
|
|
60
|
+
else:
|
|
61
|
+
self.send_response(400)
|
|
62
|
+
self.end_headers()
|
|
63
|
+
|
|
64
|
+
def log_message(self, _format, *_args):
|
|
65
|
+
"""Suppress server logs."""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
return Handler
|
|
69
|
+
|
|
70
|
+
def stop(self) -> None:
|
|
71
|
+
"""Stop the server."""
|
|
72
|
+
if self.server:
|
|
73
|
+
self.server.server_close()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_google_credentials(
|
|
77
|
+
config_dir: Path, client_secrets_path: Optional[Path | str] = None
|
|
78
|
+
) -> Credentials:
|
|
79
|
+
"""Authenticate with Google and return credentials.
|
|
80
|
+
|
|
81
|
+
First tries to load existing credentials from config_dir.
|
|
82
|
+
If not found or expired, runs OAuth flow.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
config_dir: Directory to store credentials (e.g., ~/.remind/plugins/google-tasks/).
|
|
86
|
+
client_secrets_path: Path to client_secrets.json from Google Cloud Console.
|
|
87
|
+
If None, looks for REMIND_GOOGLE_CLIENT_SECRETS_PATH env var.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Valid Google OAuth credentials.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
Exception: If OAuth flow fails or credentials not found.
|
|
94
|
+
"""
|
|
95
|
+
import os
|
|
96
|
+
|
|
97
|
+
credentials_file = config_dir / "credentials.json"
|
|
98
|
+
|
|
99
|
+
# Try to load existing credentials
|
|
100
|
+
if credentials_file.exists():
|
|
101
|
+
creds = Credentials.from_authorized_user_file(str(credentials_file))
|
|
102
|
+
if creds.valid:
|
|
103
|
+
return creds
|
|
104
|
+
if creds.expired and creds.refresh_token:
|
|
105
|
+
creds.refresh(Request())
|
|
106
|
+
_save_credentials(creds, credentials_file)
|
|
107
|
+
return creds
|
|
108
|
+
|
|
109
|
+
# No valid credentials, run OAuth flow
|
|
110
|
+
if client_secrets_path is None:
|
|
111
|
+
env_path = os.environ.get("REMIND_GOOGLE_CLIENT_SECRETS_PATH")
|
|
112
|
+
if not env_path:
|
|
113
|
+
raise Exception(
|
|
114
|
+
"Google OAuth client secrets not found. "
|
|
115
|
+
"Set REMIND_GOOGLE_CLIENT_SECRETS_PATH or provide path to client_secrets.json from Google Cloud Console."
|
|
116
|
+
)
|
|
117
|
+
client_secrets_path = Path(env_path).expanduser()
|
|
118
|
+
else:
|
|
119
|
+
client_secrets_path = Path(client_secrets_path).expanduser()
|
|
120
|
+
|
|
121
|
+
if not client_secrets_path.exists():
|
|
122
|
+
raise Exception(f"Client secrets file not found: {client_secrets_path}")
|
|
123
|
+
|
|
124
|
+
# Run OAuth flow
|
|
125
|
+
scopes = ["https://www.googleapis.com/auth/tasks"]
|
|
126
|
+
flow = InstalledAppFlow.from_client_secrets_file(str(client_secrets_path), scopes)
|
|
127
|
+
|
|
128
|
+
# Run local server to capture callback
|
|
129
|
+
server = LocalhostServer()
|
|
130
|
+
server.start()
|
|
131
|
+
|
|
132
|
+
# Open browser for user to authorize
|
|
133
|
+
auth_url, _ = flow.authorization_url()
|
|
134
|
+
webbrowser.open(auth_url)
|
|
135
|
+
|
|
136
|
+
# Wait for callback (max 10 minutes)
|
|
137
|
+
import time
|
|
138
|
+
|
|
139
|
+
for _ in range(600): # 10 minutes
|
|
140
|
+
if server.auth_code:
|
|
141
|
+
break
|
|
142
|
+
time.sleep(1)
|
|
143
|
+
else:
|
|
144
|
+
raise Exception("OAuth callback not received (timed out)")
|
|
145
|
+
|
|
146
|
+
server.stop()
|
|
147
|
+
|
|
148
|
+
# Exchange auth code for credentials
|
|
149
|
+
flow.fetch_token(code=server.auth_code)
|
|
150
|
+
creds = flow.credentials
|
|
151
|
+
|
|
152
|
+
# Save credentials
|
|
153
|
+
_save_credentials(creds, credentials_file)
|
|
154
|
+
|
|
155
|
+
return creds
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _save_credentials(creds: Credentials, path: Path) -> None:
|
|
159
|
+
"""Save credentials to a JSON file.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
creds: Google OAuth credentials.
|
|
163
|
+
path: Path to save credentials to.
|
|
164
|
+
"""
|
|
165
|
+
path.write_text(creds.to_json())
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Google Tasks plugin for Remind."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from remind_plugin_base import BasePlugin, Reminder
|
|
8
|
+
|
|
9
|
+
from remind_plugin_google_tasks.config import GoogleTasksConfig
|
|
10
|
+
from remind_plugin_google_tasks.tasks_client import GoogleTasksClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GoogleTasksPlugin(BasePlugin):
|
|
14
|
+
"""Sync Remind reminders with Google Tasks."""
|
|
15
|
+
|
|
16
|
+
name = "google-tasks"
|
|
17
|
+
display_name = "Google Tasks"
|
|
18
|
+
version = "0.1.0"
|
|
19
|
+
|
|
20
|
+
def setup(self, config_dir: Path) -> None:
|
|
21
|
+
"""Interactive setup wizard for Google Tasks.
|
|
22
|
+
|
|
23
|
+
Prompts user for Google OAuth credentials and task list selection.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
config_dir: Path to ~/.remind/plugins/google-tasks/
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
Exception: If setup fails.
|
|
30
|
+
"""
|
|
31
|
+
typer.echo("\n[bold cyan]Google Tasks Setup[/bold cyan]\n")
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
# Get OAuth credentials
|
|
35
|
+
typer.echo("1. Authenticating with Google...")
|
|
36
|
+
client = GoogleTasksClient(config_dir)
|
|
37
|
+
|
|
38
|
+
# List task lists and let user pick one
|
|
39
|
+
typer.echo("2. Fetching your Google Task lists...")
|
|
40
|
+
task_lists = client.list_task_lists()
|
|
41
|
+
|
|
42
|
+
if not task_lists:
|
|
43
|
+
typer.echo(
|
|
44
|
+
"No task lists found. Creating a new one called 'Remind'...\n"
|
|
45
|
+
)
|
|
46
|
+
task_list_id = client.create_task_list("Remind")
|
|
47
|
+
task_list_name = "Remind"
|
|
48
|
+
else:
|
|
49
|
+
typer.echo("\nAvailable task lists:")
|
|
50
|
+
for i, tl in enumerate(task_lists, 1):
|
|
51
|
+
typer.echo(f" {i}. {tl['title']}")
|
|
52
|
+
|
|
53
|
+
choice = typer.prompt(
|
|
54
|
+
"\nSelect a task list (number) or enter a name to create a new one",
|
|
55
|
+
type=str,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if choice.isdigit() and 1 <= int(choice) <= len(task_lists):
|
|
59
|
+
selected = task_lists[int(choice) - 1]
|
|
60
|
+
task_list_id = selected["id"]
|
|
61
|
+
task_list_name = selected["title"]
|
|
62
|
+
else:
|
|
63
|
+
# Create new task list
|
|
64
|
+
task_list_name = choice
|
|
65
|
+
typer.echo(f"\nCreating new task list '{task_list_name}'...")
|
|
66
|
+
task_list_id = client.create_task_list(task_list_name)
|
|
67
|
+
|
|
68
|
+
# Save config
|
|
69
|
+
config = GoogleTasksConfig(
|
|
70
|
+
task_list_id=task_list_id, task_list_name=task_list_name
|
|
71
|
+
)
|
|
72
|
+
config_file = config_dir / "config.json"
|
|
73
|
+
config_file.write_text(config.model_dump_json(indent=2))
|
|
74
|
+
|
|
75
|
+
# Create test task
|
|
76
|
+
typer.echo(f"\nCreating test task in '{task_list_name}'...")
|
|
77
|
+
client.insert_task(
|
|
78
|
+
task_list_id,
|
|
79
|
+
"✓ Remind CLI is connected to Google Tasks!",
|
|
80
|
+
"This task was created by the Remind CLI integration.",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
typer.echo("\n[bold green]✓ Setup complete![/bold green]\n")
|
|
84
|
+
|
|
85
|
+
except Exception as e:
|
|
86
|
+
typer.echo(f"\n[bold red]✗ Setup failed: {e}[/bold red]\n")
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
def teardown(self, config_dir: Path) -> None:
|
|
90
|
+
"""Remove plugin credentials and configuration.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
config_dir: Path to ~/.remind/plugins/google-tasks/
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
Exception: If teardown fails.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
# Delete credentials file
|
|
100
|
+
credentials_file = config_dir / "credentials.json"
|
|
101
|
+
if credentials_file.exists():
|
|
102
|
+
credentials_file.unlink()
|
|
103
|
+
|
|
104
|
+
# Delete config file
|
|
105
|
+
config_file = config_dir / "config.json"
|
|
106
|
+
if config_file.exists():
|
|
107
|
+
config_file.unlink()
|
|
108
|
+
|
|
109
|
+
# Remove the plugin directory
|
|
110
|
+
if config_dir.exists():
|
|
111
|
+
config_dir.rmdir()
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
raise Exception(f"Failed to remove plugin configuration: {e}") from e
|
|
115
|
+
|
|
116
|
+
def is_configured(self, config_dir: Path) -> bool:
|
|
117
|
+
"""Check if plugin is properly configured.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
config_dir: Path to ~/.remind/plugins/google-tasks/
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
True if both credentials and config exist.
|
|
124
|
+
"""
|
|
125
|
+
credentials_file = config_dir / "credentials.json"
|
|
126
|
+
config_file = config_dir / "config.json"
|
|
127
|
+
return credentials_file.exists() and config_file.exists()
|
|
128
|
+
|
|
129
|
+
def status(self, config_dir: Path) -> str:
|
|
130
|
+
"""Get status for doctor command.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
config_dir: Path to ~/.remind/plugins/google-tasks/
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Status string.
|
|
137
|
+
"""
|
|
138
|
+
if not self.is_configured(config_dir):
|
|
139
|
+
return "Not configured"
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
config_file = config_dir / "config.json"
|
|
143
|
+
config_data = json.loads(config_file.read_text())
|
|
144
|
+
task_list_name = config_data.get("task_list_name", "Unknown")
|
|
145
|
+
return f"Authenticated, syncing to '{task_list_name}'"
|
|
146
|
+
except Exception:
|
|
147
|
+
return "Configured but error reading status"
|
|
148
|
+
|
|
149
|
+
def on_reminder_created(self, reminder: Reminder, config_dir: Path) -> None:
|
|
150
|
+
"""Create a task in Google Tasks when a reminder is created.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
reminder: The newly created reminder.
|
|
154
|
+
config_dir: Path to ~/.remind/plugins/google-tasks/
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
config_file = config_dir / "config.json"
|
|
158
|
+
config_data = json.loads(config_file.read_text())
|
|
159
|
+
task_list_id = config_data["task_list_id"]
|
|
160
|
+
|
|
161
|
+
# Format task note with due date
|
|
162
|
+
notes = None
|
|
163
|
+
if reminder.due_date:
|
|
164
|
+
notes = f"Due: {reminder.due_date}"
|
|
165
|
+
|
|
166
|
+
client = GoogleTasksClient(config_dir)
|
|
167
|
+
client.insert_task(task_list_id, reminder.text, notes)
|
|
168
|
+
|
|
169
|
+
except Exception as e:
|
|
170
|
+
print(f"Error syncing reminder to Google Tasks: {e}")
|
|
171
|
+
|
|
172
|
+
def on_reminder_due(self, _reminder: Reminder, _config_dir: Path) -> None:
|
|
173
|
+
"""Optionally send a notification when a reminder is due.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
_reminder: The reminder that is due.
|
|
177
|
+
_config_dir: Path to ~/.remind/plugins/google-tasks/
|
|
178
|
+
"""
|
|
179
|
+
# For now, just a no-op. Could integrate with Google Calendar or send emails.
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def on_reminder_done(self, _reminder: Reminder, _config_dir: Path) -> None:
|
|
183
|
+
"""Mark task as done in Google Tasks when marked done in Remind.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
_reminder: The reminder that was completed.
|
|
187
|
+
_config_dir: Path to ~/.remind/plugins/google-tasks/
|
|
188
|
+
"""
|
|
189
|
+
# This would require tracking which Remind reminder ID maps to which Google Task ID.
|
|
190
|
+
# For now, this is a placeholder. Full implementation would store a mapping file.
|
|
191
|
+
pass
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Google Tasks API client."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from googleapiclient.discovery import build
|
|
7
|
+
|
|
8
|
+
from remind_plugin_google_tasks.oauth import get_google_credentials
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GoogleTasksClient:
|
|
12
|
+
"""Client for interacting with Google Tasks API."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config_dir: Path, client_secrets_path: Optional[Path] = None) -> None:
|
|
15
|
+
"""Initialize client.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
config_dir: Directory with credentials (e.g., ~/.remind/plugins/google-tasks/).
|
|
19
|
+
client_secrets_path: Path to client_secrets.json from Google Cloud Console.
|
|
20
|
+
"""
|
|
21
|
+
self.config_dir = config_dir
|
|
22
|
+
self.client_secrets_path = client_secrets_path
|
|
23
|
+
self._service = None
|
|
24
|
+
|
|
25
|
+
def _get_service(self):
|
|
26
|
+
"""Lazily initialize the Google Tasks service."""
|
|
27
|
+
if self._service is None:
|
|
28
|
+
creds = get_google_credentials(self.config_dir, self.client_secrets_path)
|
|
29
|
+
self._service = build("tasks", "v1", credentials=creds)
|
|
30
|
+
return self._service
|
|
31
|
+
|
|
32
|
+
def list_task_lists(self) -> list[dict]:
|
|
33
|
+
"""Get all task lists.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
List of task list dicts with 'id', 'title' keys.
|
|
37
|
+
"""
|
|
38
|
+
service = self._get_service()
|
|
39
|
+
results = service.tasklists().list(maxResults=10).execute()
|
|
40
|
+
return results.get("items", [])
|
|
41
|
+
|
|
42
|
+
def create_task_list(self, title: str) -> str:
|
|
43
|
+
"""Create a new task list.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
title: Name of the task list.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
ID of the created task list.
|
|
50
|
+
"""
|
|
51
|
+
service = self._get_service()
|
|
52
|
+
task_list = {"title": title}
|
|
53
|
+
result = service.tasklists().insert(body=task_list).execute()
|
|
54
|
+
return result["id"]
|
|
55
|
+
|
|
56
|
+
def insert_task(self, task_list_id: str, title: str, notes: Optional[str] = None) -> str:
|
|
57
|
+
"""Insert a new task.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
task_list_id: ID of the task list.
|
|
61
|
+
title: Task title.
|
|
62
|
+
notes: Optional task notes/description.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
ID of the created task.
|
|
66
|
+
"""
|
|
67
|
+
service = self._get_service()
|
|
68
|
+
task = {"title": title}
|
|
69
|
+
if notes:
|
|
70
|
+
task["notes"] = notes
|
|
71
|
+
|
|
72
|
+
result = service.tasks().insert(tasklist=task_list_id, body=task).execute()
|
|
73
|
+
return result["id"]
|
|
74
|
+
|
|
75
|
+
def update_task(
|
|
76
|
+
self, task_list_id: str, task_id: str, completed: bool = False
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Update a task's status.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
task_list_id: ID of the task list.
|
|
82
|
+
task_id: ID of the task to update.
|
|
83
|
+
completed: Whether the task is completed.
|
|
84
|
+
"""
|
|
85
|
+
service = self._get_service()
|
|
86
|
+
task = {"id": task_id, "status": "completed" if completed else "needsAction"}
|
|
87
|
+
service.tasks().update(tasklist=task_list_id, task=task_id, body=task).execute()
|
|
88
|
+
|
|
89
|
+
def delete_task(self, task_list_id: str, task_id: str) -> None:
|
|
90
|
+
"""Delete a task.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
task_list_id: ID of the task list.
|
|
94
|
+
task_id: ID of the task to delete.
|
|
95
|
+
"""
|
|
96
|
+
service = self._get_service()
|
|
97
|
+
service.tasks().delete(tasklist=task_list_id, task=task_id).execute()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tests for Google Tasks plugin."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from remind_plugin_google_tasks import GoogleTasksPlugin
|
|
9
|
+
from remind_plugin_base import Reminder
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def plugin():
|
|
14
|
+
"""Provide a plugin instance."""
|
|
15
|
+
return GoogleTasksPlugin()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def config_dir(tmp_path):
|
|
20
|
+
"""Provide a temporary config directory."""
|
|
21
|
+
return tmp_path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_plugin_metadata():
|
|
25
|
+
"""Test plugin metadata."""
|
|
26
|
+
plugin = GoogleTasksPlugin()
|
|
27
|
+
assert plugin.name == "google-tasks"
|
|
28
|
+
assert plugin.display_name == "Google Tasks"
|
|
29
|
+
assert plugin.version == "0.1.0"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_is_configured_false_when_no_files(plugin, config_dir):
|
|
33
|
+
"""Test is_configured returns False when credentials don't exist."""
|
|
34
|
+
assert not plugin.is_configured(config_dir)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_is_configured_true_when_files_exist(plugin, config_dir):
|
|
38
|
+
"""Test is_configured returns True when credentials and config exist."""
|
|
39
|
+
(config_dir / "credentials.json").write_text("{}")
|
|
40
|
+
(config_dir / "config.json").write_text("{}")
|
|
41
|
+
assert plugin.is_configured(config_dir)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_status_not_configured(plugin, config_dir):
|
|
45
|
+
"""Test status when not configured."""
|
|
46
|
+
status = plugin.status(config_dir)
|
|
47
|
+
assert status == "Not configured"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_status_configured(plugin, config_dir):
|
|
51
|
+
"""Test status when configured."""
|
|
52
|
+
(config_dir / "credentials.json").write_text("{}")
|
|
53
|
+
config = {"task_list_id": "test-list", "task_list_name": "My Tasks"}
|
|
54
|
+
(config_dir / "config.json").write_text(json.dumps(config))
|
|
55
|
+
|
|
56
|
+
status = plugin.status(config_dir)
|
|
57
|
+
assert "My Tasks" in status
|
|
58
|
+
assert "Authenticated" in status
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_teardown_removes_files(plugin, config_dir):
|
|
62
|
+
"""Test teardown removes config files."""
|
|
63
|
+
(config_dir / "credentials.json").write_text("{}")
|
|
64
|
+
(config_dir / "config.json").write_text("{}")
|
|
65
|
+
|
|
66
|
+
plugin.teardown(config_dir)
|
|
67
|
+
|
|
68
|
+
assert not (config_dir / "credentials.json").exists()
|
|
69
|
+
assert not (config_dir / "config.json").exists()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_on_reminder_created_creates_task(plugin, config_dir):
|
|
73
|
+
"""Test on_reminder_created syncs to Google Tasks."""
|
|
74
|
+
config = {"task_list_id": "test-list", "task_list_name": "My Tasks"}
|
|
75
|
+
(config_dir / "config.json").write_text(json.dumps(config))
|
|
76
|
+
(config_dir / "credentials.json").write_text("{}")
|
|
77
|
+
|
|
78
|
+
reminder = Reminder(
|
|
79
|
+
id=1,
|
|
80
|
+
text="Buy groceries",
|
|
81
|
+
due_date="2025-02-18T17:00:00",
|
|
82
|
+
is_done=False,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
with patch("remind_plugin_google_tasks.plugin.GoogleTasksClient") as mock_client:
|
|
86
|
+
mock_instance = MagicMock()
|
|
87
|
+
mock_client.return_value = mock_instance
|
|
88
|
+
|
|
89
|
+
plugin.on_reminder_created(reminder, config_dir)
|
|
90
|
+
|
|
91
|
+
mock_instance.insert_task.assert_called_once()
|
|
92
|
+
call_args = mock_instance.insert_task.call_args
|
|
93
|
+
assert call_args[0][0] == "test-list"
|
|
94
|
+
assert call_args[0][1] == "Buy groceries"
|
|
95
|
+
assert "2025-02-18T17:00:00" in call_args[0][2]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_on_reminder_hooks_dont_error(plugin, config_dir):
|
|
99
|
+
"""Test reminder hooks don't raise errors."""
|
|
100
|
+
reminder = Reminder(id=1, text="Test", is_done=False)
|
|
101
|
+
|
|
102
|
+
# Should not raise
|
|
103
|
+
plugin.on_reminder_due(reminder, config_dir)
|
|
104
|
+
plugin.on_reminder_done(reminder, config_dir)
|