google-workspace-cli 0.1.0__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.
- google_workspace_cli-0.1.0.dist-info/METADATA +270 -0
- google_workspace_cli-0.1.0.dist-info/RECORD +13 -0
- google_workspace_cli-0.1.0.dist-info/WHEEL +4 -0
- google_workspace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- google_workspace_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- gw/__init__.py +1 -0
- gw/auth.py +199 -0
- gw/calendar.py +239 -0
- gw/cli.py +119 -0
- gw/config.py +110 -0
- gw/drive.py +189 -0
- gw/gmail.py +366 -0
- gw/output.py +35 -0
gw/calendar.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from gw.auth import build_service
|
|
9
|
+
from gw.config import DEFAULT_CONFIG_DIR, GwConfig
|
|
10
|
+
from gw.output import format_output
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.pass_context
|
|
15
|
+
def cal(ctx: click.Context) -> None:
|
|
16
|
+
"""Google Calendar operations."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cal.command("list")
|
|
21
|
+
@click.option("--days", default=None, type=int, help="Number of days to show")
|
|
22
|
+
@click.pass_context
|
|
23
|
+
def list_events(ctx: click.Context, days: int | None) -> None:
|
|
24
|
+
"""List upcoming events."""
|
|
25
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
26
|
+
cfg = GwConfig(config_dir)
|
|
27
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
28
|
+
output_json = ctx.obj.get("json", False)
|
|
29
|
+
|
|
30
|
+
if days is None:
|
|
31
|
+
default_days = cfg.get_default("calendar", "days")
|
|
32
|
+
days = int(default_days) if default_days is not None else 7
|
|
33
|
+
|
|
34
|
+
service = build_service(cfg, account, "calendar", "v3")
|
|
35
|
+
tz = cfg.get_default("calendar", "timezone") or "Asia/Tokyo"
|
|
36
|
+
|
|
37
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
38
|
+
end = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
|
|
39
|
+
|
|
40
|
+
result = (
|
|
41
|
+
service.events()
|
|
42
|
+
.list(
|
|
43
|
+
calendarId="primary",
|
|
44
|
+
timeMin=now,
|
|
45
|
+
timeMax=end,
|
|
46
|
+
singleEvents=True,
|
|
47
|
+
orderBy="startTime",
|
|
48
|
+
timeZone=tz,
|
|
49
|
+
)
|
|
50
|
+
.execute()
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
events = []
|
|
54
|
+
for item in result.get("items", []):
|
|
55
|
+
start = item.get("start", {}).get("dateTime", item.get("start", {}).get("date", ""))
|
|
56
|
+
end_time = item.get("end", {}).get("dateTime", item.get("end", {}).get("date", ""))
|
|
57
|
+
events.append(
|
|
58
|
+
{
|
|
59
|
+
"id": item["id"],
|
|
60
|
+
"summary": item.get("summary", "(No title)"),
|
|
61
|
+
"start": start,
|
|
62
|
+
"end": end_time,
|
|
63
|
+
"meet": item.get("hangoutLink", ""),
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
click.echo(format_output(events, output_json=output_json, account=account))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@cal.command()
|
|
71
|
+
@click.argument("event_id")
|
|
72
|
+
@click.pass_context
|
|
73
|
+
def get(ctx: click.Context, event_id: str) -> None:
|
|
74
|
+
"""Get event details."""
|
|
75
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
76
|
+
cfg = GwConfig(config_dir)
|
|
77
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
78
|
+
output_json = ctx.obj.get("json", False)
|
|
79
|
+
|
|
80
|
+
service = build_service(cfg, account, "calendar", "v3")
|
|
81
|
+
item = service.events().get(calendarId="primary", eventId=event_id).execute()
|
|
82
|
+
|
|
83
|
+
data = {
|
|
84
|
+
"id": item["id"],
|
|
85
|
+
"summary": item.get("summary", "(No title)"),
|
|
86
|
+
"start": item.get("start", {}).get("dateTime", ""),
|
|
87
|
+
"end": item.get("end", {}).get("dateTime", ""),
|
|
88
|
+
"location": item.get("location", ""),
|
|
89
|
+
"description": item.get("description", ""),
|
|
90
|
+
"meet": item.get("hangoutLink", ""),
|
|
91
|
+
"attendees": [a.get("email", "") for a in item.get("attendees", [])],
|
|
92
|
+
}
|
|
93
|
+
click.echo(format_output(data, output_json=output_json, account=account))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@cal.command()
|
|
97
|
+
@click.option("--title", required=True)
|
|
98
|
+
@click.option("--start", required=True, help="Start time: YYYY-MM-DD HH:MM")
|
|
99
|
+
@click.option("--end", required=True, help="End time: YYYY-MM-DD HH:MM")
|
|
100
|
+
@click.option("--meet", is_flag=True, help="Add Google Meet link")
|
|
101
|
+
@click.option("--attendee", multiple=True, help="Attendee email (repeatable)")
|
|
102
|
+
@click.pass_context
|
|
103
|
+
def create(ctx: click.Context, title: str, start: str, end: str, meet: bool, attendee: tuple[str, ...]) -> None:
|
|
104
|
+
"""Create a new event."""
|
|
105
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
106
|
+
cfg = GwConfig(config_dir)
|
|
107
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
108
|
+
output_json = ctx.obj.get("json", False)
|
|
109
|
+
tz = cfg.get_default("calendar", "timezone") or "Asia/Tokyo"
|
|
110
|
+
|
|
111
|
+
body: dict = {
|
|
112
|
+
"summary": title,
|
|
113
|
+
"start": {"dateTime": _parse_datetime(start, tz), "timeZone": tz},
|
|
114
|
+
"end": {"dateTime": _parse_datetime(end, tz), "timeZone": tz},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if attendee:
|
|
118
|
+
body["attendees"] = [{"email": e} for e in attendee]
|
|
119
|
+
|
|
120
|
+
if meet:
|
|
121
|
+
body["conferenceData"] = {
|
|
122
|
+
"createRequest": {
|
|
123
|
+
"requestId": str(uuid.uuid4()),
|
|
124
|
+
"conferenceSolutionKey": {"type": "hangoutsMeet"},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
service = build_service(cfg, account, "calendar", "v3")
|
|
129
|
+
conference_version = 1 if meet else 0
|
|
130
|
+
result = (
|
|
131
|
+
service.events()
|
|
132
|
+
.insert(
|
|
133
|
+
calendarId="primary",
|
|
134
|
+
body=body,
|
|
135
|
+
conferenceDataVersion=conference_version,
|
|
136
|
+
)
|
|
137
|
+
.execute()
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
data = {
|
|
141
|
+
"id": result["id"],
|
|
142
|
+
"summary": result.get("summary", ""),
|
|
143
|
+
"link": result.get("htmlLink", ""),
|
|
144
|
+
"meet": result.get("hangoutLink", ""),
|
|
145
|
+
}
|
|
146
|
+
click.echo(format_output(data, output_json=output_json, account=account))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@cal.command()
|
|
150
|
+
@click.argument("event_id")
|
|
151
|
+
@click.option("--title", default=None)
|
|
152
|
+
@click.option("--start", default=None, help="New start time: YYYY-MM-DD HH:MM")
|
|
153
|
+
@click.option("--end", default=None, help="New end time: YYYY-MM-DD HH:MM")
|
|
154
|
+
@click.pass_context
|
|
155
|
+
def update(ctx: click.Context, event_id: str, title: str | None, start: str | None, end: str | None) -> None:
|
|
156
|
+
"""Update an event."""
|
|
157
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
158
|
+
cfg = GwConfig(config_dir)
|
|
159
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
160
|
+
output_json = ctx.obj.get("json", False)
|
|
161
|
+
tz = cfg.get_default("calendar", "timezone") or "Asia/Tokyo"
|
|
162
|
+
|
|
163
|
+
service = build_service(cfg, account, "calendar", "v3")
|
|
164
|
+
event = service.events().get(calendarId="primary", eventId=event_id).execute()
|
|
165
|
+
|
|
166
|
+
if title:
|
|
167
|
+
event["summary"] = title
|
|
168
|
+
if start:
|
|
169
|
+
event["start"] = {"dateTime": _parse_datetime(start, tz), "timeZone": tz}
|
|
170
|
+
if end:
|
|
171
|
+
event["end"] = {"dateTime": _parse_datetime(end, tz), "timeZone": tz}
|
|
172
|
+
|
|
173
|
+
result = service.events().update(calendarId="primary", eventId=event_id, body=event).execute()
|
|
174
|
+
click.echo(format_output(f"Updated: {result.get('summary', '')}", output_json=output_json, account=account))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@cal.command()
|
|
178
|
+
@click.argument("event_id")
|
|
179
|
+
@click.pass_context
|
|
180
|
+
def delete(ctx: click.Context, event_id: str) -> None:
|
|
181
|
+
"""Delete an event."""
|
|
182
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
183
|
+
cfg = GwConfig(config_dir)
|
|
184
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
185
|
+
output_json = ctx.obj.get("json", False)
|
|
186
|
+
|
|
187
|
+
service = build_service(cfg, account, "calendar", "v3")
|
|
188
|
+
service.events().delete(calendarId="primary", eventId=event_id).execute()
|
|
189
|
+
click.echo(format_output("Deleted event.", output_json=output_json, account=account))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@cal.command()
|
|
193
|
+
@click.option("--date", required=True, help="Date to check: YYYY-MM-DD")
|
|
194
|
+
@click.option("--account", "account_override", default=None, help="Account email to check")
|
|
195
|
+
@click.pass_context
|
|
196
|
+
def free(ctx: click.Context, date: str, account_override: str | None) -> None:
|
|
197
|
+
"""Check free/busy status."""
|
|
198
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
199
|
+
cfg = GwConfig(config_dir)
|
|
200
|
+
# Use the --account option passed to this subcommand, or fall back to ctx.obj
|
|
201
|
+
account = cfg.resolve_account(account_override or ctx.obj.get("account"))
|
|
202
|
+
output_json = ctx.obj.get("json", False)
|
|
203
|
+
tz = cfg.get_default("calendar", "timezone") or "Asia/Tokyo"
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
next_day = (datetime.strptime(date, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d")
|
|
207
|
+
except ValueError as exc:
|
|
208
|
+
raise click.BadParameter(f"Invalid date {date!r}; expected format 'YYYY-MM-DD'.") from exc
|
|
209
|
+
|
|
210
|
+
service = build_service(cfg, account, "calendar", "v3")
|
|
211
|
+
body = {
|
|
212
|
+
"timeMin": _parse_datetime(f"{date} 00:00", tz),
|
|
213
|
+
"timeMax": _parse_datetime(f"{next_day} 00:00", tz),
|
|
214
|
+
"timeZone": tz,
|
|
215
|
+
"items": [{"id": account}],
|
|
216
|
+
}
|
|
217
|
+
result = service.freebusy().query(body=body).execute()
|
|
218
|
+
|
|
219
|
+
busy_times = []
|
|
220
|
+
for cal_id, cal_data in result.get("calendars", {}).items():
|
|
221
|
+
for busy in cal_data.get("busy", []):
|
|
222
|
+
busy_times.append({"account": cal_id, "start": busy["start"], "end": busy["end"]})
|
|
223
|
+
|
|
224
|
+
if busy_times:
|
|
225
|
+
click.echo(format_output(busy_times, output_json=output_json, account=account))
|
|
226
|
+
else:
|
|
227
|
+
click.echo(format_output(f"No busy times on {date}.", output_json=output_json, account=account))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _parse_datetime(dt_str: str, tz_name: str) -> str:
|
|
231
|
+
"""Parse 'YYYY-MM-DD HH:MM' into RFC3339 with timezone offset."""
|
|
232
|
+
from zoneinfo import ZoneInfo
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M")
|
|
236
|
+
except ValueError as exc:
|
|
237
|
+
raise click.BadParameter(f"Invalid datetime {dt_str!r}; expected format 'YYYY-MM-DD HH:MM'.") from exc
|
|
238
|
+
dt = dt.replace(tzinfo=ZoneInfo(tz_name))
|
|
239
|
+
return dt.isoformat()
|
gw/cli.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import json as json_module
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from gw.auth import auth
|
|
7
|
+
from gw.calendar import cal
|
|
8
|
+
from gw.config import DEFAULT_CONFIG_DIR, GwConfig
|
|
9
|
+
from gw.drive import drive
|
|
10
|
+
from gw.gmail import mail
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
15
|
+
@click.option("--account", default=None, help="Google account email to use")
|
|
16
|
+
@click.option(
|
|
17
|
+
"--config-dir",
|
|
18
|
+
type=click.Path(),
|
|
19
|
+
default=None,
|
|
20
|
+
hidden=True,
|
|
21
|
+
help="Config directory override (for testing)",
|
|
22
|
+
)
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def main(ctx: click.Context, output_json: bool, account: str | None, config_dir: str | None) -> None:
|
|
25
|
+
"""gw - Google Workspace CLI for Claude Code."""
|
|
26
|
+
ctx.ensure_object(dict)
|
|
27
|
+
ctx.obj["json"] = output_json
|
|
28
|
+
ctx.obj["account"] = account
|
|
29
|
+
if config_dir:
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
ctx.obj["config_dir"] = Path(config_dir)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
main.add_command(auth)
|
|
36
|
+
main.add_command(cal)
|
|
37
|
+
main.add_command(drive)
|
|
38
|
+
main.add_command(mail)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.group("config")
|
|
42
|
+
@click.pass_context
|
|
43
|
+
def config_group(ctx: click.Context) -> None:
|
|
44
|
+
"""Manage gw-cli configuration."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@config_group.command()
|
|
49
|
+
@click.pass_context
|
|
50
|
+
def show(ctx: click.Context) -> None:
|
|
51
|
+
"""Show current configuration."""
|
|
52
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
53
|
+
cfg = GwConfig(config_dir)
|
|
54
|
+
data = {
|
|
55
|
+
"active_account": cfg.active_account,
|
|
56
|
+
"accounts": [a["email"] for a in cfg.accounts],
|
|
57
|
+
"defaults": cfg.defaults,
|
|
58
|
+
"loop": cfg.loop,
|
|
59
|
+
}
|
|
60
|
+
click.echo(json_module.dumps(data, indent=2, ensure_ascii=False))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@config_group.command("set")
|
|
64
|
+
@click.argument("key")
|
|
65
|
+
@click.argument("value")
|
|
66
|
+
@click.pass_context
|
|
67
|
+
def set_config(ctx: click.Context, key: str, value: str) -> None:
|
|
68
|
+
"""Set a config value (e.g. defaults.calendar.days 14)."""
|
|
69
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
70
|
+
cfg = GwConfig(config_dir)
|
|
71
|
+
|
|
72
|
+
parts = key.split(".")
|
|
73
|
+
if len(parts) == 3 and parts[0] == "defaults":
|
|
74
|
+
converted: object = value
|
|
75
|
+
try:
|
|
76
|
+
converted = int(value)
|
|
77
|
+
except ValueError:
|
|
78
|
+
try:
|
|
79
|
+
converted = float(value)
|
|
80
|
+
except ValueError:
|
|
81
|
+
if value.lower() in ("true", "false"):
|
|
82
|
+
converted = value.lower() == "true"
|
|
83
|
+
elif value.lower() == "null":
|
|
84
|
+
converted = None
|
|
85
|
+
|
|
86
|
+
cfg.set_default(parts[1], parts[2], converted)
|
|
87
|
+
cfg.save()
|
|
88
|
+
click.echo(f"Set {key} = {converted}")
|
|
89
|
+
else:
|
|
90
|
+
click.echo(f"Unknown config key: {key}", err=True)
|
|
91
|
+
raise SystemExit(1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
main.add_command(config_group)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cli() -> None:
|
|
98
|
+
"""Console-script entry point with friendly top-level error handling.
|
|
99
|
+
|
|
100
|
+
Click only renders ClickException subclasses nicely; uncaught RuntimeError
|
|
101
|
+
or ValueError (raised by build_service, resolve_account, config loading,
|
|
102
|
+
etc.) would otherwise surface as a raw Python traceback. Catch those here
|
|
103
|
+
and report a one-line error with a non-zero exit code.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
main(standalone_mode=False)
|
|
107
|
+
except click.ClickException as exc:
|
|
108
|
+
exc.show()
|
|
109
|
+
sys.exit(exc.exit_code)
|
|
110
|
+
except click.exceptions.Abort:
|
|
111
|
+
click.echo("Aborted.", err=True)
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
except (RuntimeError, ValueError) as exc:
|
|
114
|
+
click.echo(f"Error: {exc}", err=True)
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
cli()
|
gw/config.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "google-workspace-cli"
|
|
11
|
+
|
|
12
|
+
DEFAULT_DEFAULTS = {
|
|
13
|
+
"calendar": {"days": 7, "timezone": "Asia/Tokyo"},
|
|
14
|
+
"mail": {"limit": 20, "check_query": "is:unread"},
|
|
15
|
+
"drive": {"default_folder": None},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GwConfig:
|
|
20
|
+
def __init__(self, config_dir: Path = DEFAULT_CONFIG_DIR) -> None:
|
|
21
|
+
self.config_dir = config_dir
|
|
22
|
+
self.config_path = config_dir / "config.json"
|
|
23
|
+
self.credentials_dir = config_dir / "credentials"
|
|
24
|
+
self.tokens_dir = config_dir / "tokens"
|
|
25
|
+
self._load()
|
|
26
|
+
|
|
27
|
+
def _load(self) -> None:
|
|
28
|
+
if self.config_path.exists():
|
|
29
|
+
data = json.loads(self.config_path.read_text())
|
|
30
|
+
self.active_account: str | None = data.get("active_account")
|
|
31
|
+
self.accounts: list[dict] = data.get("accounts", [])
|
|
32
|
+
self.defaults: dict = data["defaults"] if "defaults" in data else copy.deepcopy(DEFAULT_DEFAULTS)
|
|
33
|
+
self.loop: dict = data.get("loop", {"mail_check_interval": "5m"})
|
|
34
|
+
else:
|
|
35
|
+
self.active_account = None
|
|
36
|
+
self.accounts = []
|
|
37
|
+
self.defaults = copy.deepcopy(DEFAULT_DEFAULTS)
|
|
38
|
+
self.loop = {"mail_check_interval": "5m"}
|
|
39
|
+
|
|
40
|
+
def save(self) -> None:
|
|
41
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
self.credentials_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
self.tokens_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
data = {
|
|
45
|
+
"active_account": self.active_account,
|
|
46
|
+
"accounts": self.accounts,
|
|
47
|
+
"defaults": self.defaults,
|
|
48
|
+
"loop": self.loop,
|
|
49
|
+
}
|
|
50
|
+
self.config_path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
51
|
+
os.chmod(self.config_path, 0o600)
|
|
52
|
+
|
|
53
|
+
def add_account(self, email: str, credentials_path: str) -> None:
|
|
54
|
+
for acct in self.accounts:
|
|
55
|
+
if acct["email"] == email:
|
|
56
|
+
return
|
|
57
|
+
self.accounts.append(
|
|
58
|
+
{
|
|
59
|
+
"email": email,
|
|
60
|
+
"credentials": credentials_path,
|
|
61
|
+
"added_at": datetime.now(timezone.utc).isoformat(),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
if self.active_account is None:
|
|
65
|
+
self.active_account = email
|
|
66
|
+
self.save()
|
|
67
|
+
|
|
68
|
+
def switch_account(self, email: str) -> None:
|
|
69
|
+
if not any(a["email"] == email for a in self.accounts):
|
|
70
|
+
raise ValueError(f"Account '{email}' not found. Use 'gw auth login' to add it.")
|
|
71
|
+
self.active_account = email
|
|
72
|
+
self.save()
|
|
73
|
+
|
|
74
|
+
def remove_account(self, email: str) -> None:
|
|
75
|
+
self.accounts = [a for a in self.accounts if a["email"] != email]
|
|
76
|
+
if self.active_account == email:
|
|
77
|
+
self.active_account = self.accounts[0]["email"] if self.accounts else None
|
|
78
|
+
token_path = self.tokens_dir / f"{email}.json"
|
|
79
|
+
if token_path.exists():
|
|
80
|
+
token_path.unlink()
|
|
81
|
+
creds_path = self.credentials_dir / f"{email}.json"
|
|
82
|
+
if creds_path.exists():
|
|
83
|
+
creds_path.unlink()
|
|
84
|
+
self.save()
|
|
85
|
+
|
|
86
|
+
def resolve_account(self, account: str | None) -> str:
|
|
87
|
+
if account is not None:
|
|
88
|
+
if not any(a["email"] == account for a in self.accounts):
|
|
89
|
+
raise ValueError(f"Account '{account}' not found.")
|
|
90
|
+
return account
|
|
91
|
+
if self.active_account is None:
|
|
92
|
+
raise RuntimeError("No active account. Run 'gw auth login' first.")
|
|
93
|
+
return self.active_account
|
|
94
|
+
|
|
95
|
+
def get_credentials_path(self, email: str) -> Path:
|
|
96
|
+
for acct in self.accounts:
|
|
97
|
+
if acct["email"] == email:
|
|
98
|
+
return self.config_dir / acct["credentials"]
|
|
99
|
+
raise ValueError(f"Account '{email}' not found.")
|
|
100
|
+
|
|
101
|
+
def get_token_path(self, email: str) -> Path:
|
|
102
|
+
return self.tokens_dir / f"{email}.json"
|
|
103
|
+
|
|
104
|
+
def get_default(self, service: str, key: str) -> Any:
|
|
105
|
+
return self.defaults.get(service, {}).get(key)
|
|
106
|
+
|
|
107
|
+
def set_default(self, service: str, key: str, value: object) -> None:
|
|
108
|
+
if service not in self.defaults:
|
|
109
|
+
self.defaults[service] = {}
|
|
110
|
+
self.defaults[service][key] = value
|
gw/drive.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from gw.auth import build_service
|
|
9
|
+
from gw.config import DEFAULT_CONFIG_DIR, GwConfig
|
|
10
|
+
from gw.output import format_output
|
|
11
|
+
|
|
12
|
+
GOOGLE_DOC_TYPES = {
|
|
13
|
+
"doc": "application/vnd.google-apps.document",
|
|
14
|
+
"sheet": "application/vnd.google-apps.spreadsheet",
|
|
15
|
+
"slide": "application/vnd.google-apps.presentation",
|
|
16
|
+
"form": "application/vnd.google-apps.form",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group()
|
|
21
|
+
@click.pass_context
|
|
22
|
+
def drive(ctx: click.Context) -> None:
|
|
23
|
+
"""Google Drive operations."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@drive.command("list")
|
|
28
|
+
@click.option("--folder", default=None, help="Folder ID to list")
|
|
29
|
+
@click.option("--query", default=None, help="Drive search query")
|
|
30
|
+
@click.pass_context
|
|
31
|
+
def list_files(ctx: click.Context, folder: str | None, query: str | None) -> None:
|
|
32
|
+
"""List files in Drive."""
|
|
33
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
34
|
+
cfg = GwConfig(config_dir)
|
|
35
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
36
|
+
output_json = ctx.obj.get("json", False)
|
|
37
|
+
|
|
38
|
+
service = build_service(cfg, account, "drive", "v3")
|
|
39
|
+
|
|
40
|
+
q_parts = []
|
|
41
|
+
if folder:
|
|
42
|
+
q_parts.append(f"'{folder}' in parents")
|
|
43
|
+
if query:
|
|
44
|
+
q_parts.append(query)
|
|
45
|
+
q_parts.append("trashed = false")
|
|
46
|
+
q = " and ".join(q_parts)
|
|
47
|
+
|
|
48
|
+
result = (
|
|
49
|
+
service.files()
|
|
50
|
+
.list(
|
|
51
|
+
q=q,
|
|
52
|
+
fields="files(id,name,mimeType,modifiedTime,size,webViewLink)",
|
|
53
|
+
orderBy="modifiedTime desc",
|
|
54
|
+
pageSize=50,
|
|
55
|
+
)
|
|
56
|
+
.execute()
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
files = []
|
|
60
|
+
for f in result.get("files", []):
|
|
61
|
+
files.append(
|
|
62
|
+
{
|
|
63
|
+
"id": f["id"],
|
|
64
|
+
"name": f["name"],
|
|
65
|
+
"type": f["mimeType"],
|
|
66
|
+
"modified": f.get("modifiedTime", ""),
|
|
67
|
+
"size": f.get("size", ""),
|
|
68
|
+
"link": f.get("webViewLink", ""),
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
click.echo(format_output(files, output_json=output_json, account=account))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@drive.command()
|
|
76
|
+
@click.argument("file_path", type=click.Path(exists=True))
|
|
77
|
+
@click.option("--folder", default=None, help="Destination folder ID")
|
|
78
|
+
@click.pass_context
|
|
79
|
+
def upload(ctx: click.Context, file_path: str, folder: str | None) -> None:
|
|
80
|
+
"""Upload a file to Drive."""
|
|
81
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
82
|
+
cfg = GwConfig(config_dir)
|
|
83
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
84
|
+
output_json = ctx.obj.get("json", False)
|
|
85
|
+
|
|
86
|
+
from googleapiclient.http import MediaFileUpload
|
|
87
|
+
|
|
88
|
+
service = build_service(cfg, account, "drive", "v3")
|
|
89
|
+
path = Path(file_path)
|
|
90
|
+
mime_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
|
91
|
+
|
|
92
|
+
file_metadata: dict = {"name": path.name}
|
|
93
|
+
if folder:
|
|
94
|
+
file_metadata["parents"] = [folder]
|
|
95
|
+
|
|
96
|
+
media = MediaFileUpload(str(path), mimetype=mime_type)
|
|
97
|
+
result = service.files().create(body=file_metadata, media_body=media, fields="id,name,webViewLink").execute()
|
|
98
|
+
|
|
99
|
+
click.echo(
|
|
100
|
+
format_output(
|
|
101
|
+
{
|
|
102
|
+
"id": result["id"],
|
|
103
|
+
"name": result["name"],
|
|
104
|
+
"link": result.get("webViewLink", ""),
|
|
105
|
+
},
|
|
106
|
+
output_json=output_json,
|
|
107
|
+
account=account,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@drive.command()
|
|
113
|
+
@click.option("--type", "doc_type", required=True, type=click.Choice(list(GOOGLE_DOC_TYPES.keys())))
|
|
114
|
+
@click.option("--title", required=True)
|
|
115
|
+
@click.option("--folder", default=None, help="Destination folder ID")
|
|
116
|
+
@click.pass_context
|
|
117
|
+
def create(ctx: click.Context, doc_type: str, title: str, folder: str | None) -> None:
|
|
118
|
+
"""Create a new Google Doc/Sheet/Slide."""
|
|
119
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
120
|
+
cfg = GwConfig(config_dir)
|
|
121
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
122
|
+
output_json = ctx.obj.get("json", False)
|
|
123
|
+
|
|
124
|
+
service = build_service(cfg, account, "drive", "v3")
|
|
125
|
+
file_metadata: dict = {"name": title, "mimeType": GOOGLE_DOC_TYPES[doc_type]}
|
|
126
|
+
if folder:
|
|
127
|
+
file_metadata["parents"] = [folder]
|
|
128
|
+
|
|
129
|
+
result = service.files().create(body=file_metadata, fields="id,name,webViewLink").execute()
|
|
130
|
+
|
|
131
|
+
click.echo(
|
|
132
|
+
format_output(
|
|
133
|
+
{
|
|
134
|
+
"id": result["id"],
|
|
135
|
+
"name": result["name"],
|
|
136
|
+
"link": result.get("webViewLink", ""),
|
|
137
|
+
},
|
|
138
|
+
output_json=output_json,
|
|
139
|
+
account=account,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@drive.command()
|
|
145
|
+
@click.argument("file_id")
|
|
146
|
+
@click.option("--email", required=True, help="Email to share with")
|
|
147
|
+
@click.option("--role", required=True, type=click.Choice(["reader", "writer", "commenter"]))
|
|
148
|
+
@click.pass_context
|
|
149
|
+
def share(ctx: click.Context, file_id: str, email: str, role: str) -> None:
|
|
150
|
+
"""Share a file."""
|
|
151
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
152
|
+
cfg = GwConfig(config_dir)
|
|
153
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
154
|
+
output_json = ctx.obj.get("json", False)
|
|
155
|
+
|
|
156
|
+
service = build_service(cfg, account, "drive", "v3")
|
|
157
|
+
service.permissions().create(
|
|
158
|
+
fileId=file_id,
|
|
159
|
+
body={"type": "user", "role": role, "emailAddress": email},
|
|
160
|
+
).execute()
|
|
161
|
+
click.echo(format_output(f"Shared {file_id} with {email} as {role}.", output_json=output_json, account=account))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@drive.command()
|
|
165
|
+
@click.argument("file_id")
|
|
166
|
+
@click.option("--email", required=True, help="Email to remove sharing")
|
|
167
|
+
@click.pass_context
|
|
168
|
+
def unshare(ctx: click.Context, file_id: str, email: str) -> None:
|
|
169
|
+
"""Remove sharing from a file."""
|
|
170
|
+
config_dir = ctx.obj.get("config_dir", DEFAULT_CONFIG_DIR)
|
|
171
|
+
cfg = GwConfig(config_dir)
|
|
172
|
+
account = cfg.resolve_account(ctx.obj.get("account"))
|
|
173
|
+
output_json = ctx.obj.get("json", False)
|
|
174
|
+
|
|
175
|
+
service = build_service(cfg, account, "drive", "v3")
|
|
176
|
+
perms = service.permissions().list(fileId=file_id, fields="permissions(id,emailAddress,role)").execute()
|
|
177
|
+
|
|
178
|
+
perm_id = None
|
|
179
|
+
for p in perms.get("permissions", []):
|
|
180
|
+
if p.get("emailAddress", "").lower() == email.lower():
|
|
181
|
+
perm_id = p["id"]
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
if perm_id is None:
|
|
185
|
+
click.echo(format_output(f"No permission found for {email}.", output_json=output_json, account=account))
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
service.permissions().delete(fileId=file_id, permissionId=perm_id).execute()
|
|
189
|
+
click.echo(format_output(f"Removed {email} from {file_id}.", output_json=output_json, account=account))
|