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,89 @@
|
|
|
1
|
+
"""Graph operations for mailbox settings, automatic replies and the focused inbox (spec §8.3).
|
|
2
|
+
|
|
3
|
+
Pure: client and parameters in, Graph dicts / `PageResult` / `Plan` out.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from mgraphctl.graph import mail
|
|
11
|
+
from mgraphctl.html import text_to_html
|
|
12
|
+
from mgraphctl.http import JSON_HEADERS, GraphClient, PageResult, Plan, PlannedRequest
|
|
13
|
+
from mgraphctl.render import to_graph_dtz
|
|
14
|
+
|
|
15
|
+
SETTINGS_PATH = "/me/mailboxSettings"
|
|
16
|
+
OOF_PATH = "/me/mailboxSettings/automaticRepliesSetting"
|
|
17
|
+
FOCUSED_PATH = "/me/mailFolders/inbox/messages"
|
|
18
|
+
|
|
19
|
+
# `--external` values → the Graph `externalAudience` enum (spec §8.3 `oof set`).
|
|
20
|
+
AUDIENCE = {"all": "all", "contacts": "contactsOnly", "none": "none"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_settings(client: GraphClient) -> dict:
|
|
24
|
+
return client.get(SETTINGS_PATH)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_oof(client: GraphClient) -> dict:
|
|
28
|
+
return client.get(OOF_PATH)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def plan_set_oof(
|
|
32
|
+
client: GraphClient,
|
|
33
|
+
*,
|
|
34
|
+
message: str | None,
|
|
35
|
+
external_message: str | None,
|
|
36
|
+
start: datetime | None,
|
|
37
|
+
end: datetime | None,
|
|
38
|
+
audience: str,
|
|
39
|
+
clear: bool,
|
|
40
|
+
tz: str,
|
|
41
|
+
) -> Plan:
|
|
42
|
+
"""`scheduled` when both ends are given, `alwaysEnabled` otherwise, `disabled` for --clear."""
|
|
43
|
+
if clear:
|
|
44
|
+
setting: dict[str, object] = {"status": "disabled"}
|
|
45
|
+
else:
|
|
46
|
+
setting = {
|
|
47
|
+
"status": "scheduled" if start and end else "alwaysEnabled",
|
|
48
|
+
"externalAudience": audience,
|
|
49
|
+
}
|
|
50
|
+
if start and end:
|
|
51
|
+
setting["scheduledStartDateTime"] = to_graph_dtz(start, tz)
|
|
52
|
+
setting["scheduledEndDateTime"] = to_graph_dtz(end, tz)
|
|
53
|
+
setting["internalReplyMessage"] = text_to_html(message or "")
|
|
54
|
+
setting["externalReplyMessage"] = text_to_html(external_message or message or "")
|
|
55
|
+
return [
|
|
56
|
+
PlannedRequest(
|
|
57
|
+
"PATCH",
|
|
58
|
+
client.url(SETTINGS_PATH),
|
|
59
|
+
dict(JSON_HEADERS),
|
|
60
|
+
{"automaticRepliesSetting": setting},
|
|
61
|
+
)
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def list_focused(
|
|
66
|
+
client: GraphClient, *, other: bool, after: datetime, limit: int | None, all_: bool
|
|
67
|
+
) -> PageResult:
|
|
68
|
+
"""Inbox messages Outlook classified as focused (or `--other`), newest first.
|
|
69
|
+
|
|
70
|
+
`receivedDateTime` leads the filter so Graph can serve the `$orderby` from the same index.
|
|
71
|
+
"""
|
|
72
|
+
classification = "other" if other else "focused"
|
|
73
|
+
params = {
|
|
74
|
+
"$select": mail.LIST_SELECT,
|
|
75
|
+
"$filter": (
|
|
76
|
+
f"receivedDateTime ge {after.isoformat()} "
|
|
77
|
+
f"and inferenceClassification eq '{classification}'"
|
|
78
|
+
),
|
|
79
|
+
"$orderby": "receivedDateTime desc",
|
|
80
|
+
}
|
|
81
|
+
return client.paginate(
|
|
82
|
+
FOCUSED_PATH,
|
|
83
|
+
params=params,
|
|
84
|
+
outlook_tz=True,
|
|
85
|
+
limit=limit,
|
|
86
|
+
all_=all_,
|
|
87
|
+
cap=mail.CAP_LIST,
|
|
88
|
+
page_size=mail.PAGE_FILTER,
|
|
89
|
+
)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Graph operations for online meetings, transcripts and AI insights (spec §8.10).
|
|
2
|
+
|
|
3
|
+
Pure: client and parameters in, Graph dicts / lists out. `meetings list` resolves its own
|
|
4
|
+
calendar window (its own `$select`, no import of `graph/calendar` — that module is owned by
|
|
5
|
+
a different task and this one must not depend on it).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from mgraphctl import odata
|
|
14
|
+
from mgraphctl.errors import GraphError, MsgraphError, NotFoundError, UsageError
|
|
15
|
+
from mgraphctl.http import BatchRequest, DownloadResult, GraphClient, PageResult
|
|
16
|
+
from mgraphctl.render import to_iso_offset
|
|
17
|
+
|
|
18
|
+
EVENT_SELECT = "id,subject,start,end,organizer,isOnlineMeeting,onlineMeeting"
|
|
19
|
+
PAGE_SIZE = 50
|
|
20
|
+
CAP = 200
|
|
21
|
+
|
|
22
|
+
TRANSCRIPT_TEXT_ACCEPT = "application/vnd.microsoft.graph.transcript+text"
|
|
23
|
+
|
|
24
|
+
NO_LICENSE_NOTE = (
|
|
25
|
+
"AI insights require a Microsoft 365 Copilot license, or this meeting is not eligible."
|
|
26
|
+
)
|
|
27
|
+
NO_INSIGHTS_NOTE = "No AI insights are available for this meeting."
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_online_events(
|
|
31
|
+
client: GraphClient,
|
|
32
|
+
*,
|
|
33
|
+
start: datetime,
|
|
34
|
+
end: datetime,
|
|
35
|
+
subject: str | None,
|
|
36
|
+
limit: int,
|
|
37
|
+
tz: str,
|
|
38
|
+
) -> PageResult:
|
|
39
|
+
"""Calendar events in `[start, end]` that are online meetings with a join URL.
|
|
40
|
+
|
|
41
|
+
`truncated` (and `pages`) are threaded through from the underlying `calendarView`
|
|
42
|
+
pagination unchanged: filtering to online meetings (and by `--subject`) only ever removes
|
|
43
|
+
items, so a page that was truncated before filtering still means "there may be more".
|
|
44
|
+
"""
|
|
45
|
+
params = {
|
|
46
|
+
"startDateTime": to_iso_offset(start),
|
|
47
|
+
"endDateTime": to_iso_offset(end),
|
|
48
|
+
"$select": EVENT_SELECT,
|
|
49
|
+
}
|
|
50
|
+
page = client.paginate(
|
|
51
|
+
"/me/calendarView",
|
|
52
|
+
params=params,
|
|
53
|
+
outlook_tz=True,
|
|
54
|
+
limit=limit,
|
|
55
|
+
all_=False,
|
|
56
|
+
cap=CAP,
|
|
57
|
+
page_size=PAGE_SIZE,
|
|
58
|
+
)
|
|
59
|
+
events = [
|
|
60
|
+
e
|
|
61
|
+
for e in page.items
|
|
62
|
+
if e.get("isOnlineMeeting") and (e.get("onlineMeeting") or {}).get("joinUrl")
|
|
63
|
+
]
|
|
64
|
+
if subject:
|
|
65
|
+
needle = subject.casefold()
|
|
66
|
+
events = [e for e in events if needle in (e.get("subject") or "").casefold()]
|
|
67
|
+
return PageResult(items=events, truncated=page.truncated, pages=page.pages)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_meetings(client: GraphClient, events: list[dict]) -> dict[str, dict]:
|
|
71
|
+
"""`$batch` of one `onlineMeetings` join-URL filter per event; keyed by event id."""
|
|
72
|
+
targets = [e for e in events if (e.get("onlineMeeting") or {}).get("joinUrl")]
|
|
73
|
+
if not targets:
|
|
74
|
+
return {}
|
|
75
|
+
requests = [
|
|
76
|
+
BatchRequest(
|
|
77
|
+
id=event["id"],
|
|
78
|
+
method="GET",
|
|
79
|
+
url=odata.with_query(
|
|
80
|
+
"/me/onlineMeetings",
|
|
81
|
+
{"$filter": _join_url_filter(event["onlineMeeting"]["joinUrl"])},
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
for event in targets
|
|
85
|
+
]
|
|
86
|
+
resolved: dict[str, dict] = {}
|
|
87
|
+
for response in client.batch(requests):
|
|
88
|
+
if response.error is not None:
|
|
89
|
+
continue
|
|
90
|
+
values = (response.body or {}).get("value") or []
|
|
91
|
+
if values:
|
|
92
|
+
resolved[response.id] = values[0]
|
|
93
|
+
return resolved
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def transcripts_for(client: GraphClient, meeting_ids: list[str]) -> dict[str, list]:
|
|
97
|
+
"""`GET /me/onlineMeetings/{id}/transcripts` for each meeting id, one call per id."""
|
|
98
|
+
result: dict[str, list] = {}
|
|
99
|
+
for meeting_id in meeting_ids:
|
|
100
|
+
if meeting_id in result:
|
|
101
|
+
continue
|
|
102
|
+
page = client.get(odata.p("me", "onlineMeetings", meeting_id, "transcripts"))
|
|
103
|
+
result[meeting_id] = (page or {}).get("value") or []
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _join_url_filter(join_url: str) -> str:
|
|
108
|
+
return f"JoinWebUrl eq '{odata.odata_str(join_url)}'"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _by_join_url(client: GraphClient, join_url: str) -> dict:
|
|
112
|
+
result = client.get("/me/onlineMeetings", params={"$filter": _join_url_filter(join_url)})
|
|
113
|
+
values = (result or {}).get("value") or []
|
|
114
|
+
if not values:
|
|
115
|
+
raise NotFoundError("NOT_FOUND", f"no online meeting found for join URL {join_url!r}")
|
|
116
|
+
return values[0]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def select_meeting(
|
|
120
|
+
client: GraphClient, meeting: str | None, join_url: str | None, event: str | None
|
|
121
|
+
) -> dict:
|
|
122
|
+
"""Resolve exactly one of MEETING / `--join-url` / `--event` to a meeting dict (spec §6.6).
|
|
123
|
+
|
|
124
|
+
A bare `MEETING` id costs no request (`{"id": meeting}`); `--join-url` and `--event` both
|
|
125
|
+
end in the `$filter` lookup, which already returns the full record.
|
|
126
|
+
"""
|
|
127
|
+
given = [v for v in (meeting, join_url, event) if v is not None]
|
|
128
|
+
if len(given) != 1:
|
|
129
|
+
raise UsageError("USAGE", "give exactly one of MEETING, --join-url, or --event")
|
|
130
|
+
if meeting is not None:
|
|
131
|
+
return {"id": meeting}
|
|
132
|
+
if event is not None:
|
|
133
|
+
ev = client.get(odata.p("me", "events", event), params={"$select": "onlineMeeting"})
|
|
134
|
+
join_url = (ev.get("onlineMeeting") or {}).get("joinUrl")
|
|
135
|
+
if not join_url:
|
|
136
|
+
raise NotFoundError("NOT_FOUND", f"event {event!r} has no online meeting")
|
|
137
|
+
return _by_join_url(client, join_url)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_meeting(
|
|
141
|
+
client: GraphClient,
|
|
142
|
+
meeting: str | None = None,
|
|
143
|
+
*,
|
|
144
|
+
join_url: str | None = None,
|
|
145
|
+
event: str | None = None,
|
|
146
|
+
) -> dict:
|
|
147
|
+
"""The full online meeting record, however it was selected (`meetings get`, spec §8.10).
|
|
148
|
+
|
|
149
|
+
`select_meeting` costs no request for a bare id (it hands back `{"id": meeting}` so the
|
|
150
|
+
cheaper verbs never over-fetch); `get` always wants the full record, so a bare id gets one
|
|
151
|
+
extra `GET /me/onlineMeetings/{id}` here. `--join-url` and `--event` already resolve
|
|
152
|
+
through the `$filter` lookup, which returns the full record, so no second call is needed.
|
|
153
|
+
"""
|
|
154
|
+
selected = select_meeting(client, meeting, join_url, event)
|
|
155
|
+
if meeting is not None:
|
|
156
|
+
return client.get(odata.p("me", "onlineMeetings", selected["id"]))
|
|
157
|
+
return selected
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def list_transcripts(client: GraphClient, meeting_id: str) -> list[dict]:
|
|
161
|
+
result = client.get(odata.p("me", "onlineMeetings", meeting_id, "transcripts"))
|
|
162
|
+
return (result or {}).get("value") or []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def looks_like_vtt(body: str) -> bool:
|
|
166
|
+
"""Whether a transcript body is WebVTT, which its required signature line says (§8.11).
|
|
167
|
+
|
|
168
|
+
What Graph returns does not always follow what was asked for: `$format=text/vtt` can come
|
|
169
|
+
back as plain text on the speaker-attribution fallback, and some meeting types answer with
|
|
170
|
+
vtt regardless. The body itself is the only honest source.
|
|
171
|
+
"""
|
|
172
|
+
return body.lstrip("\ufeff").lstrip().startswith("WEBVTT")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_transcript_body(client: GraphClient, meeting_id: str, transcript_id: str) -> str:
|
|
176
|
+
"""The transcript exactly as Graph returned it — WebVTT, or plain text on the fallback.
|
|
177
|
+
|
|
178
|
+
A 403 `SpeakerAttributionNotAllowed` is retried once with an `Accept` header that asks
|
|
179
|
+
Graph for the plain-text representation directly (already speaker-scrubbed). That fallback
|
|
180
|
+
has no WebVTT form, so it always yields plain text; the caller sniffs the body with
|
|
181
|
+
`looks_like_vtt` rather than assuming it got what it asked for.
|
|
182
|
+
"""
|
|
183
|
+
path = odata.p("me", "onlineMeetings", meeting_id, "transcripts", transcript_id, "content")
|
|
184
|
+
try:
|
|
185
|
+
return client.request("GET", path, params={"$format": "text/vtt"}, expect="text")
|
|
186
|
+
except GraphError as exc:
|
|
187
|
+
if exc.status == 403 and exc.code == "SpeakerAttributionNotAllowed":
|
|
188
|
+
return client.request(
|
|
189
|
+
"GET", path, headers={"Accept": TRANSCRIPT_TEXT_ACCEPT}, expect="text"
|
|
190
|
+
)
|
|
191
|
+
raise
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def transcript_created_at(client: GraphClient, meeting_id: str, transcript_id: str) -> str | None:
|
|
195
|
+
"""Best-effort `createdDateTime` for one transcript, or None when the lookup fails.
|
|
196
|
+
|
|
197
|
+
Costs one extra list request, so only the `--json` path — the only one that emits the
|
|
198
|
+
field — pays it. The content fetch has already succeeded by this point, so a failure here
|
|
199
|
+
is never fatal.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
listed = list_transcripts(client, meeting_id)
|
|
203
|
+
except (MsgraphError, ValueError): # ValueError covers a 200 carrying unparseable JSON
|
|
204
|
+
return None
|
|
205
|
+
for item in listed:
|
|
206
|
+
if item.get("id") == transcript_id:
|
|
207
|
+
return item.get("createdDateTime")
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def insights(client: GraphClient, oid: str, meeting_id: str) -> tuple[list[dict], str | None]:
|
|
212
|
+
"""AI insights: v1.0 first, `/beta` on 404; per-item detail from whichever base answered.
|
|
213
|
+
|
|
214
|
+
A 403 at either stage becomes a soft "needs a Copilot license" note, not an error; a
|
|
215
|
+
per-item detail failure keeps the summary list entry instead of dropping it. The summary
|
|
216
|
+
call passes no explicit `beta=`, so it honours the client's global `--beta` flag like any
|
|
217
|
+
other call; `beta` here tracks whichever base actually answered (the client's own setting
|
|
218
|
+
when the first call succeeded, or `True` when the 404 fallback to `/beta` was needed) so
|
|
219
|
+
the per-item detail GETs land on the same base as the summary that named them.
|
|
220
|
+
"""
|
|
221
|
+
path = odata.p("copilot", "users", oid, "onlineMeetings", meeting_id, "aiInsights")
|
|
222
|
+
try:
|
|
223
|
+
summary = client.get(path)
|
|
224
|
+
beta = client.beta
|
|
225
|
+
except GraphError as exc:
|
|
226
|
+
if exc.status == 403:
|
|
227
|
+
return [], NO_LICENSE_NOTE
|
|
228
|
+
if exc.status != 404:
|
|
229
|
+
raise
|
|
230
|
+
try:
|
|
231
|
+
summary = client.get(path, beta=True)
|
|
232
|
+
beta = True
|
|
233
|
+
except GraphError as exc2:
|
|
234
|
+
if exc2.status == 403:
|
|
235
|
+
return [], NO_LICENSE_NOTE
|
|
236
|
+
if exc2.status == 404:
|
|
237
|
+
return [], NO_INSIGHTS_NOTE
|
|
238
|
+
raise
|
|
239
|
+
items = (summary or {}).get("value") or []
|
|
240
|
+
if not items:
|
|
241
|
+
return [], NO_INSIGHTS_NOTE
|
|
242
|
+
detailed: list[dict] = []
|
|
243
|
+
for item in items:
|
|
244
|
+
detail_path = odata.p(
|
|
245
|
+
"copilot",
|
|
246
|
+
"users",
|
|
247
|
+
oid,
|
|
248
|
+
"onlineMeetings",
|
|
249
|
+
meeting_id,
|
|
250
|
+
"aiInsights",
|
|
251
|
+
str(item.get("id")),
|
|
252
|
+
)
|
|
253
|
+
try:
|
|
254
|
+
detailed.append(client.get(detail_path, beta=beta))
|
|
255
|
+
except GraphError:
|
|
256
|
+
detailed.append(item)
|
|
257
|
+
return detailed, None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def list_recordings(client: GraphClient, meeting_id: str) -> list[dict]:
|
|
261
|
+
result = client.get(odata.p("me", "onlineMeetings", meeting_id, "recordings"))
|
|
262
|
+
return (result or {}).get("value") or []
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def download_recording(
|
|
266
|
+
client: GraphClient, meeting_id: str, recording_id: str, dest: Path
|
|
267
|
+
) -> DownloadResult:
|
|
268
|
+
path = odata.p("me", "onlineMeetings", meeting_id, "recordings", recording_id, "content")
|
|
269
|
+
return client.download(path, dest)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""OneDrive-only Graph operations (spec §8.11): base resolution for `--drive`, plus the two
|
|
2
|
+
listings that are always relative to the signed-in user's own drive (`sharedWithMe`, `recent`).
|
|
3
|
+
|
|
4
|
+
Every other onedrive verb delegates straight to `mgraphctl.graph.files` with a `base` resolved
|
|
5
|
+
by `base_for()` here; that module already implements every drive-item operation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from mgraphctl import odata
|
|
11
|
+
from mgraphctl.http import GraphClient, PageResult
|
|
12
|
+
|
|
13
|
+
CAP_SHARED_WITH_ME = 200
|
|
14
|
+
CAP_RECENT = 200
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def base_for(drive_id: str | None) -> str:
|
|
18
|
+
"""`/me/drive`, or `/drives/{id}` when `--drive` names another drive."""
|
|
19
|
+
if drive_id is None:
|
|
20
|
+
return "/me/drive"
|
|
21
|
+
return odata.p("drives", drive_id)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def shared_with_me(client: GraphClient, *, limit: int) -> PageResult:
|
|
25
|
+
"""Items other people have shared (§8.11 `shared-with-me`); always the caller's own drive."""
|
|
26
|
+
return client.paginate(
|
|
27
|
+
"/me/drive/sharedWithMe", limit=limit, all_=False, cap=CAP_SHARED_WITH_ME, page_size=None
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def recent(client: GraphClient, *, limit: int) -> PageResult:
|
|
32
|
+
"""Recently used files (§8.11 `recent`); always the caller's own drive."""
|
|
33
|
+
return client.paginate(
|
|
34
|
+
"/me/drive/recent", limit=limit, all_=False, cap=CAP_RECENT, page_size=None
|
|
35
|
+
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Graph operations for OneNote notebooks, sections and pages (spec §8.13).
|
|
2
|
+
|
|
3
|
+
Pure: client and parameters in, Graph dicts / PageResult / Plan out.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from html import escape as escape_html
|
|
9
|
+
|
|
10
|
+
from mgraphctl.errors import GraphError
|
|
11
|
+
from mgraphctl.html import text_to_html
|
|
12
|
+
from mgraphctl.http import GraphClient, PageResult, Plan, PlannedRequest
|
|
13
|
+
from mgraphctl.odata import p
|
|
14
|
+
from mgraphctl.resolve import looks_like_id, pick_unique, split_id_prefix
|
|
15
|
+
|
|
16
|
+
NOTEBOOK_SELECT = "id,displayName,lastModifiedDateTime,links"
|
|
17
|
+
SECTION_SELECT = "id,displayName,lastModifiedDateTime,parentNotebook"
|
|
18
|
+
PAGE_SELECT = "id,title,lastModifiedDateTime,links"
|
|
19
|
+
SEARCH_SELECT = "id,title,createdDateTime,parentSection"
|
|
20
|
+
HTML_CONTENT = {"Content-Type": "text/html"}
|
|
21
|
+
|
|
22
|
+
PAGE_TOP = 100
|
|
23
|
+
CAP_LIST = 500
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def list_notebooks(client: GraphClient, *, limit: int, all_: bool = False) -> PageResult:
|
|
27
|
+
return client.paginate(
|
|
28
|
+
"/me/onenote/notebooks",
|
|
29
|
+
params={"$select": NOTEBOOK_SELECT},
|
|
30
|
+
limit=limit,
|
|
31
|
+
all_=all_,
|
|
32
|
+
cap=CAP_LIST,
|
|
33
|
+
page_size=PAGE_TOP,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_notebook(client: GraphClient, value: str) -> str:
|
|
38
|
+
"""A notebook id, or the id of the notebook whose `displayName` matches `value` (§6.6)."""
|
|
39
|
+
bare, forced = split_id_prefix(value)
|
|
40
|
+
if looks_like_id(value, "onenote"):
|
|
41
|
+
return bare
|
|
42
|
+
payload = client.get("/me/onenote/notebooks", params={"$top": PAGE_TOP}) or {}
|
|
43
|
+
match = pick_unique(payload.get("value") or [], "displayName", value, what="notebook")
|
|
44
|
+
return match["id"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def list_sections(
|
|
48
|
+
client: GraphClient, notebook_id: str | None, *, limit: int, all_: bool = False
|
|
49
|
+
) -> PageResult:
|
|
50
|
+
path = (
|
|
51
|
+
"/me/onenote/sections"
|
|
52
|
+
if notebook_id is None
|
|
53
|
+
else p("me", "onenote", "notebooks", notebook_id, "sections")
|
|
54
|
+
)
|
|
55
|
+
return client.paginate(
|
|
56
|
+
path,
|
|
57
|
+
params={"$select": SECTION_SELECT},
|
|
58
|
+
limit=limit,
|
|
59
|
+
all_=all_,
|
|
60
|
+
cap=CAP_LIST,
|
|
61
|
+
page_size=PAGE_TOP,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_section(client: GraphClient, value: str) -> str:
|
|
66
|
+
"""A section id, or the id of the section whose `displayName` matches `value` (§6.6)."""
|
|
67
|
+
bare, forced = split_id_prefix(value)
|
|
68
|
+
if looks_like_id(value, "onenote"):
|
|
69
|
+
return bare
|
|
70
|
+
payload = client.get("/me/onenote/sections", params={"$top": PAGE_TOP}) or {}
|
|
71
|
+
match = pick_unique(payload.get("value") or [], "displayName", value, what="section")
|
|
72
|
+
return match["id"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def list_pages(client: GraphClient, section_id: str, *, limit: int, all_: bool) -> PageResult:
|
|
76
|
+
path = p("me", "onenote", "sections", section_id, "pages")
|
|
77
|
+
return client.paginate(
|
|
78
|
+
path,
|
|
79
|
+
params={"$select": PAGE_SELECT, "$orderby": "lastModifiedDateTime desc"},
|
|
80
|
+
limit=limit,
|
|
81
|
+
all_=all_,
|
|
82
|
+
cap=CAP_LIST,
|
|
83
|
+
page_size=PAGE_TOP,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_page(client: GraphClient, value: str) -> str:
|
|
88
|
+
"""A page id, or the id of the page whose `title` matches `value` (§6.6)."""
|
|
89
|
+
bare, forced = split_id_prefix(value)
|
|
90
|
+
if looks_like_id(value, "onenote"):
|
|
91
|
+
return bare
|
|
92
|
+
payload = client.get("/me/onenote/pages", params={"$top": PAGE_TOP}) or {}
|
|
93
|
+
match = pick_unique(payload.get("value") or [], "title", value, what="page")
|
|
94
|
+
return match["id"]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_page(client: GraphClient, page_id: str) -> tuple[dict, str]:
|
|
98
|
+
"""A page's metadata and its HTML content (two Graph calls; §8.13 `read`)."""
|
|
99
|
+
meta = client.get(p("me", "onenote", "pages", page_id), params={"$select": PAGE_SELECT})
|
|
100
|
+
content = client.get(
|
|
101
|
+
p("me", "onenote", "pages", page_id, "content"),
|
|
102
|
+
params={"includeIDs": True},
|
|
103
|
+
expect="text",
|
|
104
|
+
)
|
|
105
|
+
return meta, content
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def plan_create_page(
|
|
109
|
+
client: GraphClient, section_id: str, *, title: str, body: str, html: bool
|
|
110
|
+
) -> Plan:
|
|
111
|
+
"""`POST .../pages` with an HTML string body.
|
|
112
|
+
|
|
113
|
+
Title is always escaped; body is escaped and paragraph-wrapped unless `html` (quirk 14).
|
|
114
|
+
"""
|
|
115
|
+
content = body if html else text_to_html(body)
|
|
116
|
+
page_html = (
|
|
117
|
+
f"<!DOCTYPE html><html><head><title>{escape_html(title)}</title></head>"
|
|
118
|
+
f"<body>{content}</body></html>"
|
|
119
|
+
)
|
|
120
|
+
url = client.url(p("me", "onenote", "sections", section_id, "pages"))
|
|
121
|
+
return [PlannedRequest("POST", url, dict(HTML_CONTENT), page_html)]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def search_pages(client: GraphClient, q: str, *, limit: int) -> PageResult:
|
|
125
|
+
"""`$search` over page titles/content; Graph only supports this for consumer notebooks."""
|
|
126
|
+
try:
|
|
127
|
+
return client.paginate(
|
|
128
|
+
"/me/onenote/pages",
|
|
129
|
+
params={"$search": q, "$select": SEARCH_SELECT},
|
|
130
|
+
limit=limit,
|
|
131
|
+
all_=False,
|
|
132
|
+
cap=CAP_LIST,
|
|
133
|
+
page_size=PAGE_TOP,
|
|
134
|
+
)
|
|
135
|
+
except GraphError as exc:
|
|
136
|
+
if exc.status in (400, 501):
|
|
137
|
+
exc.hint = f"search {q} --type driveItem"
|
|
138
|
+
raise
|
mgraphctl/graph/org.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Graph operations for manager, direct reports and the management chain (spec §8.6).
|
|
2
|
+
|
|
3
|
+
Pure: client and parameters in, Graph dicts / PageResult / list[dict] out.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from mgraphctl import odata
|
|
9
|
+
from mgraphctl.errors import GraphError, NotFoundError
|
|
10
|
+
from mgraphctl.http import GraphClient, PageResult
|
|
11
|
+
|
|
12
|
+
MANAGER_SELECT = "id,displayName,userPrincipalName,mail,jobTitle,department"
|
|
13
|
+
CHAIN_SELECT = "id,displayName,userPrincipalName,jobTitle"
|
|
14
|
+
EVENTUAL = {"ConsistencyLevel": "eventual"}
|
|
15
|
+
|
|
16
|
+
PAGE = 100
|
|
17
|
+
CAP = 500
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _user_path(upn: str | None) -> str:
|
|
21
|
+
return "/me" if upn is None else odata.p("users", upn)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def manager(client: GraphClient, upn: str | None) -> dict:
|
|
25
|
+
"""The user's manager (self by default). 404 becomes a clear NOT_FOUND (spec §8.6)."""
|
|
26
|
+
try:
|
|
27
|
+
return client.get(f"{_user_path(upn)}/manager", params={"$select": MANAGER_SELECT})
|
|
28
|
+
except GraphError as exc:
|
|
29
|
+
if exc.status != 404:
|
|
30
|
+
raise
|
|
31
|
+
raise NotFoundError("NOT_FOUND", "No manager found", request_id=exc.request_id) from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def reports(client: GraphClient, upn: str | None, *, limit: int, all_: bool) -> PageResult:
|
|
35
|
+
"""The user's direct reports (self by default)."""
|
|
36
|
+
path = f"{_user_path(upn)}/directReports"
|
|
37
|
+
return client.paginate(
|
|
38
|
+
path, params={"$select": MANAGER_SELECT}, limit=limit, all_=all_, cap=CAP, page_size=PAGE
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _walk_expanded(obj: dict) -> list[dict]:
|
|
43
|
+
"""Self, then each nested `manager`, flattened from the `$expand` response."""
|
|
44
|
+
levels = []
|
|
45
|
+
current: dict | None = obj
|
|
46
|
+
while current is not None:
|
|
47
|
+
levels.append({k: v for k, v in current.items() if k != "manager"})
|
|
48
|
+
current = current.get("manager")
|
|
49
|
+
return levels
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def chain_expand(client: GraphClient, upn: str | None, *, max_levels: int) -> list[dict]:
|
|
53
|
+
"""The management chain via one `$expand=manager($levels=max)` call (spec §8.6 `chain`).
|
|
54
|
+
|
|
55
|
+
Self, then each nested `manager`, from the single response. Raises `GraphError` (400/403
|
|
56
|
+
on a tenant that rejects the expand) for the caller to catch and fall back to
|
|
57
|
+
`chain_iterative`.
|
|
58
|
+
"""
|
|
59
|
+
path = _user_path(upn)
|
|
60
|
+
expand = f"manager($levels=max;$select={CHAIN_SELECT})"
|
|
61
|
+
obj = client.get(path, params={"$expand": expand, "$count": True}, headers=EVENTUAL)
|
|
62
|
+
return _walk_expanded(obj)[: max_levels + 1]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def chain_iterative(client: GraphClient, upn: str | None, *, max_levels: int) -> list[dict]:
|
|
66
|
+
"""The management chain via a `/users/{id}/manager` walk, one level at a time.
|
|
67
|
+
|
|
68
|
+
The fallback path for tenants that reject `$expand`; callers gate this on
|
|
69
|
+
`User.Read.All` before calling it (spec §8.6, §4.4 — the branch-level gate lives in the
|
|
70
|
+
command layer, not here).
|
|
71
|
+
"""
|
|
72
|
+
start = client.get(_user_path(upn), params={"$select": CHAIN_SELECT})
|
|
73
|
+
levels = [start]
|
|
74
|
+
current_id = start.get("id")
|
|
75
|
+
while current_id and len(levels) <= max_levels:
|
|
76
|
+
try:
|
|
77
|
+
nxt = client.get(
|
|
78
|
+
f"{odata.p('users', current_id)}/manager", params={"$select": CHAIN_SELECT}
|
|
79
|
+
)
|
|
80
|
+
except GraphError as exc:
|
|
81
|
+
if exc.status == 404:
|
|
82
|
+
break
|
|
83
|
+
raise
|
|
84
|
+
levels.append(nxt)
|
|
85
|
+
current_id = nxt.get("id")
|
|
86
|
+
return levels
|