mgraphctl 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.
Potentially problematic release.
This version of mgraphctl might be problematic. Click here for more details.
- mgraphctl/__init__.py +3 -0
- mgraphctl/__main__.py +3 -0
- mgraphctl/auth.py +395 -0
- mgraphctl/cli.py +259 -0
- mgraphctl/commands/__init__.py +71 -0
- mgraphctl/commands/api.py +164 -0
- mgraphctl/commands/calendar.py +491 -0
- mgraphctl/commands/chats.py +329 -0
- mgraphctl/commands/config_cmd.py +140 -0
- mgraphctl/commands/groups.py +65 -0
- mgraphctl/commands/mail.py +705 -0
- mgraphctl/commands/mailbox.py +176 -0
- mgraphctl/commands/meetings.py +294 -0
- mgraphctl/commands/onedrive.py +351 -0
- mgraphctl/commands/onenote.py +196 -0
- mgraphctl/commands/org.py +102 -0
- mgraphctl/commands/people.py +201 -0
- mgraphctl/commands/planner.py +268 -0
- mgraphctl/commands/presence.py +102 -0
- mgraphctl/commands/search.py +75 -0
- mgraphctl/commands/sharepoint.py +304 -0
- mgraphctl/commands/teams.py +235 -0
- mgraphctl/commands/todo.py +253 -0
- mgraphctl/commands/top.py +284 -0
- mgraphctl/config.py +385 -0
- mgraphctl/errors.py +276 -0
- mgraphctl/fixtures.py +149 -0
- mgraphctl/graph/__init__.py +1 -0
- mgraphctl/graph/calendar.py +313 -0
- mgraphctl/graph/chats.py +447 -0
- mgraphctl/graph/files.py +269 -0
- mgraphctl/graph/groups.py +55 -0
- mgraphctl/graph/mail.py +663 -0
- mgraphctl/graph/mailbox.py +89 -0
- mgraphctl/graph/meetings.py +269 -0
- mgraphctl/graph/onedrive.py +35 -0
- mgraphctl/graph/onenote.py +138 -0
- mgraphctl/graph/org.py +86 -0
- mgraphctl/graph/people.py +83 -0
- mgraphctl/graph/planner.py +415 -0
- mgraphctl/graph/presence.py +89 -0
- mgraphctl/graph/search.py +178 -0
- mgraphctl/graph/sharepoint.py +294 -0
- mgraphctl/graph/teams.py +240 -0
- mgraphctl/graph/todo.py +199 -0
- mgraphctl/graph/users.py +85 -0
- mgraphctl/html.py +127 -0
- mgraphctl/http.py +843 -0
- mgraphctl/odata.py +72 -0
- mgraphctl/render.py +627 -0
- mgraphctl/resolve.py +111 -0
- mgraphctl/token_store.py +196 -0
- mgraphctl-0.1.0.dist-info/METADATA +226 -0
- mgraphctl-0.1.0.dist-info/RECORD +57 -0
- mgraphctl-0.1.0.dist-info/WHEEL +4 -0
- mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
- mgraphctl-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""Outlook calendar commands (spec §8.4)."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from mgraphctl import html as html_mod
|
|
9
|
+
from mgraphctl.cli import (
|
|
10
|
+
AllFlag,
|
|
11
|
+
DryRunFlag,
|
|
12
|
+
JsonFlag,
|
|
13
|
+
LimitOpt,
|
|
14
|
+
graph_command,
|
|
15
|
+
make_noun_app,
|
|
16
|
+
page_bounds,
|
|
17
|
+
)
|
|
18
|
+
from mgraphctl.errors import UsageError
|
|
19
|
+
from mgraphctl.graph import calendar as calendar_mod
|
|
20
|
+
from mgraphctl.graph import users as users_mod
|
|
21
|
+
from mgraphctl.http import GraphClient
|
|
22
|
+
from mgraphctl.render import (
|
|
23
|
+
Column,
|
|
24
|
+
DryRunResult,
|
|
25
|
+
ListResult,
|
|
26
|
+
ObjectResult,
|
|
27
|
+
TextResult,
|
|
28
|
+
Window,
|
|
29
|
+
WriteResult,
|
|
30
|
+
fmt_dtz,
|
|
31
|
+
fmt_event_time,
|
|
32
|
+
fmt_person,
|
|
33
|
+
parse_dt,
|
|
34
|
+
parse_duration,
|
|
35
|
+
truncate,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
app = make_noun_app("Calendar events, availability, meeting times.")
|
|
39
|
+
|
|
40
|
+
SHOW_AS_VALUES = frozenset({"free", "tentative", "busy", "oof", "workingElsewhere"})
|
|
41
|
+
DOMAIN_VALUES = frozenset({"work", "personal", "unrestricted"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _end_short(full: str) -> str:
|
|
45
|
+
"""The `HH:MM` portion of an `fmt_dtz` string, for a same-line "start – end" pair."""
|
|
46
|
+
return full[11:16] if len(full) > 16 else full
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _validate_show_as(show_as: str | None) -> None:
|
|
50
|
+
if show_as is not None and show_as not in SHOW_AS_VALUES:
|
|
51
|
+
raise UsageError("USAGE", f"--show-as must be one of {', '.join(sorted(SHOW_AS_VALUES))}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fmt_owner(owner: dict | None) -> str:
|
|
55
|
+
"""A calendar's `owner` is a plain `{name, address}` EmailAddress, not a Recipient."""
|
|
56
|
+
if not owner:
|
|
57
|
+
return ""
|
|
58
|
+
name, address = owner.get("name"), owner.get("address")
|
|
59
|
+
if name and address:
|
|
60
|
+
return f"{name} <{address}>"
|
|
61
|
+
return address or name or ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------ list / calendars / get
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("list")
|
|
68
|
+
@graph_command(scopes=["Calendars.Read"])
|
|
69
|
+
def list_(
|
|
70
|
+
client: GraphClient,
|
|
71
|
+
start: Annotated[
|
|
72
|
+
str | None, typer.Option("--start", "--after", help="Window start (default: now).")
|
|
73
|
+
] = None,
|
|
74
|
+
end: Annotated[
|
|
75
|
+
str | None, typer.Option("--end", "--before", help="Window end (default: --days out).")
|
|
76
|
+
] = None,
|
|
77
|
+
days: Annotated[
|
|
78
|
+
int | None, typer.Option("--days", help="Days from --start (default 7).")
|
|
79
|
+
] = None,
|
|
80
|
+
calendar: Annotated[str | None, typer.Option("--calendar", help="Calendar name or id.")] = None,
|
|
81
|
+
search: Annotated[
|
|
82
|
+
str | None, typer.Option("--search", help="Client-side match on subject/attendees.")
|
|
83
|
+
] = None,
|
|
84
|
+
limit: LimitOpt = None,
|
|
85
|
+
all_: AllFlag = False,
|
|
86
|
+
json_: JsonFlag = False,
|
|
87
|
+
):
|
|
88
|
+
"""List events in a time window (default: the next 7 days)."""
|
|
89
|
+
limit, all_ = page_bounds(limit, all_, default=50)
|
|
90
|
+
if days is not None and (start is not None or end is not None):
|
|
91
|
+
raise UsageError("USAGE", "--days cannot be combined with --start or --end")
|
|
92
|
+
tz = client.tz
|
|
93
|
+
start_dt = parse_dt(start, tz) if start is not None else parse_dt("now", tz)
|
|
94
|
+
if end is not None:
|
|
95
|
+
end_dt = parse_dt(end, tz, end_of_day=True)
|
|
96
|
+
else:
|
|
97
|
+
end_dt = start_dt + timedelta(days=days if days is not None else 7)
|
|
98
|
+
calendar_id = calendar_mod.resolve_calendar(client, calendar)
|
|
99
|
+
page = calendar_mod.calendar_view(
|
|
100
|
+
client,
|
|
101
|
+
start=start_dt,
|
|
102
|
+
end=end_dt,
|
|
103
|
+
calendar_id=calendar_id,
|
|
104
|
+
select=None,
|
|
105
|
+
limit=limit,
|
|
106
|
+
all_=all_,
|
|
107
|
+
tz=tz,
|
|
108
|
+
)
|
|
109
|
+
items = calendar_mod.filter_search(page.items, search) if search else page.items
|
|
110
|
+
return ListResult(
|
|
111
|
+
items=items,
|
|
112
|
+
page=page,
|
|
113
|
+
window=Window(after=start_dt, before=end_dt),
|
|
114
|
+
hit_cap=calendar_mod.CAP_LIST if all_ else None,
|
|
115
|
+
columns=[
|
|
116
|
+
Column("start", lambda e: fmt_event_time(e, "start", tz)),
|
|
117
|
+
Column("end", lambda e: fmt_event_time(e, "end", tz)),
|
|
118
|
+
Column("flags", calendar_mod.flags),
|
|
119
|
+
Column("subject", lambda e: truncate(e.get("subject"))),
|
|
120
|
+
Column("organizer", lambda e: fmt_person(e.get("organizer"))),
|
|
121
|
+
Column("location", lambda e: truncate((e.get("location") or {}).get("displayName"))),
|
|
122
|
+
Column("id", "id"),
|
|
123
|
+
],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command("calendars")
|
|
128
|
+
@graph_command(scopes=["Calendars.Read"])
|
|
129
|
+
def calendars(client: GraphClient, json_: JsonFlag = False):
|
|
130
|
+
"""List the signed-in user's calendars."""
|
|
131
|
+
items = calendar_mod.list_calendars(client)
|
|
132
|
+
return ListResult(
|
|
133
|
+
items=items,
|
|
134
|
+
columns=[
|
|
135
|
+
Column("id", "id"),
|
|
136
|
+
Column("name", "name"),
|
|
137
|
+
Column("default", lambda c: "yes" if c.get("isDefaultCalendar") else ""),
|
|
138
|
+
Column("editable", lambda c: "yes" if c.get("canEdit") else ""),
|
|
139
|
+
Column("owner", lambda c: _fmt_owner(c.get("owner"))),
|
|
140
|
+
Column("color", "color"),
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command("get")
|
|
146
|
+
@graph_command(scopes=["Calendars.Read"])
|
|
147
|
+
def get(
|
|
148
|
+
client: GraphClient,
|
|
149
|
+
event_id: Annotated[str, typer.Argument(metavar="ID")],
|
|
150
|
+
html: Annotated[bool, typer.Option("--html")] = False,
|
|
151
|
+
json_: JsonFlag = False,
|
|
152
|
+
):
|
|
153
|
+
"""Show one event, with attendee response status and any Teams join URL."""
|
|
154
|
+
tz = client.tz
|
|
155
|
+
event = calendar_mod.get_event(client, event_id, html=html, tz=tz)
|
|
156
|
+
fields = [
|
|
157
|
+
("Subject", lambda e: e.get("subject") or ""),
|
|
158
|
+
("Start", lambda e: fmt_event_time(e, "start", tz)),
|
|
159
|
+
("End", lambda e: fmt_event_time(e, "end", tz)),
|
|
160
|
+
("Location", lambda e: (e.get("location") or {}).get("displayName") or ""),
|
|
161
|
+
("Organizer", lambda e: fmt_person(e.get("organizer"))),
|
|
162
|
+
("Show as", lambda e: e.get("showAs") or ""),
|
|
163
|
+
("Id", "id"),
|
|
164
|
+
]
|
|
165
|
+
sections = [calendar_mod.attendees_block(event)]
|
|
166
|
+
join_url = (event.get("onlineMeeting") or {}).get("joinUrl")
|
|
167
|
+
if join_url:
|
|
168
|
+
sections.append(f"Join URL: {join_url}")
|
|
169
|
+
body_obj = event.get("body") or {}
|
|
170
|
+
content = body_obj.get("content")
|
|
171
|
+
if content:
|
|
172
|
+
is_html = (body_obj.get("contentType") or "").lower() == "html"
|
|
173
|
+
sections.append(html_mod.to_markdown(content, "mail") if is_html and not html else content)
|
|
174
|
+
return ObjectResult(obj=event, fields=fields, body="\n\n".join(sections))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ------------------------------------------------------------ create / update / delete / respond
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _resolve_window(start: str | None, end: str | None, duration: str | None, tz: str) -> tuple:
|
|
181
|
+
if end is not None and duration is not None:
|
|
182
|
+
raise UsageError("USAGE", "--end and --duration are mutually exclusive")
|
|
183
|
+
start_dt = parse_dt(start, tz) if start is not None else None
|
|
184
|
+
end_dt = parse_dt(end, tz) if end is not None else None
|
|
185
|
+
dur_td = parse_duration(duration) if duration is not None else None
|
|
186
|
+
return start_dt, end_dt, dur_td
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command("create")
|
|
190
|
+
@graph_command(scopes=["Calendars.ReadWrite"])
|
|
191
|
+
def create(
|
|
192
|
+
client: GraphClient,
|
|
193
|
+
subject: Annotated[str, typer.Option("--subject", help="Event title.")],
|
|
194
|
+
start: Annotated[str, typer.Option("--start", help="Start date/time.")],
|
|
195
|
+
end: Annotated[str | None, typer.Option("--end", help="End date/time.")] = None,
|
|
196
|
+
duration: Annotated[str | None, typer.Option("--duration", help="30m, 2h, 1d.")] = None,
|
|
197
|
+
all_day: Annotated[bool | None, typer.Option("--all-day/--no-all-day")] = None,
|
|
198
|
+
attendees: Annotated[
|
|
199
|
+
list[str] | None, typer.Option("--attendees", help="Required attendee (repeatable).")
|
|
200
|
+
] = None,
|
|
201
|
+
optional: Annotated[
|
|
202
|
+
list[str] | None, typer.Option("--optional", help="Optional attendee (repeatable).")
|
|
203
|
+
] = None,
|
|
204
|
+
body: Annotated[str | None, typer.Option("--body")] = None,
|
|
205
|
+
html: Annotated[bool, typer.Option("--html")] = False,
|
|
206
|
+
location: Annotated[str | None, typer.Option("--location")] = None,
|
|
207
|
+
teams: Annotated[bool | None, typer.Option("--teams/--no-teams")] = None,
|
|
208
|
+
reminder: Annotated[
|
|
209
|
+
int | None, typer.Option("--reminder", help="Minutes before start.")
|
|
210
|
+
] = None,
|
|
211
|
+
show_as: Annotated[str | None, typer.Option("--show-as")] = None,
|
|
212
|
+
category: Annotated[
|
|
213
|
+
list[str] | None, typer.Option("--category", help="Category (repeatable).")
|
|
214
|
+
] = None,
|
|
215
|
+
calendar: Annotated[str | None, typer.Option("--calendar", help="Calendar name or id.")] = None,
|
|
216
|
+
transaction_id: Annotated[str | None, typer.Option("--transaction-id", hidden=True)] = None,
|
|
217
|
+
dry_run: DryRunFlag = False,
|
|
218
|
+
json_: JsonFlag = False,
|
|
219
|
+
):
|
|
220
|
+
"""Create a calendar event."""
|
|
221
|
+
_validate_show_as(show_as)
|
|
222
|
+
tz = client.tz
|
|
223
|
+
start_dt, end_dt, dur_td = _resolve_window(start, end, duration, tz)
|
|
224
|
+
if not all_day and end_dt is None and dur_td is None:
|
|
225
|
+
dur_td = timedelta(minutes=30)
|
|
226
|
+
calendar_id = calendar_mod.resolve_calendar(client, calendar)
|
|
227
|
+
params = calendar_mod.EventParams(
|
|
228
|
+
subject=subject,
|
|
229
|
+
start=start_dt,
|
|
230
|
+
end=end_dt,
|
|
231
|
+
duration=dur_td,
|
|
232
|
+
all_day=all_day,
|
|
233
|
+
attendees=attendees or [],
|
|
234
|
+
optional=optional or [],
|
|
235
|
+
body=body,
|
|
236
|
+
html=html,
|
|
237
|
+
location=location,
|
|
238
|
+
teams=teams,
|
|
239
|
+
reminder=reminder,
|
|
240
|
+
show_as=show_as,
|
|
241
|
+
categories=category or [],
|
|
242
|
+
calendar=calendar_id,
|
|
243
|
+
transaction_id=transaction_id,
|
|
244
|
+
)
|
|
245
|
+
plan = calendar_mod.plan_create(client, params, tz)
|
|
246
|
+
if dry_run:
|
|
247
|
+
return DryRunResult(plan)
|
|
248
|
+
event = calendar_mod.run_plan(client, plan)[0]
|
|
249
|
+
message = (
|
|
250
|
+
f"Created {event.get('subject')} {fmt_event_time(event, 'start', tz)} id: {event.get('id')}"
|
|
251
|
+
)
|
|
252
|
+
join_url = (event.get("onlineMeeting") or {}).get("joinUrl")
|
|
253
|
+
if join_url:
|
|
254
|
+
message += f"\nJoin URL: {join_url}"
|
|
255
|
+
return WriteResult(obj=event, message=message)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.command("update")
|
|
259
|
+
@graph_command(scopes=["Calendars.ReadWrite"])
|
|
260
|
+
def update(
|
|
261
|
+
client: GraphClient,
|
|
262
|
+
event_id: Annotated[str, typer.Argument(metavar="ID")],
|
|
263
|
+
subject: Annotated[str | None, typer.Option("--subject")] = None,
|
|
264
|
+
start: Annotated[str | None, typer.Option("--start")] = None,
|
|
265
|
+
end: Annotated[str | None, typer.Option("--end")] = None,
|
|
266
|
+
duration: Annotated[str | None, typer.Option("--duration")] = None,
|
|
267
|
+
all_day: Annotated[bool | None, typer.Option("--all-day/--no-all-day")] = None,
|
|
268
|
+
attendees: Annotated[list[str] | None, typer.Option("--attendees")] = None,
|
|
269
|
+
optional: Annotated[list[str] | None, typer.Option("--optional")] = None,
|
|
270
|
+
body: Annotated[str | None, typer.Option("--body")] = None,
|
|
271
|
+
html: Annotated[bool, typer.Option("--html")] = False,
|
|
272
|
+
location: Annotated[str | None, typer.Option("--location")] = None,
|
|
273
|
+
teams: Annotated[bool | None, typer.Option("--teams/--no-teams")] = None,
|
|
274
|
+
reminder: Annotated[int | None, typer.Option("--reminder")] = None,
|
|
275
|
+
show_as: Annotated[str | None, typer.Option("--show-as")] = None,
|
|
276
|
+
category: Annotated[list[str] | None, typer.Option("--category")] = None,
|
|
277
|
+
dry_run: DryRunFlag = False,
|
|
278
|
+
json_: JsonFlag = False,
|
|
279
|
+
):
|
|
280
|
+
"""Update a calendar event; only the given fields change.
|
|
281
|
+
|
|
282
|
+
Any `create` option except --calendar (Graph cannot move an event between calendars).
|
|
283
|
+
"""
|
|
284
|
+
_validate_show_as(show_as)
|
|
285
|
+
tz = client.tz
|
|
286
|
+
start_dt, end_dt, dur_td = _resolve_window(start, end, duration, tz)
|
|
287
|
+
params = calendar_mod.EventParams(
|
|
288
|
+
subject=subject,
|
|
289
|
+
start=start_dt,
|
|
290
|
+
end=end_dt,
|
|
291
|
+
duration=dur_td,
|
|
292
|
+
all_day=all_day,
|
|
293
|
+
attendees=attendees or [],
|
|
294
|
+
optional=optional or [],
|
|
295
|
+
body=body,
|
|
296
|
+
html=html,
|
|
297
|
+
location=location,
|
|
298
|
+
teams=teams,
|
|
299
|
+
reminder=reminder,
|
|
300
|
+
show_as=show_as,
|
|
301
|
+
categories=category or [],
|
|
302
|
+
)
|
|
303
|
+
plan = calendar_mod.plan_update(client, event_id, params, tz)
|
|
304
|
+
if dry_run:
|
|
305
|
+
return DryRunResult(plan)
|
|
306
|
+
event = calendar_mod.run_plan(client, plan)[0]
|
|
307
|
+
return WriteResult(obj=event, message=f"Updated {event_id}.")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.command("delete")
|
|
311
|
+
@graph_command(scopes=["Calendars.ReadWrite"])
|
|
312
|
+
def delete(
|
|
313
|
+
client: GraphClient,
|
|
314
|
+
event_id: Annotated[str, typer.Argument(metavar="ID")],
|
|
315
|
+
dry_run: DryRunFlag = False,
|
|
316
|
+
json_: JsonFlag = False,
|
|
317
|
+
):
|
|
318
|
+
"""Delete (cancel) a calendar event."""
|
|
319
|
+
plan = calendar_mod.plan_delete(client, event_id)
|
|
320
|
+
if dry_run:
|
|
321
|
+
return DryRunResult(plan)
|
|
322
|
+
calendar_mod.run_plan(client, plan)
|
|
323
|
+
return WriteResult(obj={"status": "deleted", "id": event_id}, message=f"Deleted {event_id}.")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@app.command("respond")
|
|
327
|
+
@graph_command(scopes=["Calendars.ReadWrite"])
|
|
328
|
+
def respond(
|
|
329
|
+
client: GraphClient,
|
|
330
|
+
event_id: Annotated[str, typer.Argument(metavar="ID")],
|
|
331
|
+
action: Annotated[str, typer.Argument(metavar="accept|decline|tentative")],
|
|
332
|
+
comment: Annotated[str | None, typer.Option("--comment")] = None,
|
|
333
|
+
send: Annotated[bool, typer.Option("--send/--no-send")] = True,
|
|
334
|
+
propose_start: Annotated[str | None, typer.Option("--propose-start")] = None,
|
|
335
|
+
propose_end: Annotated[str | None, typer.Option("--propose-end")] = None,
|
|
336
|
+
dry_run: DryRunFlag = False,
|
|
337
|
+
json_: JsonFlag = False,
|
|
338
|
+
):
|
|
339
|
+
"""Accept, decline or tentatively accept an event invitation."""
|
|
340
|
+
action = action.lower()
|
|
341
|
+
if action not in calendar_mod.RESPOND_ENDPOINTS:
|
|
342
|
+
raise UsageError("USAGE", "ACTION must be accept, decline or tentative")
|
|
343
|
+
if (propose_start is None) != (propose_end is None):
|
|
344
|
+
raise UsageError("USAGE", "--propose-start and --propose-end must be given together")
|
|
345
|
+
if propose_start is not None and action == "accept":
|
|
346
|
+
raise UsageError(
|
|
347
|
+
"USAGE", "--propose-start/--propose-end are only valid with decline or tentative"
|
|
348
|
+
)
|
|
349
|
+
tz = client.tz
|
|
350
|
+
propose = None
|
|
351
|
+
if propose_start is not None:
|
|
352
|
+
propose = (parse_dt(propose_start, tz), parse_dt(propose_end, tz))
|
|
353
|
+
plan = calendar_mod.plan_respond(
|
|
354
|
+
client, event_id, action, comment=comment, send=send, propose=propose, tz=tz
|
|
355
|
+
)
|
|
356
|
+
if dry_run:
|
|
357
|
+
return DryRunResult(plan)
|
|
358
|
+
calendar_mod.run_plan(client, plan)
|
|
359
|
+
status = calendar_mod.RESPOND_STATUS[action]
|
|
360
|
+
return WriteResult(obj={"status": status}, message=f"{status.capitalize()}.")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------ availability / find-times
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _floor(dt, interval: int):
|
|
367
|
+
minutes = dt.hour * 60 + dt.minute
|
|
368
|
+
floored = minutes - (minutes % interval)
|
|
369
|
+
return dt.replace(hour=floored // 60, minute=floored % 60, second=0, microsecond=0)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _availability_text(schedules: list[dict], start, interval: int, tz: str) -> str:
|
|
373
|
+
lines: list[str] = []
|
|
374
|
+
for item in schedules:
|
|
375
|
+
lines.append(item.get("scheduleId") or "")
|
|
376
|
+
for entry in item.get("scheduleItems") or []:
|
|
377
|
+
status = entry.get("status") or ""
|
|
378
|
+
start_full = fmt_dtz(entry.get("start"), tz)
|
|
379
|
+
end_full = fmt_dtz(entry.get("end"), tz)
|
|
380
|
+
subject = entry.get("subject")
|
|
381
|
+
line = f" {status} {start_full} – {_end_short(end_full)}"
|
|
382
|
+
if subject:
|
|
383
|
+
line += f" {subject}"
|
|
384
|
+
lines.append(line)
|
|
385
|
+
for window_start, window_end in calendar_mod.free_windows(item, start, interval):
|
|
386
|
+
lines.append(
|
|
387
|
+
f" free {window_start.strftime('%H:%M')} – {window_end.strftime('%H:%M')}"
|
|
388
|
+
)
|
|
389
|
+
return "\n".join(lines)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@app.command("availability")
|
|
393
|
+
@graph_command(scopes=["Calendars.Read"])
|
|
394
|
+
def availability(
|
|
395
|
+
client: GraphClient,
|
|
396
|
+
users: Annotated[
|
|
397
|
+
list[str] | None, typer.Option("--users", help="Mailbox (repeatable, default: me).")
|
|
398
|
+
] = None,
|
|
399
|
+
start: Annotated[str | None, typer.Option("--start")] = None,
|
|
400
|
+
end: Annotated[str | None, typer.Option("--end")] = None,
|
|
401
|
+
interval: Annotated[
|
|
402
|
+
int, typer.Option("--interval", min=5, max=1440, help="Minutes per slot.")
|
|
403
|
+
] = 30,
|
|
404
|
+
json_: JsonFlag = False,
|
|
405
|
+
):
|
|
406
|
+
"""Free/busy for one or more mailboxes over a window."""
|
|
407
|
+
tz = client.tz
|
|
408
|
+
if users:
|
|
409
|
+
emails = list(users)
|
|
410
|
+
else:
|
|
411
|
+
me = users_mod.get_me(client, select="mail,userPrincipalName")
|
|
412
|
+
emails = [me.get("mail") or me.get("userPrincipalName")]
|
|
413
|
+
start_dt = parse_dt(start, tz) if start is not None else _floor(parse_dt("now", tz), interval)
|
|
414
|
+
end_dt = (
|
|
415
|
+
parse_dt(end, tz, end_of_day=True)
|
|
416
|
+
if end is not None
|
|
417
|
+
else parse_dt("today", tz, end_of_day=True)
|
|
418
|
+
)
|
|
419
|
+
result = calendar_mod.get_schedule(
|
|
420
|
+
client, users=emails, start=start_dt, end=end_dt, interval=interval, tz=tz
|
|
421
|
+
)
|
|
422
|
+
schedules = result.get("value") or []
|
|
423
|
+
text = _availability_text(schedules, start_dt, interval, tz)
|
|
424
|
+
envelope = {
|
|
425
|
+
"items": schedules,
|
|
426
|
+
"count": len(schedules),
|
|
427
|
+
"fetched": len(schedules),
|
|
428
|
+
# getSchedule returns one entry per mailbox asked for; nothing is capped or paged.
|
|
429
|
+
"cap": None,
|
|
430
|
+
"truncated": False,
|
|
431
|
+
"window": Window(after=start_dt, before=end_dt).to_json(),
|
|
432
|
+
}
|
|
433
|
+
return TextResult(text=text, json_obj=envelope)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _format_find_times(result: dict, tz: str) -> str:
|
|
437
|
+
lines: list[str] = []
|
|
438
|
+
reason = result.get("emptySuggestionsReason")
|
|
439
|
+
if reason:
|
|
440
|
+
lines.append(f"No suggestions: {reason}")
|
|
441
|
+
for i, suggestion in enumerate(result.get("meetingTimeSuggestions") or [], 1):
|
|
442
|
+
slot = suggestion.get("meetingTimeSlot") or {}
|
|
443
|
+
start_full = fmt_dtz(slot.get("start"), tz)
|
|
444
|
+
end_full = fmt_dtz(slot.get("end"), tz)
|
|
445
|
+
confidence = suggestion.get("confidence")
|
|
446
|
+
lines.append(f"{i}. {start_full} – {_end_short(end_full)} confidence: {confidence}")
|
|
447
|
+
for entry in suggestion.get("attendeeAvailability") or []:
|
|
448
|
+
address = ((entry.get("attendee") or {}).get("emailAddress") or {}).get("address", "")
|
|
449
|
+
lines.append(f" {address}: {entry.get('availability')}")
|
|
450
|
+
if not lines:
|
|
451
|
+
lines.append("No suggestions.")
|
|
452
|
+
return "\n".join(lines)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@app.command("find-times")
|
|
456
|
+
@graph_command(scopes=["Calendars.Read.Shared"])
|
|
457
|
+
def find_times(
|
|
458
|
+
client: GraphClient,
|
|
459
|
+
attendees: Annotated[
|
|
460
|
+
list[str] | None, typer.Option("--attendees", help="Required attendee (repeatable).")
|
|
461
|
+
] = None,
|
|
462
|
+
duration: Annotated[str, typer.Option("--duration")] = "30m",
|
|
463
|
+
start: Annotated[str | None, typer.Option("--start")] = None,
|
|
464
|
+
end: Annotated[str | None, typer.Option("--end")] = None,
|
|
465
|
+
max_: Annotated[int, typer.Option("--max", help="Maximum candidates.")] = 5,
|
|
466
|
+
domain: Annotated[str, typer.Option("--domain")] = "work",
|
|
467
|
+
json_: JsonFlag = False,
|
|
468
|
+
):
|
|
469
|
+
"""Suggest meeting times for a set of attendees."""
|
|
470
|
+
if not attendees:
|
|
471
|
+
raise UsageError("USAGE", "--attendees needs at least one address")
|
|
472
|
+
if domain not in DOMAIN_VALUES:
|
|
473
|
+
raise UsageError("USAGE", f"--domain must be one of {', '.join(sorted(DOMAIN_VALUES))}")
|
|
474
|
+
tz = client.tz
|
|
475
|
+
start_dt = parse_dt(start, tz) if start is not None else parse_dt("now", tz)
|
|
476
|
+
end_dt = parse_dt(end, tz, end_of_day=True) if end is not None else start_dt + timedelta(days=7)
|
|
477
|
+
dur_td = parse_duration(duration)
|
|
478
|
+
result = calendar_mod.find_times(
|
|
479
|
+
client,
|
|
480
|
+
attendees=attendees,
|
|
481
|
+
duration=dur_td,
|
|
482
|
+
start=start_dt,
|
|
483
|
+
end=end_dt,
|
|
484
|
+
max_candidates=max_,
|
|
485
|
+
domain=domain,
|
|
486
|
+
tz=tz,
|
|
487
|
+
)
|
|
488
|
+
# `findMeetingTimes` answers with its own object, not a listing; the window is added so a
|
|
489
|
+
# consumer can still report what was asked for (spec §6.2).
|
|
490
|
+
payload = {**result, "window": Window(after=start_dt, before=end_dt).to_json()}
|
|
491
|
+
return TextResult(text=_format_find_times(result, tz), json_obj=payload)
|