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.

Files changed (57) hide show
  1. mgraphctl/__init__.py +3 -0
  2. mgraphctl/__main__.py +3 -0
  3. mgraphctl/auth.py +395 -0
  4. mgraphctl/cli.py +259 -0
  5. mgraphctl/commands/__init__.py +71 -0
  6. mgraphctl/commands/api.py +164 -0
  7. mgraphctl/commands/calendar.py +491 -0
  8. mgraphctl/commands/chats.py +329 -0
  9. mgraphctl/commands/config_cmd.py +140 -0
  10. mgraphctl/commands/groups.py +65 -0
  11. mgraphctl/commands/mail.py +705 -0
  12. mgraphctl/commands/mailbox.py +176 -0
  13. mgraphctl/commands/meetings.py +294 -0
  14. mgraphctl/commands/onedrive.py +351 -0
  15. mgraphctl/commands/onenote.py +196 -0
  16. mgraphctl/commands/org.py +102 -0
  17. mgraphctl/commands/people.py +201 -0
  18. mgraphctl/commands/planner.py +268 -0
  19. mgraphctl/commands/presence.py +102 -0
  20. mgraphctl/commands/search.py +75 -0
  21. mgraphctl/commands/sharepoint.py +304 -0
  22. mgraphctl/commands/teams.py +235 -0
  23. mgraphctl/commands/todo.py +253 -0
  24. mgraphctl/commands/top.py +284 -0
  25. mgraphctl/config.py +385 -0
  26. mgraphctl/errors.py +276 -0
  27. mgraphctl/fixtures.py +149 -0
  28. mgraphctl/graph/__init__.py +1 -0
  29. mgraphctl/graph/calendar.py +313 -0
  30. mgraphctl/graph/chats.py +447 -0
  31. mgraphctl/graph/files.py +269 -0
  32. mgraphctl/graph/groups.py +55 -0
  33. mgraphctl/graph/mail.py +663 -0
  34. mgraphctl/graph/mailbox.py +89 -0
  35. mgraphctl/graph/meetings.py +269 -0
  36. mgraphctl/graph/onedrive.py +35 -0
  37. mgraphctl/graph/onenote.py +138 -0
  38. mgraphctl/graph/org.py +86 -0
  39. mgraphctl/graph/people.py +83 -0
  40. mgraphctl/graph/planner.py +415 -0
  41. mgraphctl/graph/presence.py +89 -0
  42. mgraphctl/graph/search.py +178 -0
  43. mgraphctl/graph/sharepoint.py +294 -0
  44. mgraphctl/graph/teams.py +240 -0
  45. mgraphctl/graph/todo.py +199 -0
  46. mgraphctl/graph/users.py +85 -0
  47. mgraphctl/html.py +127 -0
  48. mgraphctl/http.py +843 -0
  49. mgraphctl/odata.py +72 -0
  50. mgraphctl/render.py +627 -0
  51. mgraphctl/resolve.py +111 -0
  52. mgraphctl/token_store.py +196 -0
  53. mgraphctl-0.1.0.dist-info/METADATA +226 -0
  54. mgraphctl-0.1.0.dist-info/RECORD +57 -0
  55. mgraphctl-0.1.0.dist-info/WHEEL +4 -0
  56. mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
  57. mgraphctl-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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)]
@@ -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))
@@ -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
+ )