teams-transcripts 0.5.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.
- legacy/__init__.py +0 -0
- legacy/transcript_download.py +3969 -0
- teams_transcripts/__init__.py +1 -0
- teams_transcripts/__main__.py +288 -0
- teams_transcripts/adapters/__init__.py +1 -0
- teams_transcripts/adapters/cdp/__init__.py +1 -0
- teams_transcripts/adapters/cdp/browser.py +196 -0
- teams_transcripts/adapters/cdp/connection.py +168 -0
- teams_transcripts/adapters/cdp/helpers.py +449 -0
- teams_transcripts/adapters/cdp/teams.py +463 -0
- teams_transcripts/adapters/vertex.py +274 -0
- teams_transcripts/domain/__init__.py +1 -0
- teams_transcripts/domain/chat.py +115 -0
- teams_transcripts/domain/html.py +126 -0
- teams_transcripts/domain/mcps.py +343 -0
- teams_transcripts/domain/models.py +531 -0
- teams_transcripts/domain/participants.py +292 -0
- teams_transcripts/domain/scoring.py +106 -0
- teams_transcripts/domain/speakers.py +130 -0
- teams_transcripts/domain/summary.py +266 -0
- teams_transcripts/domain/timestamps.py +350 -0
- teams_transcripts/domain/vtt.py +444 -0
- teams_transcripts/infrastructure/__init__.py +1 -0
- teams_transcripts/infrastructure/config.py +987 -0
- teams_transcripts/infrastructure/errors.py +120 -0
- teams_transcripts/infrastructure/logging.py +69 -0
- teams_transcripts/infrastructure/signals.py +143 -0
- teams_transcripts/mcp_server.py +818 -0
- teams_transcripts/ports/__init__.py +1 -0
- teams_transcripts/ports/agent.py +58 -0
- teams_transcripts/ports/browser.py +126 -0
- teams_transcripts/services/__init__.py +1 -0
- teams_transcripts/services/downloader.py +1283 -0
- teams_transcripts/services/extraction.py +1603 -0
- teams_transcripts/services/listing.py +826 -0
- teams_transcripts/services/navigation.py +1721 -0
- teams_transcripts/services/output.py +84 -0
- teams_transcripts/services/tenant.py +684 -0
- teams_transcripts-0.5.0.dist-info/METADATA +1438 -0
- teams_transcripts-0.5.0.dist-info/RECORD +42 -0
- teams_transcripts-0.5.0.dist-info/WHEEL +4 -0
- teams_transcripts-0.5.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Pure result summary and VTT verification functions.
|
|
2
|
+
|
|
3
|
+
All functions in this module are pure: no I/O, no network, no logging,
|
|
4
|
+
no global state.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import typing
|
|
11
|
+
from collections import Counter
|
|
12
|
+
|
|
13
|
+
from teams_transcripts.domain.timestamps import parse_meeting_times
|
|
14
|
+
|
|
15
|
+
if typing.TYPE_CHECKING:
|
|
16
|
+
from teams_transcripts.domain.models import ChatMessage, MeetingParticipants, TranscriptCue
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_result_summary(
|
|
20
|
+
config: dict,
|
|
21
|
+
*,
|
|
22
|
+
num_parts: int = 1,
|
|
23
|
+
verification: dict | None = None,
|
|
24
|
+
chat_count: int = 0,
|
|
25
|
+
participants: MeetingParticipants | None = None,
|
|
26
|
+
chat_messages: list[ChatMessage] | None = None,
|
|
27
|
+
transcript_cues: list[TranscriptCue] | None = None,
|
|
28
|
+
include_transcript: bool = True,
|
|
29
|
+
include_chat: bool = True,
|
|
30
|
+
include_metadata: bool = True,
|
|
31
|
+
) -> dict:
|
|
32
|
+
"""Build a structured result summary from config and verification data.
|
|
33
|
+
|
|
34
|
+
Used for both ``--format json`` output and ``--dry-run`` summaries.
|
|
35
|
+
|
|
36
|
+
The output is composable: each component flag controls which fields
|
|
37
|
+
are included. When all three are True (default), backward-compatible
|
|
38
|
+
schema is produced.
|
|
39
|
+
|
|
40
|
+
When ``participants`` is provided and ``include_metadata`` is True,
|
|
41
|
+
the summary uses categorised fields (``organiser``, ``invited``,
|
|
42
|
+
``speakers_list``, ``attendees``).
|
|
43
|
+
|
|
44
|
+
When ``chat_messages`` is provided and ``include_chat`` is True,
|
|
45
|
+
the full ``chat`` array is included. When all components are
|
|
46
|
+
included, ``chat_messages`` count is also kept for backward compat.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
config: Meeting configuration dict.
|
|
50
|
+
num_parts: Number of transcript parts.
|
|
51
|
+
verification: Verification result dict (from :func:`verify_vtt_content`),
|
|
52
|
+
or ``None`` for dry-run.
|
|
53
|
+
chat_count: Number of extracted chat messages (backward compat).
|
|
54
|
+
participants: Optional :class:`MeetingParticipants` with
|
|
55
|
+
categorised participant lists.
|
|
56
|
+
chat_messages: Full list of :class:`ChatMessage` instances for the
|
|
57
|
+
``"chat"`` array. ``None`` when chat was not extracted.
|
|
58
|
+
include_transcript: Whether transcript component is included.
|
|
59
|
+
include_chat: Whether chat component is included.
|
|
60
|
+
include_metadata: Whether metadata component is included.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Summary dict matching the JSON output schema.
|
|
64
|
+
"""
|
|
65
|
+
summary: dict[str, object] = {"status": "ok"}
|
|
66
|
+
all_components = include_transcript and include_chat and include_metadata
|
|
67
|
+
|
|
68
|
+
# Meeting identity (always present)
|
|
69
|
+
summary["meeting"] = config["meeting"]
|
|
70
|
+
|
|
71
|
+
# Tenant (included only when --tenant was specified and verified)
|
|
72
|
+
if config.get("tenant"):
|
|
73
|
+
summary["tenant"] = config["tenant"]
|
|
74
|
+
|
|
75
|
+
time_text = config.get("meeting_time_text", "")
|
|
76
|
+
if time_text:
|
|
77
|
+
start_dt, end_dt = parse_meeting_times(time_text, config["date"])
|
|
78
|
+
summary["start"] = start_dt
|
|
79
|
+
if end_dt:
|
|
80
|
+
summary["end"] = end_dt
|
|
81
|
+
else:
|
|
82
|
+
summary["start"] = config["date"]
|
|
83
|
+
if config.get("time"):
|
|
84
|
+
summary["start"] = f"{config['date']}T{config['time']}"
|
|
85
|
+
|
|
86
|
+
# Participants (when metadata is included)
|
|
87
|
+
if include_metadata:
|
|
88
|
+
if participants is not None:
|
|
89
|
+
_add_participant_fields(summary, participants)
|
|
90
|
+
else:
|
|
91
|
+
_add_legacy_member_fields(summary, config)
|
|
92
|
+
|
|
93
|
+
# Transcript fields (when transcript is included)
|
|
94
|
+
if include_transcript:
|
|
95
|
+
summary["parts"] = num_parts
|
|
96
|
+
|
|
97
|
+
# Verification data (populated after download, absent for dry-run)
|
|
98
|
+
if verification and verification.get("valid"):
|
|
99
|
+
summary["cues"] = verification["cue_count"]
|
|
100
|
+
summary["speakers"] = verification["speakers"]
|
|
101
|
+
summary["file_size"] = verification["file_size"]
|
|
102
|
+
else:
|
|
103
|
+
# Dry-run: include speaker names with zero counts
|
|
104
|
+
speaker_map = config.get("speaker_map")
|
|
105
|
+
if speaker_map:
|
|
106
|
+
summary["speakers"] = dict.fromkeys(sorted(speaker_map.values()), 0)
|
|
107
|
+
|
|
108
|
+
# Structured transcript cues (when available)
|
|
109
|
+
if transcript_cues is not None:
|
|
110
|
+
summary["transcript"] = [_cue_to_dict(c) for c in transcript_cues]
|
|
111
|
+
|
|
112
|
+
# Chat (when chat is included)
|
|
113
|
+
if include_chat and chat_messages is not None:
|
|
114
|
+
summary["chat"] = [_chat_message_to_dict(m, config) for m in chat_messages]
|
|
115
|
+
|
|
116
|
+
# Backward compat: include chat_messages count when all components are on
|
|
117
|
+
if all_components and chat_count:
|
|
118
|
+
summary["chat_messages"] = chat_count
|
|
119
|
+
elif include_chat and not all_components and chat_count:
|
|
120
|
+
# Non-all-components mode: still include count for convenience
|
|
121
|
+
summary["chat_messages"] = chat_count
|
|
122
|
+
|
|
123
|
+
return summary
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _chat_message_to_dict(message: ChatMessage, config: dict) -> dict:
|
|
127
|
+
"""Convert a ChatMessage to a plain dict for JSON output.
|
|
128
|
+
|
|
129
|
+
Computes the offset from meeting start and includes reactions.
|
|
130
|
+
"""
|
|
131
|
+
d: dict[str, object] = {
|
|
132
|
+
"author": message.author,
|
|
133
|
+
"text": message.text,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Compute offset from meeting start
|
|
137
|
+
time_text = config.get("meeting_time_text", "")
|
|
138
|
+
offset = _compute_chat_offset(message.timestamp, time_text, config.get("date", ""))
|
|
139
|
+
d["offset"] = offset or None
|
|
140
|
+
|
|
141
|
+
# Reactions
|
|
142
|
+
if message.reactions:
|
|
143
|
+
d["reactions"] = [
|
|
144
|
+
{"type": r.type, "count": len(r.users), "users": r.users} for r in message.reactions
|
|
145
|
+
]
|
|
146
|
+
else:
|
|
147
|
+
d["reactions"] = None
|
|
148
|
+
|
|
149
|
+
return d
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _cue_to_dict(cue: TranscriptCue) -> dict:
|
|
153
|
+
"""Convert a TranscriptCue to a plain dict for JSON output.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Dict with keys: id, start, end, start_sec, end_sec, speaker, text.
|
|
157
|
+
"""
|
|
158
|
+
return {
|
|
159
|
+
"id": cue.id,
|
|
160
|
+
"start": cue.start,
|
|
161
|
+
"end": cue.end,
|
|
162
|
+
"start_sec": cue.start_sec,
|
|
163
|
+
"end_sec": cue.end_sec,
|
|
164
|
+
"speaker": cue.speaker,
|
|
165
|
+
"text": cue.text,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _compute_chat_offset(timestamp: str, meeting_time_text: str, date: str) -> str:
|
|
170
|
+
"""Compute HH:MM:SS.mmm offset from meeting start for a chat message.
|
|
171
|
+
|
|
172
|
+
Returns empty string if offset cannot be computed.
|
|
173
|
+
"""
|
|
174
|
+
from datetime import datetime as dt
|
|
175
|
+
|
|
176
|
+
from teams_transcripts.domain.timestamps import format_vtt_timestamp
|
|
177
|
+
|
|
178
|
+
if not meeting_time_text:
|
|
179
|
+
return ""
|
|
180
|
+
|
|
181
|
+
start_dt_str, _ = parse_meeting_times(meeting_time_text, date)
|
|
182
|
+
if not start_dt_str:
|
|
183
|
+
return ""
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
# Parse message timestamp and meeting start
|
|
187
|
+
msg_time = dt.fromisoformat(timestamp)
|
|
188
|
+
start_time = dt.fromisoformat(start_dt_str)
|
|
189
|
+
delta = msg_time - start_time
|
|
190
|
+
seconds = max(0.0, delta.total_seconds())
|
|
191
|
+
return format_vtt_timestamp(seconds)
|
|
192
|
+
except (ValueError, TypeError):
|
|
193
|
+
return ""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _member_to_dict(member: object) -> dict[str, str]:
|
|
197
|
+
"""Convert a RosterMember to a plain dict with name, email, and optional rsvp_status."""
|
|
198
|
+
d: dict[str, str] = {"name": member.name, "email": member.email} # type: ignore[union-attr]
|
|
199
|
+
rsvp = getattr(member, "rsvp_status", "")
|
|
200
|
+
if rsvp:
|
|
201
|
+
d["rsvp_status"] = rsvp
|
|
202
|
+
return d
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _add_participant_fields(summary: dict, participants: MeetingParticipants) -> None:
|
|
206
|
+
"""Add categorised participant fields to the summary dict."""
|
|
207
|
+
if participants.organiser:
|
|
208
|
+
org = participants.organiser[0]
|
|
209
|
+
summary["organiser"] = _member_to_dict(org)
|
|
210
|
+
|
|
211
|
+
if participants.invited:
|
|
212
|
+
summary["invited"] = [_member_to_dict(m) for m in participants.invited]
|
|
213
|
+
|
|
214
|
+
if participants.speakers:
|
|
215
|
+
summary["speakers_list"] = [_member_to_dict(m) for m in participants.speakers]
|
|
216
|
+
|
|
217
|
+
if participants.attendees:
|
|
218
|
+
summary["attendees"] = [_member_to_dict(m) for m in participants.attendees]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _add_legacy_member_fields(summary: dict, config: dict) -> None:
|
|
222
|
+
"""Add legacy members/organiser fields from roster or speaker_map."""
|
|
223
|
+
roster = config.get("roster_members", [])
|
|
224
|
+
if roster:
|
|
225
|
+
summary["members"] = sorted(m["name"] for m in roster)
|
|
226
|
+
organisers = sorted(m["name"] for m in roster if m["role"] == "organiser")
|
|
227
|
+
if organisers:
|
|
228
|
+
summary["organiser"] = organisers[0] if len(organisers) == 1 else organisers
|
|
229
|
+
else:
|
|
230
|
+
speaker_map = config.get("speaker_map")
|
|
231
|
+
if speaker_map:
|
|
232
|
+
summary["members"] = sorted(speaker_map.values())
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def verify_vtt_content(content: str, *, require_cues: bool = False) -> dict:
|
|
236
|
+
"""Verify VTT content and return a summary dict.
|
|
237
|
+
|
|
238
|
+
This is the pure version of ``verify_output()`` -- it operates on
|
|
239
|
+
content strings rather than file paths.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
content: VTT file content string.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Dict with ``"valid"`` bool and either error info or
|
|
246
|
+
``"cue_count"``, ``"speakers"`` keys.
|
|
247
|
+
"""
|
|
248
|
+
if not content.startswith("WEBVTT"):
|
|
249
|
+
return {"valid": False, "error": "missing WEBVTT header"}
|
|
250
|
+
|
|
251
|
+
# Count cues
|
|
252
|
+
cue_count = len(re.findall(r"\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}", content))
|
|
253
|
+
if require_cues and cue_count == 0:
|
|
254
|
+
return {"valid": False, "error": "no transcript cues found"}
|
|
255
|
+
|
|
256
|
+
# Extract speakers
|
|
257
|
+
speaker_pattern = re.compile(r"^<v ([^>]+)>", re.MULTILINE)
|
|
258
|
+
speakers = speaker_pattern.findall(content)
|
|
259
|
+
speaker_counts = Counter(speakers)
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
"valid": True,
|
|
263
|
+
"cue_count": cue_count,
|
|
264
|
+
"speakers": dict(speaker_counts),
|
|
265
|
+
"file_size": len(content.encode("utf-8")),
|
|
266
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Pure timestamp parsing, formatting, and manipulation functions.
|
|
2
|
+
|
|
3
|
+
All functions in this module are pure: no I/O, no network, no logging,
|
|
4
|
+
no global state. They form the foundation for VTT generation and
|
|
5
|
+
meeting time resolution.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# VTT timestamp parsing and formatting
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_vtt_timestamp(ts: str) -> float:
|
|
19
|
+
"""Parse a VTT timestamp (``HH:MM:SS.mmm``) to seconds.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
ts: Timestamp string in ``HH:MM:SS.mmm`` format.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Time in seconds as a float.
|
|
26
|
+
"""
|
|
27
|
+
parts = ts.split(":")
|
|
28
|
+
h = int(parts[0])
|
|
29
|
+
m = int(parts[1])
|
|
30
|
+
s_ms = parts[2].split(".")
|
|
31
|
+
s = int(s_ms[0])
|
|
32
|
+
ms = int(s_ms[1]) if len(s_ms) > 1 else 0
|
|
33
|
+
return h * 3600 + m * 60 + s + ms / 1000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_vtt_timestamp(seconds: float) -> str:
|
|
37
|
+
"""Format seconds as a VTT timestamp (``HH:MM:SS.mmm``).
|
|
38
|
+
|
|
39
|
+
Negative values are clamped to zero. Sub-millisecond fractions are
|
|
40
|
+
truncated (not rounded) to match the original implementation.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
seconds: Time in seconds.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Formatted timestamp string.
|
|
47
|
+
"""
|
|
48
|
+
seconds = max(0.0, seconds)
|
|
49
|
+
h = int(seconds) // 3600
|
|
50
|
+
m = (int(seconds) % 3600) // 60
|
|
51
|
+
s = int(seconds) % 60
|
|
52
|
+
ms = int((seconds % 1) * 1000)
|
|
53
|
+
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Approach-specific timestamp conversions
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sec_to_vtt_float(s: float) -> str:
|
|
62
|
+
"""Convert float seconds to VTT timestamp (Approach A).
|
|
63
|
+
|
|
64
|
+
Identical to :func:`format_vtt_timestamp` -- this alias exists to
|
|
65
|
+
make the Approach A origin explicit. Used when transcript data has
|
|
66
|
+
sub-second precision (React props extraction).
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
s: Time in seconds (float, may have fractional milliseconds).
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Formatted VTT timestamp.
|
|
73
|
+
"""
|
|
74
|
+
s = max(0.0, s)
|
|
75
|
+
h = int(s) // 3600
|
|
76
|
+
m = (int(s) % 3600) // 60
|
|
77
|
+
sec = int(s) % 60
|
|
78
|
+
ms = int((s % 1) * 1000)
|
|
79
|
+
return f"{h:02d}:{m:02d}:{sec:02d}.{ms:03d}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sec_to_vtt_int(s: int) -> str:
|
|
83
|
+
"""Convert integer seconds to VTT timestamp (Approach B).
|
|
84
|
+
|
|
85
|
+
Always produces ``.000`` milliseconds since DOM-extracted timestamps
|
|
86
|
+
have only second-level granularity.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
s: Time in integer seconds.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Formatted VTT timestamp with ``.000`` milliseconds.
|
|
93
|
+
"""
|
|
94
|
+
return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}.000"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ts_to_sec(ts: str | None) -> int | None:
|
|
98
|
+
"""Convert a Teams DOM timestamp (``MM:SS`` or ``HH:MM:SS``) to seconds.
|
|
99
|
+
|
|
100
|
+
Used for Approach B (DOM extraction) where timestamps come from the
|
|
101
|
+
virtualised transcript list.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
ts: Timestamp string, or ``None``/empty string.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Integer seconds, or ``None`` when input is falsy. Returns 0
|
|
108
|
+
for unrecognised formats.
|
|
109
|
+
"""
|
|
110
|
+
if not ts:
|
|
111
|
+
return None
|
|
112
|
+
parts = ts.split(":")
|
|
113
|
+
if len(parts) == 2:
|
|
114
|
+
return int(parts[0]) * 60 + int(parts[1])
|
|
115
|
+
if len(parts) == 3:
|
|
116
|
+
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Occurrence duration parsing
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_occurrence_duration(text: str) -> int | None:
|
|
126
|
+
"""Parse meeting duration in seconds from occurrence dropdown text.
|
|
127
|
+
|
|
128
|
+
Expected format: ``'17 April 2026 19:30 - 20:00'`` or similar.
|
|
129
|
+
|
|
130
|
+
Handles midnight crossing (e.g. ``23:30 - 00:30`` = 60 minutes).
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
text: Raw occurrence text from the dropdown.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Duration in seconds, or ``None`` if the text cannot be parsed.
|
|
137
|
+
"""
|
|
138
|
+
m = re.search(r"(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})", text)
|
|
139
|
+
if not m:
|
|
140
|
+
return None
|
|
141
|
+
start_min = int(m.group(1)) * 60 + int(m.group(2))
|
|
142
|
+
end_min = int(m.group(3)) * 60 + int(m.group(4))
|
|
143
|
+
if end_min <= start_min:
|
|
144
|
+
end_min += 24 * 60 # crosses midnight
|
|
145
|
+
return (end_min - start_min) * 60
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Meeting time parsing
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Month name lookup for Recap header text.
|
|
154
|
+
_MONTH_NAMES: dict[str, str] = {
|
|
155
|
+
"january": "01",
|
|
156
|
+
"february": "02",
|
|
157
|
+
"march": "03",
|
|
158
|
+
"april": "04",
|
|
159
|
+
"may": "05",
|
|
160
|
+
"june": "06",
|
|
161
|
+
"july": "07",
|
|
162
|
+
"august": "08",
|
|
163
|
+
"september": "09",
|
|
164
|
+
"october": "10",
|
|
165
|
+
"november": "11",
|
|
166
|
+
"december": "12",
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def parse_meeting_times(text: str, fallback_date: str) -> tuple[str, str]:
|
|
171
|
+
"""Parse occurrence text into ISO 8601 start and end datetime strings.
|
|
172
|
+
|
|
173
|
+
*text* has the form ``22 April 2026 11:15 - 12:00``.
|
|
174
|
+
*fallback_date* is ``YYYY-MM-DD`` used when the text cannot be parsed.
|
|
175
|
+
|
|
176
|
+
The Recap header displays times in the user's local timezone. The
|
|
177
|
+
local UTC offset for the parsed date is appended so the output is a
|
|
178
|
+
fully-qualified ISO 8601 datetime (e.g. ``2026-04-22T11:15+01:00``).
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
text: Raw Recap header time text.
|
|
182
|
+
fallback_date: Date string to return when parsing fails.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
``(start, end)`` where each is ``YYYY-MM-DDTHH:MM+HH:MM`` or
|
|
186
|
+
just ``YYYY-MM-DD`` if the time component is missing.
|
|
187
|
+
"""
|
|
188
|
+
m = re.match(
|
|
189
|
+
r"(\d{1,2})\s+(\w+)\s+(\d{4})\s+(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})",
|
|
190
|
+
text.strip(),
|
|
191
|
+
)
|
|
192
|
+
if not m:
|
|
193
|
+
return fallback_date, fallback_date
|
|
194
|
+
|
|
195
|
+
day = int(m.group(1))
|
|
196
|
+
month = _MONTH_NAMES.get(m.group(2).lower(), "01")
|
|
197
|
+
year = m.group(3)
|
|
198
|
+
start_time = m.group(4).zfill(5) # ensure HH:MM
|
|
199
|
+
end_time = m.group(5).zfill(5)
|
|
200
|
+
|
|
201
|
+
date_str = f"{year}-{month}-{day:02d}"
|
|
202
|
+
|
|
203
|
+
# Determine the local UTC offset for this specific date. The offset
|
|
204
|
+
# may differ from today's offset due to DST transitions.
|
|
205
|
+
sh, sm = (int(x) for x in start_time.split(":"))
|
|
206
|
+
try:
|
|
207
|
+
naive = datetime(int(year), int(month), day, sh, sm, tzinfo=timezone.utc)
|
|
208
|
+
local_dt = naive.astimezone(tz=None)
|
|
209
|
+
utc_offset = local_dt.utcoffset() or timedelta(0)
|
|
210
|
+
except (ValueError, OSError):
|
|
211
|
+
utc_offset = datetime.now(timezone.utc).astimezone().utcoffset() or timedelta(0)
|
|
212
|
+
|
|
213
|
+
offset_str = _format_utc_offset(utc_offset)
|
|
214
|
+
|
|
215
|
+
return f"{date_str}T{start_time}{offset_str}", f"{date_str}T{end_time}{offset_str}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# UTC to local time conversion
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def utc_to_local_iso(utc_timestamp: str) -> str:
|
|
224
|
+
"""Convert a UTC ISO 8601 timestamp to local time with UTC offset.
|
|
225
|
+
|
|
226
|
+
Accepts strings like ``2026-04-22T10:15:48.462Z`` and returns
|
|
227
|
+
``2026-04-22T11:15:48.462+01:00`` (for BST). If parsing fails
|
|
228
|
+
the original string is returned unchanged.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
utc_timestamp: UTC timestamp string (``Z`` or ``+00:00`` suffix).
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Local ISO 8601 string with UTC offset, or the original string
|
|
235
|
+
on parse failure.
|
|
236
|
+
"""
|
|
237
|
+
try:
|
|
238
|
+
dt = datetime.fromisoformat(utc_timestamp.replace("Z", "+00:00"))
|
|
239
|
+
local_dt = dt.astimezone(tz=None)
|
|
240
|
+
offset = local_dt.utcoffset() or timedelta(0)
|
|
241
|
+
offset_str = _format_utc_offset(offset)
|
|
242
|
+
# Format with milliseconds if present in original
|
|
243
|
+
if "." in utc_timestamp:
|
|
244
|
+
ms = f"{local_dt.microsecond // 1000:03d}"
|
|
245
|
+
return f"{local_dt.strftime('%Y-%m-%dT%H:%M:%S')}.{ms}{offset_str}"
|
|
246
|
+
return local_dt.strftime(f"%Y-%m-%dT%H:%M:%S{offset_str}")
|
|
247
|
+
except (ValueError, OSError):
|
|
248
|
+
return utc_timestamp
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Timestamp fill algorithms (Approach B)
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def forward_fill(segments: list[dict]) -> None:
|
|
257
|
+
"""Forward-fill null timestamps: set each to ``last_good + 10``.
|
|
258
|
+
|
|
259
|
+
Modifies *segments* in place. Leading ``None`` values are left
|
|
260
|
+
unchanged (handled by :func:`backfill`).
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
segments: List of segment dicts with a ``"ts_sec"`` key.
|
|
264
|
+
"""
|
|
265
|
+
last_good = None
|
|
266
|
+
for seg in segments:
|
|
267
|
+
if seg["ts_sec"] is not None:
|
|
268
|
+
last_good = seg["ts_sec"]
|
|
269
|
+
elif last_good is not None:
|
|
270
|
+
seg["ts_sec"] = last_good + 10
|
|
271
|
+
last_good = seg["ts_sec"]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def backfill(segments: list[dict]) -> None:
|
|
275
|
+
"""Backfill remaining null timestamps: set each to ``next_good - 10``.
|
|
276
|
+
|
|
277
|
+
Values are clamped to 0. Trailing ``None`` values are left unchanged
|
|
278
|
+
(handled by :func:`final_fallback`). Modifies *segments* in place.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
segments: List of segment dicts with a ``"ts_sec"`` key.
|
|
282
|
+
"""
|
|
283
|
+
next_good = None
|
|
284
|
+
for seg in reversed(segments):
|
|
285
|
+
if seg["ts_sec"] is not None:
|
|
286
|
+
next_good = seg["ts_sec"]
|
|
287
|
+
elif next_good is not None:
|
|
288
|
+
seg["ts_sec"] = max(0, next_good - 10)
|
|
289
|
+
next_good = seg["ts_sec"]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def final_fallback(segments: list[dict]) -> None:
|
|
293
|
+
"""Set any remaining null timestamps to 0.
|
|
294
|
+
|
|
295
|
+
Called after :func:`forward_fill` and :func:`backfill` to handle the
|
|
296
|
+
edge case where all timestamps were ``None``. Modifies *segments*
|
|
297
|
+
in place.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
segments: List of segment dicts with a ``"ts_sec"`` key.
|
|
301
|
+
"""
|
|
302
|
+
for seg in segments:
|
|
303
|
+
if seg["ts_sec"] is None:
|
|
304
|
+
seg["ts_sec"] = 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def detect_discontinuity(segments: list[dict]) -> list[dict]:
|
|
308
|
+
"""Truncate segments at a timestamp discontinuity.
|
|
309
|
+
|
|
310
|
+
A discontinuity is defined as a gap > 600 seconds AND a timestamp
|
|
311
|
+
> 2x the running maximum. These typically arise from virtualised
|
|
312
|
+
scroll artefacts in the Teams DOM.
|
|
313
|
+
|
|
314
|
+
With 2 or fewer segments, no truncation is applied.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
segments: List of segment dicts with a ``"ts_sec"`` key.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
The (possibly truncated) segment list.
|
|
321
|
+
"""
|
|
322
|
+
if len(segments) <= 2:
|
|
323
|
+
return segments
|
|
324
|
+
running_max = 0
|
|
325
|
+
cut_at = None
|
|
326
|
+
for i, seg in enumerate(segments):
|
|
327
|
+
ts = seg["ts_sec"]
|
|
328
|
+
if i > 0 and ts is not None and running_max > 0:
|
|
329
|
+
gap = ts - running_max
|
|
330
|
+
if gap > 600 and ts > running_max * 2:
|
|
331
|
+
cut_at = i
|
|
332
|
+
break
|
|
333
|
+
if ts is not None and ts > running_max:
|
|
334
|
+
running_max = ts
|
|
335
|
+
if cut_at is not None:
|
|
336
|
+
return segments[:cut_at]
|
|
337
|
+
return segments
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# Internal helpers
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _format_utc_offset(utc_offset: timedelta) -> str:
|
|
346
|
+
"""Format a UTC offset timedelta as ``+HH:MM`` or ``-HH:MM``."""
|
|
347
|
+
total_seconds = int(utc_offset.total_seconds())
|
|
348
|
+
sign = "+" if total_seconds >= 0 else "-"
|
|
349
|
+
abs_seconds = abs(total_seconds)
|
|
350
|
+
return f"{sign}{abs_seconds // 3600:02d}:{(abs_seconds % 3600) // 60:02d}"
|