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
mgraphctl/graph/teams.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Graph operations for Teams and channels (spec §8.7).
|
|
2
|
+
|
|
3
|
+
Pure: client and parameters in, Graph dicts, a `PageResult` or a `Plan` out.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from mgraphctl import odata, resolve
|
|
12
|
+
from mgraphctl.html import to_markdown
|
|
13
|
+
from mgraphctl.http import (
|
|
14
|
+
JSON_HEADERS,
|
|
15
|
+
GraphClient,
|
|
16
|
+
PageResult,
|
|
17
|
+
Plan,
|
|
18
|
+
PlannedRequest,
|
|
19
|
+
filter_page,
|
|
20
|
+
with_routing,
|
|
21
|
+
)
|
|
22
|
+
from mgraphctl.render import Column, fmt_dt, parse_graph_dt, truncate
|
|
23
|
+
|
|
24
|
+
CHANNEL_SELECT = "id,displayName,description,membershipType"
|
|
25
|
+
# The Teams endpoints reject a `$top` above 50.
|
|
26
|
+
PAGE_MEMBERS, CAP_MEMBERS = 50, 999
|
|
27
|
+
PAGE_MESSAGES, CAP_MESSAGES = 50, 200
|
|
28
|
+
CAP_TEAMS = CAP_CHANNELS = 999
|
|
29
|
+
BODY_CAP = 300
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def list_teams(client: GraphClient) -> PageResult:
|
|
33
|
+
"""Every team the signed-in user joined. `/me/joinedTeams` takes no OData parameters."""
|
|
34
|
+
return client.paginate("/me/joinedTeams", limit=None, all_=True, cap=CAP_TEAMS, page_size=None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_team(client: GraphClient, value: str) -> dict:
|
|
38
|
+
"""A GUID (or `id:`-forced value) as-is, else the team of that display name (§6.6)."""
|
|
39
|
+
bare, _ = resolve.split_id_prefix(value)
|
|
40
|
+
if resolve.looks_like_id(value, "team"):
|
|
41
|
+
return {"id": bare}
|
|
42
|
+
return resolve.pick_unique(list_teams(client).items, "displayName", value, what="team")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_channel(client: GraphClient, team_id: str, value: str) -> dict:
|
|
46
|
+
"""A `19:` id (or `id:`-forced value) as-is, else the channel of that display name (§6.6)."""
|
|
47
|
+
bare, _ = resolve.split_id_prefix(value)
|
|
48
|
+
if resolve.looks_like_id(value, "channel"):
|
|
49
|
+
return {"id": bare}
|
|
50
|
+
channels = list_channels(client, team_id).items
|
|
51
|
+
return resolve.pick_unique(channels, "displayName", value, what="channel")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_team(client: GraphClient, team_id: str) -> dict:
|
|
55
|
+
return client.get(odata.p("teams", team_id))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_members(client: GraphClient, team_id: str, *, limit: int | None, all_: bool) -> PageResult:
|
|
59
|
+
return client.paginate(
|
|
60
|
+
odata.p("teams", team_id, "members"),
|
|
61
|
+
limit=limit,
|
|
62
|
+
all_=all_,
|
|
63
|
+
cap=CAP_MEMBERS,
|
|
64
|
+
page_size=PAGE_MEMBERS,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def list_channels(client: GraphClient, team_id: str) -> PageResult:
|
|
69
|
+
return client.paginate(
|
|
70
|
+
odata.p("teams", team_id, "channels"),
|
|
71
|
+
params={"$select": CHANNEL_SELECT},
|
|
72
|
+
limit=None,
|
|
73
|
+
all_=True,
|
|
74
|
+
cap=CAP_CHANNELS,
|
|
75
|
+
page_size=None,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_channel(client: GraphClient, team_id: str, channel_id: str) -> dict:
|
|
80
|
+
return client.get(odata.p("teams", team_id, "channels", channel_id))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _renderable(message: dict) -> bool:
|
|
84
|
+
"""Tombstones and forward-compatible kinds carry no body worth showing (spec §8.7)."""
|
|
85
|
+
if message.get("deletedDateTime"):
|
|
86
|
+
return False
|
|
87
|
+
return message.get("messageType") != "unknownFutureValue"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _keep(page: PageResult) -> PageResult:
|
|
91
|
+
return filter_page(page, _renderable)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def chain_modified(message: dict) -> datetime | None:
|
|
95
|
+
"""When the message's whole reply chain was last touched, which is how Graph orders it.
|
|
96
|
+
|
|
97
|
+
Graph sorts channel messages "by the last modified date of the entire reply chain", so a
|
|
98
|
+
root message's own `lastModifiedDateTime` is not the key the feed is in order of. With
|
|
99
|
+
`--with-replies` the replies are in hand and the real key can be computed; without them
|
|
100
|
+
the root's own timestamp is the best available approximation.
|
|
101
|
+
"""
|
|
102
|
+
stamps = [
|
|
103
|
+
parse_graph_dt(m.get("lastModifiedDateTime") or m.get("createdDateTime"))
|
|
104
|
+
for m in [message, *(message.get("replies") or [])]
|
|
105
|
+
]
|
|
106
|
+
known = [s for s in stamps if s is not None]
|
|
107
|
+
return max(known) if known else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _window_filter(
|
|
111
|
+
after: datetime | None, before: datetime | None
|
|
112
|
+
) -> Callable[[dict], bool] | None:
|
|
113
|
+
"""Keep the messages whose reply chain was last touched inside the window."""
|
|
114
|
+
if after is None and before is None:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def keep(message: dict) -> bool:
|
|
118
|
+
stamp = chain_modified(message)
|
|
119
|
+
if stamp is None: # undatable: keep it rather than silently drop a message
|
|
120
|
+
return True
|
|
121
|
+
if after is not None and stamp <= after:
|
|
122
|
+
return False
|
|
123
|
+
return before is None or stamp < before
|
|
124
|
+
|
|
125
|
+
return keep
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _before_window(after: datetime | None) -> Callable[[dict], bool] | None:
|
|
129
|
+
"""Page no further once a message's chain was last touched at or before `after`."""
|
|
130
|
+
if after is None:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def past_it(message: dict) -> bool:
|
|
134
|
+
stamp = chain_modified(message)
|
|
135
|
+
return stamp is not None and stamp <= after
|
|
136
|
+
|
|
137
|
+
return past_it
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def channel_messages(
|
|
141
|
+
client: GraphClient,
|
|
142
|
+
team_id: str,
|
|
143
|
+
channel_id: str,
|
|
144
|
+
*,
|
|
145
|
+
with_replies: bool,
|
|
146
|
+
after: datetime | None = None,
|
|
147
|
+
before: datetime | None = None,
|
|
148
|
+
limit: int | None,
|
|
149
|
+
all_: bool,
|
|
150
|
+
) -> PageResult:
|
|
151
|
+
"""Channel messages, newest-modified first, optionally narrowed to a time window.
|
|
152
|
+
|
|
153
|
+
The window is applied here rather than as a `$filter`: Graph documents `$top` and
|
|
154
|
+
`$expand` as the only query parameters this endpoint supports (v1.0 and beta alike), so a
|
|
155
|
+
`$filter` would be rejected or — worse — ignored, and an ignored one would dress an
|
|
156
|
+
unfiltered page up as a window. Since the feed is ordered newest-first, paging can still
|
|
157
|
+
stop at the far edge of the window instead of walking to the cap.
|
|
158
|
+
"""
|
|
159
|
+
params = {"$expand": "replies"} if with_replies else None
|
|
160
|
+
keep = _window_filter(after, before)
|
|
161
|
+
page = client.paginate(
|
|
162
|
+
odata.p("teams", team_id, "channels", channel_id, "messages"),
|
|
163
|
+
params=params,
|
|
164
|
+
limit=limit,
|
|
165
|
+
all_=all_,
|
|
166
|
+
cap=CAP_MESSAGES,
|
|
167
|
+
page_size=PAGE_MESSAGES,
|
|
168
|
+
stop=_before_window(after),
|
|
169
|
+
)
|
|
170
|
+
page = with_routing(_keep(page), teamId=team_id, channelId=channel_id)
|
|
171
|
+
return page if keep is None else filter_page(page, keep)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def channel_replies(
|
|
175
|
+
client: GraphClient,
|
|
176
|
+
team_id: str,
|
|
177
|
+
channel_id: str,
|
|
178
|
+
msg_id: str,
|
|
179
|
+
*,
|
|
180
|
+
limit: int | None,
|
|
181
|
+
all_: bool,
|
|
182
|
+
) -> PageResult:
|
|
183
|
+
page = client.paginate(
|
|
184
|
+
odata.p("teams", team_id, "channels", channel_id, "messages", msg_id, "replies"),
|
|
185
|
+
limit=limit,
|
|
186
|
+
all_=all_,
|
|
187
|
+
cap=CAP_MESSAGES,
|
|
188
|
+
page_size=PAGE_MESSAGES,
|
|
189
|
+
)
|
|
190
|
+
return with_routing(_keep(page), teamId=team_id, channelId=channel_id)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def message_sender(message: dict) -> str:
|
|
194
|
+
"""The display name behind a message's `from`, whichever identity kind carries it."""
|
|
195
|
+
identity = message.get("from") or {}
|
|
196
|
+
for kind in ("user", "application", "device"):
|
|
197
|
+
name = (identity.get(kind) or {}).get("displayName")
|
|
198
|
+
if name:
|
|
199
|
+
return name
|
|
200
|
+
return ""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def render_message_body(m: dict, full: bool) -> str:
|
|
204
|
+
"""One line of body text: HTML through Markdown, capped at 300 chars unless `full`."""
|
|
205
|
+
body = m.get("body") or {}
|
|
206
|
+
content = body.get("content") or ""
|
|
207
|
+
if (body.get("contentType") or "").lower() == "html":
|
|
208
|
+
content = to_markdown(content, mode="teams")
|
|
209
|
+
single_line = " ".join(content.split())
|
|
210
|
+
return single_line if full else truncate(single_line, BODY_CAP)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def message_columns(tz: str, full: bool) -> list[Column]:
|
|
214
|
+
return [
|
|
215
|
+
Column("created", lambda m: fmt_dt(m.get("createdDateTime"), tz)),
|
|
216
|
+
Column("id", "id"),
|
|
217
|
+
Column("from", message_sender),
|
|
218
|
+
Column("body", lambda m: render_message_body(m, full)),
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def plan_channel_send(
|
|
223
|
+
client: GraphClient,
|
|
224
|
+
team_id: str,
|
|
225
|
+
channel_id: str,
|
|
226
|
+
*,
|
|
227
|
+
body: str,
|
|
228
|
+
html: bool,
|
|
229
|
+
subject: str | None,
|
|
230
|
+
reply_to: str | None,
|
|
231
|
+
) -> Plan:
|
|
232
|
+
"""One POST. Plain text goes out as `contentType: "text"`, so nothing needs escaping."""
|
|
233
|
+
segments = ["teams", team_id, "channels", channel_id, "messages"]
|
|
234
|
+
if reply_to is not None:
|
|
235
|
+
segments += [reply_to, "replies"]
|
|
236
|
+
payload: dict[str, object] = {}
|
|
237
|
+
if subject is not None:
|
|
238
|
+
payload["subject"] = subject
|
|
239
|
+
payload["body"] = {"contentType": "html" if html else "text", "content": body}
|
|
240
|
+
return [PlannedRequest("POST", client.url(odata.p(*segments)), dict(JSON_HEADERS), payload)]
|
mgraphctl/graph/todo.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Graph operations for Microsoft To Do (spec §8.15).
|
|
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
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mgraphctl import odata, render
|
|
12
|
+
from mgraphctl.errors import UsageError
|
|
13
|
+
from mgraphctl.http import JSON_HEADERS, GraphClient, PageResult, Plan, PlannedRequest
|
|
14
|
+
from mgraphctl.resolve import looks_like_id, pick_unique, split_id_prefix
|
|
15
|
+
|
|
16
|
+
LIST_PAGE = 100
|
|
17
|
+
TASK_PAGE = 100
|
|
18
|
+
CAP_TASKS = 500
|
|
19
|
+
MESSAGE_SELECT = "subject,webLink,bodyPreview,from,receivedDateTime"
|
|
20
|
+
IMPORTANCE_VALUES = ("low", "normal", "high")
|
|
21
|
+
STATUS_VALUES = ("notStarted", "inProgress", "completed", "waitingOnOthers", "deferred")
|
|
22
|
+
WELLKNOWN_ALIASES = {"defaultlist": "defaultList", "flaggedemails": "flaggedEmails"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run(client: GraphClient, plan: Plan) -> Any:
|
|
26
|
+
"""Execute every step of `plan` in order, returning the last step's result."""
|
|
27
|
+
result: Any = None
|
|
28
|
+
for step in plan:
|
|
29
|
+
result = client.execute(step)
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------------------------------- reads
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def list_lists(client: GraphClient) -> list[dict]:
|
|
37
|
+
payload = client.get("/me/todo/lists", params={"$top": LIST_PAGE}) or {}
|
|
38
|
+
return payload.get("value") or []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_list(client: GraphClient, value: str) -> dict:
|
|
42
|
+
"""A list id, well-known name (`defaultList`/`flaggedEmails`), or `displayName` (§6.6)."""
|
|
43
|
+
if looks_like_id(value, "todo_list"):
|
|
44
|
+
bare, _ = split_id_prefix(value)
|
|
45
|
+
return {"id": bare}
|
|
46
|
+
lists_ = list_lists(client)
|
|
47
|
+
wellknown = WELLKNOWN_ALIASES.get(value.strip().lower())
|
|
48
|
+
if wellknown is not None:
|
|
49
|
+
return pick_unique(lists_, "wellknownListName", wellknown, what="list")
|
|
50
|
+
return pick_unique(lists_, "displayName", value, what="list")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def list_tasks(
|
|
54
|
+
client: GraphClient,
|
|
55
|
+
list_id: str,
|
|
56
|
+
*,
|
|
57
|
+
include_completed: bool = False,
|
|
58
|
+
limit: int | None,
|
|
59
|
+
all_: bool,
|
|
60
|
+
) -> PageResult:
|
|
61
|
+
params: dict[str, object] = {}
|
|
62
|
+
if not include_completed:
|
|
63
|
+
params["$filter"] = "status ne 'completed'"
|
|
64
|
+
path = odata.p("me", "todo", "lists", list_id, "tasks")
|
|
65
|
+
return client.paginate(
|
|
66
|
+
path,
|
|
67
|
+
params=params,
|
|
68
|
+
outlook_tz=True,
|
|
69
|
+
limit=limit,
|
|
70
|
+
all_=all_,
|
|
71
|
+
cap=CAP_TASKS,
|
|
72
|
+
page_size=TASK_PAGE,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_task(client: GraphClient, list_id: str, task_id: str) -> dict:
|
|
77
|
+
path = odata.p("me", "todo", "lists", list_id, "tasks", task_id)
|
|
78
|
+
return client.get(path, params={"$expand": "checklistItems,linkedResources"}, outlook_tz=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --------------------------------------------------------------------------- writes
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def task_body(
|
|
85
|
+
*,
|
|
86
|
+
title: str | None = None,
|
|
87
|
+
due: datetime | None = None,
|
|
88
|
+
body_text: str | None = None,
|
|
89
|
+
importance: str | None = None,
|
|
90
|
+
reminder: datetime | None = None,
|
|
91
|
+
start: datetime | None = None,
|
|
92
|
+
status: str | None = None,
|
|
93
|
+
tz: str,
|
|
94
|
+
) -> dict:
|
|
95
|
+
body: dict[str, Any] = {}
|
|
96
|
+
if title is not None:
|
|
97
|
+
body["title"] = title
|
|
98
|
+
if body_text is not None:
|
|
99
|
+
body["body"] = {"content": body_text, "contentType": "text"}
|
|
100
|
+
if importance is not None:
|
|
101
|
+
if importance not in IMPORTANCE_VALUES:
|
|
102
|
+
raise UsageError("USAGE", "--importance must be low, normal or high")
|
|
103
|
+
body["importance"] = importance
|
|
104
|
+
if due is not None:
|
|
105
|
+
body["dueDateTime"] = render.to_graph_dtz(due, tz)
|
|
106
|
+
if reminder is not None:
|
|
107
|
+
body["reminderDateTime"] = render.to_graph_dtz(reminder, tz)
|
|
108
|
+
body["isReminderOn"] = True
|
|
109
|
+
if start is not None:
|
|
110
|
+
body["startDateTime"] = render.to_graph_dtz(start, tz)
|
|
111
|
+
if status is not None:
|
|
112
|
+
if status not in STATUS_VALUES:
|
|
113
|
+
raise UsageError("USAGE", f"--status must be one of {', '.join(STATUS_VALUES)}")
|
|
114
|
+
body["status"] = status
|
|
115
|
+
return body
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def plan_create(client: GraphClient, list_id: str, **opts: Any) -> Plan:
|
|
119
|
+
body = task_body(**opts)
|
|
120
|
+
url = client.url(odata.p("me", "todo", "lists", list_id, "tasks"))
|
|
121
|
+
return [PlannedRequest("POST", url, dict(JSON_HEADERS), body)]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_create(client: GraphClient, list_id: str, **opts: Any) -> dict:
|
|
125
|
+
return _run(client, plan_create(client, list_id, **opts))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def plan_update(client: GraphClient, list_id: str, task_id: str, **opts: Any) -> Plan:
|
|
129
|
+
body = task_body(**opts)
|
|
130
|
+
url = client.url(odata.p("me", "todo", "lists", list_id, "tasks", task_id))
|
|
131
|
+
return [PlannedRequest("PATCH", url, dict(JSON_HEADERS), body)]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def run_update(client: GraphClient, list_id: str, task_id: str, **opts: Any) -> dict:
|
|
135
|
+
return _run(client, plan_update(client, list_id, task_id, **opts))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def plan_complete(client: GraphClient, list_id: str, task_id: str, *, tz: str) -> Plan:
|
|
139
|
+
return plan_update(client, list_id, task_id, status="completed", tz=tz)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run_complete(client: GraphClient, list_id: str, task_id: str, *, tz: str) -> dict:
|
|
143
|
+
return _run(client, plan_complete(client, list_id, task_id, tz=tz))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def plan_delete(client: GraphClient, list_id: str, task_id: str) -> Plan:
|
|
147
|
+
url = client.url(odata.p("me", "todo", "lists", list_id, "tasks", task_id))
|
|
148
|
+
return [PlannedRequest("DELETE", url, {}, None, expect="none")]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def run_delete(client: GraphClient, list_id: str, task_id: str) -> None:
|
|
152
|
+
_run(client, plan_delete(client, list_id, task_id))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def fetch_message_for_task(client: GraphClient, msg_id: str) -> dict:
|
|
156
|
+
"""A minimal mail message view for `from-mail` (spec §8.15). Not shared with `graph/mail.py`."""
|
|
157
|
+
path = odata.p("me", "messages", msg_id)
|
|
158
|
+
return client.get(path, params={"$select": MESSAGE_SELECT})
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def plan_from_mail(
|
|
162
|
+
client: GraphClient,
|
|
163
|
+
list_id: str,
|
|
164
|
+
message: dict,
|
|
165
|
+
*,
|
|
166
|
+
title: str | None = None,
|
|
167
|
+
due: datetime | None = None,
|
|
168
|
+
importance: str | None = None,
|
|
169
|
+
tz: str,
|
|
170
|
+
) -> Plan:
|
|
171
|
+
subject = message.get("subject") or ""
|
|
172
|
+
sender = render.fmt_person(message.get("from"))
|
|
173
|
+
received = message.get("receivedDateTime")
|
|
174
|
+
received_str = render.fmt_dt(received, tz) if received else "N/A"
|
|
175
|
+
content = f"From: {sender}\nReceived: {received_str}\n\n{message.get('bodyPreview') or ''}"
|
|
176
|
+
body: dict[str, Any] = {
|
|
177
|
+
"title": title or subject,
|
|
178
|
+
"body": {"content": content, "contentType": "text"},
|
|
179
|
+
"linkedResources": [
|
|
180
|
+
{
|
|
181
|
+
"webUrl": message.get("webLink"),
|
|
182
|
+
"applicationName": "Microsoft Outlook",
|
|
183
|
+
"displayName": subject,
|
|
184
|
+
"externalId": message.get("id"),
|
|
185
|
+
}
|
|
186
|
+
],
|
|
187
|
+
}
|
|
188
|
+
if due is not None:
|
|
189
|
+
body["dueDateTime"] = render.to_graph_dtz(due, tz)
|
|
190
|
+
if importance is not None:
|
|
191
|
+
if importance not in IMPORTANCE_VALUES:
|
|
192
|
+
raise UsageError("USAGE", "--importance must be low, normal or high")
|
|
193
|
+
body["importance"] = importance
|
|
194
|
+
url = client.url(odata.p("me", "todo", "lists", list_id, "tasks"))
|
|
195
|
+
return [PlannedRequest("POST", url, dict(JSON_HEADERS), body)]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def run_from_mail(client: GraphClient, list_id: str, message: dict, **opts: Any) -> dict:
|
|
199
|
+
return _run(client, plan_from_mail(client, list_id, message, **opts))
|
mgraphctl/graph/users.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Graph operations for users and the directory (spec §8.1, §8.5, §8.16).
|
|
2
|
+
|
|
3
|
+
Pure: client and parameters in, Graph dicts or a `PageResult` out.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from mgraphctl import odata
|
|
9
|
+
from mgraphctl.errors import UsageError
|
|
10
|
+
from mgraphctl.http import GraphClient, PageResult
|
|
11
|
+
from mgraphctl.resolve import looks_like_id, pick_unique
|
|
12
|
+
|
|
13
|
+
ME_SELECT = (
|
|
14
|
+
"id,displayName,userPrincipalName,mail,jobTitle,department,officeLocation,"
|
|
15
|
+
"businessPhones,mobilePhone,preferredLanguage"
|
|
16
|
+
)
|
|
17
|
+
USER_SELECT = (
|
|
18
|
+
"id,displayName,userPrincipalName,mail,jobTitle,department,officeLocation,"
|
|
19
|
+
"businessPhones,mobilePhone"
|
|
20
|
+
)
|
|
21
|
+
USERS_SEARCH_SELECT = "id,displayName,userPrincipalName,mail,jobTitle,department,officeLocation"
|
|
22
|
+
|
|
23
|
+
GROUPS_PATH = "/me/memberOf/microsoft.graph.group"
|
|
24
|
+
UNIFIED_FILTER = "groupTypes/any(c:c eq 'Unified')"
|
|
25
|
+
EVENTUAL = {"ConsistencyLevel": "eventual"}
|
|
26
|
+
|
|
27
|
+
PAGE_SEARCH = 100
|
|
28
|
+
PAGE_GROUPS = 999
|
|
29
|
+
CAP = 999
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def user_path(ref: str) -> str:
|
|
33
|
+
"""`/me` for the signed-in user, `/users/<ref>` (encoded) for anyone else."""
|
|
34
|
+
return "/me" if ref == "me" else odata.p("users", ref)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_me(client: GraphClient, *, select: str = ME_SELECT) -> dict:
|
|
38
|
+
return client.get("/me", params={"$select": select})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_user(client: GraphClient, ref: str, *, select: str = USER_SELECT) -> dict:
|
|
42
|
+
return client.get(user_path(ref), params={"$select": select})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def search_users(client: GraphClient, q: str, *, limit: int, all_: bool) -> PageResult:
|
|
46
|
+
"""Directory search. `$search` is tokenised, not substring, and needs ConsistencyLevel."""
|
|
47
|
+
term = f'"displayName:{q}" OR "mail:{q}"'
|
|
48
|
+
params = {"$search": term, "$count": True, "$select": USERS_SEARCH_SELECT}
|
|
49
|
+
return client.paginate(
|
|
50
|
+
"/users",
|
|
51
|
+
params=params,
|
|
52
|
+
headers=EVENTUAL,
|
|
53
|
+
limit=limit,
|
|
54
|
+
all_=all_,
|
|
55
|
+
cap=CAP,
|
|
56
|
+
page_size=PAGE_SEARCH,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def list_unified_groups(client: GraphClient) -> list[dict]:
|
|
61
|
+
"""Every Microsoft 365 group the signed-in user belongs to, id and name only."""
|
|
62
|
+
params = {"$filter": UNIFIED_FILTER, "$count": True, "$select": "id,displayName"}
|
|
63
|
+
return client.paginate(
|
|
64
|
+
GROUPS_PATH,
|
|
65
|
+
params=params,
|
|
66
|
+
headers=EVENTUAL,
|
|
67
|
+
limit=None,
|
|
68
|
+
all_=True,
|
|
69
|
+
cap=CAP,
|
|
70
|
+
page_size=PAGE_GROUPS,
|
|
71
|
+
).items
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_user(client: GraphClient, value: str, *, can_search: bool) -> dict:
|
|
75
|
+
"""Turn a UPN, id or display name into a user object (§6.6)."""
|
|
76
|
+
if looks_like_id(value, "user"):
|
|
77
|
+
return get_user(client, value)
|
|
78
|
+
if not can_search:
|
|
79
|
+
raise UsageError(
|
|
80
|
+
"USAGE",
|
|
81
|
+
f"{value!r} is not a UPN or id; directory search needs User.ReadBasic.All "
|
|
82
|
+
"(login --scopes extended)",
|
|
83
|
+
)
|
|
84
|
+
page = search_users(client, value, limit=50, all_=False)
|
|
85
|
+
return pick_unique(page.items, "displayName", value, what="user")
|
mgraphctl/html.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""HTML → Markdown, plain text → HTML and WebVTT → text conversion (spec §6.2, §7.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as html_stdlib
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from bs4 import BeautifulSoup
|
|
11
|
+
from markdownify import MarkdownConverter
|
|
12
|
+
|
|
13
|
+
_HOSTED_CONTENT_RE = re.compile(r"/hostedContents/([^/]+)/\$value")
|
|
14
|
+
_BLANK_LINES_RE = re.compile(r"\n{3,}")
|
|
15
|
+
_PARAGRAPH_SPLIT_RE = re.compile(r"\n{2,}")
|
|
16
|
+
_CUE_TIMING_RE = re.compile(r"^(\d{2}:\d{2}:\d{2})\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}")
|
|
17
|
+
_VOICE_RE = re.compile(r"^<v\s+([^>]+)>(.*)</v>$", re.DOTALL)
|
|
18
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
19
|
+
|
|
20
|
+
_CONVERT_OPTIONS = {
|
|
21
|
+
"heading_style": "ATX",
|
|
22
|
+
"bullets": "-",
|
|
23
|
+
"escape_underscores": False,
|
|
24
|
+
"escape_asterisks": False,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _TeamsConverter(MarkdownConverter):
|
|
29
|
+
"""Renders Teams-specific markup: mentions, attachments, hosted images, system events."""
|
|
30
|
+
|
|
31
|
+
def convert_at(self, el, text, parent_tags):
|
|
32
|
+
return f"@{text}"
|
|
33
|
+
|
|
34
|
+
def convert_attachment(self, el, text, parent_tags):
|
|
35
|
+
name = el.attrs.get("name") or el.attrs.get("id") or ""
|
|
36
|
+
return f"[attachment: {name}]"
|
|
37
|
+
|
|
38
|
+
def convert_img(self, el, text, parent_tags):
|
|
39
|
+
src = el.attrs.get("src") or ""
|
|
40
|
+
match = _HOSTED_CONTENT_RE.search(src)
|
|
41
|
+
if match:
|
|
42
|
+
return f"[image: hostedContents/{match.group(1)}]"
|
|
43
|
+
return super().convert_img(el, text, parent_tags)
|
|
44
|
+
|
|
45
|
+
def convert_systemeventmessage(self, el, text, parent_tags):
|
|
46
|
+
return "[system event]"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def to_markdown(html: str, mode: Literal["teams", "onenote", "mail"]) -> str:
|
|
50
|
+
"""Convert a Graph HTML body to Markdown, applying Teams markers only in "teams" mode."""
|
|
51
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
52
|
+
for tag in soup.find_all(("style", "script")):
|
|
53
|
+
tag.decompose()
|
|
54
|
+
converter_cls = _TeamsConverter if mode == "teams" else MarkdownConverter
|
|
55
|
+
text = converter_cls(**_CONVERT_OPTIONS).convert_soup(soup)
|
|
56
|
+
return _BLANK_LINES_RE.sub("\n\n", text).strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def text_to_html(text: str) -> str:
|
|
60
|
+
"""Convert plain text to escaped HTML: blank-line-separated `<p>`, `<br>` within a paragraph."""
|
|
61
|
+
paragraphs = _PARAGRAPH_SPLIT_RE.split(text)
|
|
62
|
+
rendered = []
|
|
63
|
+
for paragraph in paragraphs:
|
|
64
|
+
lines = paragraph.split("\n")
|
|
65
|
+
escaped = "<br>".join(html_stdlib.escape(line) for line in lines)
|
|
66
|
+
rendered.append(f"<p>{escaped}</p>")
|
|
67
|
+
return "".join(rendered)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cues(vtt: str) -> Iterator[tuple[str, list[tuple[str | None, str]]]]:
|
|
71
|
+
"""Yield `(timestamp, [(speaker | None, text), ...])` for every cue in a WebVTT document.
|
|
72
|
+
|
|
73
|
+
A cue can carry more than one line, and only some of them name a speaker with `<v Name>`,
|
|
74
|
+
so each line is kept as its own segment for the caller to render or merge.
|
|
75
|
+
"""
|
|
76
|
+
for block in _PARAGRAPH_SPLIT_RE.split(vtt.strip()):
|
|
77
|
+
lines = [ln for ln in block.splitlines() if ln.strip()]
|
|
78
|
+
timing_idx = next(
|
|
79
|
+
(i for i, ln in enumerate(lines) if _CUE_TIMING_RE.match(ln.strip())), None
|
|
80
|
+
)
|
|
81
|
+
if timing_idx is None:
|
|
82
|
+
continue
|
|
83
|
+
stamp = _CUE_TIMING_RE.match(lines[timing_idx].strip()).group(1)
|
|
84
|
+
segments: list[tuple[str | None, str]] = []
|
|
85
|
+
for text_line in lines[timing_idx + 1 :]:
|
|
86
|
+
voice = _VOICE_RE.match(text_line.strip())
|
|
87
|
+
if voice:
|
|
88
|
+
# The name is kept exactly as the tag spelled it: `vtt_to_text` renders it
|
|
89
|
+
# verbatim, and only turn-merging, which has to compare names, normalises it.
|
|
90
|
+
name, content = voice.groups()
|
|
91
|
+
segments.append((name, _TAG_RE.sub("", content).strip()))
|
|
92
|
+
else:
|
|
93
|
+
segments.append((None, _TAG_RE.sub("", text_line).strip()))
|
|
94
|
+
yield stamp, segments
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def vtt_to_text(vtt: str) -> str:
|
|
98
|
+
"""Convert a WebVTT transcript to `[HH:MM:SS] Speaker: text` lines, one per cue."""
|
|
99
|
+
lines_out = []
|
|
100
|
+
for stamp, segments in _cues(vtt):
|
|
101
|
+
rendered = [f"{name}: {text}" if name else text for name, text in segments]
|
|
102
|
+
lines_out.append(f"[{stamp}] {' '.join(rendered)}")
|
|
103
|
+
return "\n".join(lines_out)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def vtt_to_speaker_turns(vtt: str) -> str:
|
|
107
|
+
"""Convert a WebVTT transcript to speaker turns: `**Speaker:** text`, blank line between.
|
|
108
|
+
|
|
109
|
+
Consecutive cues from the same speaker are one turn — a transcript cues every few seconds,
|
|
110
|
+
so rendering one line per cue chops a single remark into a dozen fragments. A line with no
|
|
111
|
+
`<v>` tag continues the turn it falls inside.
|
|
112
|
+
"""
|
|
113
|
+
turns: list[tuple[str | None, list[str]]] = []
|
|
114
|
+
for _, segments in _cues(vtt):
|
|
115
|
+
for raw_name, text in segments:
|
|
116
|
+
if not text:
|
|
117
|
+
continue
|
|
118
|
+
name = raw_name.strip() if raw_name is not None else None
|
|
119
|
+
speaker = name if name is not None else (turns[-1][0] if turns else None)
|
|
120
|
+
if turns and turns[-1][0] == speaker:
|
|
121
|
+
turns[-1][1].append(text)
|
|
122
|
+
else:
|
|
123
|
+
turns.append((speaker, [text]))
|
|
124
|
+
return "\n\n".join(
|
|
125
|
+
f"**{speaker}:** {' '.join(parts)}" if speaker else " ".join(parts)
|
|
126
|
+
for speaker, parts in turns
|
|
127
|
+
)
|