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,444 @@
|
|
|
1
|
+
"""Pure VTT generation, merging, and post-processing functions.
|
|
2
|
+
|
|
3
|
+
All functions in this module are pure: no I/O, no network, no logging,
|
|
4
|
+
no global state. They take string content in and return string content out.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import typing
|
|
11
|
+
from html import escape as html_escape
|
|
12
|
+
from html import unescape as html_unescape
|
|
13
|
+
|
|
14
|
+
from teams_transcripts.domain.models import TranscriptCue
|
|
15
|
+
from teams_transcripts.domain.timestamps import (
|
|
16
|
+
format_vtt_timestamp,
|
|
17
|
+
parse_meeting_times,
|
|
18
|
+
parse_vtt_timestamp,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if typing.TYPE_CHECKING:
|
|
22
|
+
from teams_transcripts.domain.models import MeetingParticipants, RosterMember
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def merge_parts(vtt_contents: list[str]) -> str:
|
|
26
|
+
"""Merge multiple VTT file contents into a single VTT.
|
|
27
|
+
|
|
28
|
+
Each part's timestamps are offset so that Part 2 continues from
|
|
29
|
+
Part 1's last cue end time, and so on. Cues are renumbered
|
|
30
|
+
sequentially across all parts.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
vtt_contents: List of VTT file contents (strings), one per part.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Merged VTT content as a single string.
|
|
37
|
+
"""
|
|
38
|
+
timestamp_re = re.compile(r"(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})")
|
|
39
|
+
|
|
40
|
+
all_cues: list[tuple[float, float, list[str]]] = []
|
|
41
|
+
cumulative_offset = 0.0
|
|
42
|
+
|
|
43
|
+
for vtt_text in vtt_contents:
|
|
44
|
+
lines = vtt_text.split("\n")
|
|
45
|
+
part_cues: list[tuple[float, float, list[str]]] = []
|
|
46
|
+
|
|
47
|
+
i = 0
|
|
48
|
+
while i < len(lines):
|
|
49
|
+
line = lines[i].strip()
|
|
50
|
+
ts_match = timestamp_re.match(line)
|
|
51
|
+
if ts_match:
|
|
52
|
+
start = parse_vtt_timestamp(ts_match.group(1))
|
|
53
|
+
end = parse_vtt_timestamp(ts_match.group(2))
|
|
54
|
+
# Collect cue content lines (everything until next blank line)
|
|
55
|
+
content_lines = []
|
|
56
|
+
i += 1
|
|
57
|
+
while i < len(lines) and lines[i].strip():
|
|
58
|
+
content_lines.append(lines[i])
|
|
59
|
+
i += 1
|
|
60
|
+
part_cues.append(
|
|
61
|
+
(start + cumulative_offset, end + cumulative_offset, content_lines)
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
i += 1
|
|
65
|
+
|
|
66
|
+
# Update offset: next part starts after this part's last cue end
|
|
67
|
+
if part_cues:
|
|
68
|
+
last_end = max(end for _, end, _ in part_cues)
|
|
69
|
+
cumulative_offset = last_end
|
|
70
|
+
|
|
71
|
+
all_cues.extend(part_cues)
|
|
72
|
+
|
|
73
|
+
# Build merged VTT
|
|
74
|
+
result = ["WEBVTT", ""]
|
|
75
|
+
for idx, (start, end, content_lines) in enumerate(all_cues, 1):
|
|
76
|
+
result.append(str(idx))
|
|
77
|
+
result.append(f"{format_vtt_timestamp(start)} --> {format_vtt_timestamp(end)}")
|
|
78
|
+
result.extend(content_lines)
|
|
79
|
+
result.append("")
|
|
80
|
+
|
|
81
|
+
return "\n".join(result)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def prepend_metadata(
|
|
85
|
+
content: str,
|
|
86
|
+
config: dict,
|
|
87
|
+
*,
|
|
88
|
+
participants: MeetingParticipants | None = None,
|
|
89
|
+
) -> str:
|
|
90
|
+
"""Insert a VTT NOTE block with meeting metadata after the WEBVTT header.
|
|
91
|
+
|
|
92
|
+
The NOTE block contains the meeting title, participant information,
|
|
93
|
+
and start/end times.
|
|
94
|
+
|
|
95
|
+
When ``participants`` is provided, the block uses categorised lines
|
|
96
|
+
(Organiser, Invited, Speakers, Attendees). Otherwise it falls back
|
|
97
|
+
to the legacy roster/speaker-map format.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
content: Raw VTT file content.
|
|
101
|
+
config: Meeting configuration dict with keys ``"meeting"``,
|
|
102
|
+
``"date"``, ``"time"``, and optionally ``"roster_members"``,
|
|
103
|
+
``"speaker_map"``, ``"meeting_time_text"``.
|
|
104
|
+
participants: Optional :class:`MeetingParticipants` with
|
|
105
|
+
categorised participant lists.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
VTT content with the metadata NOTE block inserted.
|
|
109
|
+
"""
|
|
110
|
+
# Build the NOTE block
|
|
111
|
+
lines = ["NOTE Meeting Metadata"]
|
|
112
|
+
lines.append(f"Meeting: {config['meeting']}")
|
|
113
|
+
|
|
114
|
+
if participants is not None:
|
|
115
|
+
_append_participant_lines(lines, participants)
|
|
116
|
+
else:
|
|
117
|
+
_append_legacy_roster_lines(lines, config)
|
|
118
|
+
|
|
119
|
+
# Start/end times
|
|
120
|
+
time_text = config.get("meeting_time_text", "")
|
|
121
|
+
if time_text:
|
|
122
|
+
start_dt, end_dt = parse_meeting_times(time_text, config["date"])
|
|
123
|
+
else:
|
|
124
|
+
start_dt = config["date"]
|
|
125
|
+
if config.get("time"):
|
|
126
|
+
start_dt = f"{config['date']}T{config['time']}"
|
|
127
|
+
end_dt = ""
|
|
128
|
+
|
|
129
|
+
lines.append(f"Start: {start_dt}")
|
|
130
|
+
if end_dt:
|
|
131
|
+
lines.append(f"End: {end_dt}")
|
|
132
|
+
|
|
133
|
+
note_block = "\n".join(lines)
|
|
134
|
+
|
|
135
|
+
# Insert after the WEBVTT header line
|
|
136
|
+
if content.startswith("WEBVTT"):
|
|
137
|
+
content = content.replace("WEBVTT\n\n", f"WEBVTT\n\n{note_block}\n\n", 1)
|
|
138
|
+
else:
|
|
139
|
+
content = f"WEBVTT\n\n{note_block}\n\n{content}"
|
|
140
|
+
|
|
141
|
+
return content
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_INLINE_NAME_THRESHOLD = 10
|
|
145
|
+
"""Maximum number of members/attendees to list by name inline.
|
|
146
|
+
|
|
147
|
+
Above this threshold, a count is shown instead (e.g. "127 members").
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _append_participant_lines(lines: list[str], participants: MeetingParticipants) -> None:
|
|
152
|
+
"""Append categorised participant lines to the NOTE block."""
|
|
153
|
+
# Organiser
|
|
154
|
+
if participants.organiser:
|
|
155
|
+
org = participants.organiser[0]
|
|
156
|
+
if org.email:
|
|
157
|
+
lines.append(f"Organiser: {org.name} <{org.email}>")
|
|
158
|
+
else:
|
|
159
|
+
lines.append(f"Organiser: {org.name}")
|
|
160
|
+
|
|
161
|
+
# Invited
|
|
162
|
+
if participants.invited:
|
|
163
|
+
count = len(participants.invited)
|
|
164
|
+
if count <= _INLINE_NAME_THRESHOLD:
|
|
165
|
+
names = ", ".join(m.name for m in participants.invited)
|
|
166
|
+
lines.append(f"Invited: {names}")
|
|
167
|
+
else:
|
|
168
|
+
rsvp_summary = _rsvp_summary(participants.invited)
|
|
169
|
+
if rsvp_summary:
|
|
170
|
+
lines.append(f"Invited: {count} members ({rsvp_summary})")
|
|
171
|
+
else:
|
|
172
|
+
lines.append(f"Invited: {count} members")
|
|
173
|
+
|
|
174
|
+
# Speakers
|
|
175
|
+
if participants.speakers:
|
|
176
|
+
names = ", ".join(m.name for m in participants.speakers)
|
|
177
|
+
lines.append(f"Speakers: {names}")
|
|
178
|
+
|
|
179
|
+
# Attendees
|
|
180
|
+
if participants.attendees:
|
|
181
|
+
count = len(participants.attendees)
|
|
182
|
+
if count <= _INLINE_NAME_THRESHOLD:
|
|
183
|
+
names = ", ".join(m.name for m in participants.attendees)
|
|
184
|
+
lines.append(f"Attendees: {names}")
|
|
185
|
+
else:
|
|
186
|
+
lines.append(f"Attendees: {count} participants")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _rsvp_summary(members: list[RosterMember]) -> str:
|
|
190
|
+
"""Build a compact RSVP summary string for the NOTE block.
|
|
191
|
+
|
|
192
|
+
Returns a string like ``"50 accepted, 5 tentative, 2 declined, 33 no response"``
|
|
193
|
+
or empty string if no RSVP data is available.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
members: List of roster members (may have empty ``rsvp_status``).
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Compact summary string, or empty string when no RSVP data present.
|
|
200
|
+
"""
|
|
201
|
+
from collections import Counter
|
|
202
|
+
|
|
203
|
+
counts: Counter[str] = Counter()
|
|
204
|
+
for m in members:
|
|
205
|
+
if m.rsvp_status:
|
|
206
|
+
counts[m.rsvp_status] += 1
|
|
207
|
+
|
|
208
|
+
if not counts:
|
|
209
|
+
return ""
|
|
210
|
+
|
|
211
|
+
# Display order: accepted, tentative, declined, organizer, none
|
|
212
|
+
parts: list[str] = []
|
|
213
|
+
display_order = ["accepted", "tentative", "declined", "organizer", "none"]
|
|
214
|
+
for status in display_order:
|
|
215
|
+
if status in counts:
|
|
216
|
+
label = "no response" if status == "none" else status
|
|
217
|
+
parts.append(f"{counts[status]} {label}")
|
|
218
|
+
|
|
219
|
+
# Any remaining statuses not in the display order
|
|
220
|
+
for status, count in sorted(counts.items()):
|
|
221
|
+
if status not in display_order:
|
|
222
|
+
parts.append(f"{count} {status}")
|
|
223
|
+
|
|
224
|
+
return ", ".join(parts)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _append_legacy_roster_lines(lines: list[str], config: dict) -> None:
|
|
228
|
+
"""Append legacy roster/speaker lines to the NOTE block.
|
|
229
|
+
|
|
230
|
+
Used when no :class:`MeetingParticipants` is available (backward
|
|
231
|
+
compatibility).
|
|
232
|
+
"""
|
|
233
|
+
roster = config.get("roster_members")
|
|
234
|
+
if roster:
|
|
235
|
+
organiser_names = sorted(m["name"] for m in roster if m["role"] == "organiser")
|
|
236
|
+
member_names = sorted(m["name"] for m in roster)
|
|
237
|
+
lines.append(f"Members: {', '.join(member_names)}")
|
|
238
|
+
if organiser_names:
|
|
239
|
+
lines.append(f"Organiser: {', '.join(organiser_names)}")
|
|
240
|
+
|
|
241
|
+
# Email addresses for all members that have one
|
|
242
|
+
email_entries = sorted((m["name"], m["email"]) for m in roster if m.get("email"))
|
|
243
|
+
if email_entries:
|
|
244
|
+
email_strs = [f"{name} <{email}>" for name, email in email_entries]
|
|
245
|
+
lines.append(f"Emails: {', '.join(email_strs)}")
|
|
246
|
+
else:
|
|
247
|
+
# Fallback: speakers only (from Recap)
|
|
248
|
+
speaker_map = config.get("speaker_map")
|
|
249
|
+
if speaker_map:
|
|
250
|
+
names = sorted(speaker_map.values())
|
|
251
|
+
lines.append(f"Speakers: {', '.join(names)}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def ensure_trailing_newline(content: str) -> str:
|
|
255
|
+
"""Ensure the VTT content ends with a trailing newline.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
content: VTT file content.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Content with a trailing newline appended if missing.
|
|
262
|
+
"""
|
|
263
|
+
if content and not content.endswith("\n"):
|
|
264
|
+
return content + "\n"
|
|
265
|
+
return content
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def generate_vtt_from_segments(segments: list[dict]) -> str:
|
|
269
|
+
"""Generate VTT content from Approach B DOM-extracted segments.
|
|
270
|
+
|
|
271
|
+
Each segment dict must have ``"speaker"``, ``"ts_sec"`` (int seconds),
|
|
272
|
+
and ``"text"`` keys. Timestamps must already be filled (no ``None``
|
|
273
|
+
values).
|
|
274
|
+
|
|
275
|
+
Cue end times are set to the next segment's start time. The last
|
|
276
|
+
cue gets a 10-second duration. If a cue's end time is not greater
|
|
277
|
+
than its start, 10 seconds is added.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
segments: Ordered list of segment dicts.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Complete VTT content string.
|
|
284
|
+
"""
|
|
285
|
+
from teams_transcripts.domain.timestamps import sec_to_vtt_int
|
|
286
|
+
|
|
287
|
+
vtt = ["WEBVTT", ""]
|
|
288
|
+
for i, seg in enumerate(segments):
|
|
289
|
+
ss = seg["ts_sec"]
|
|
290
|
+
es = segments[i + 1]["ts_sec"] if i + 1 < len(segments) else ss + 10
|
|
291
|
+
if es <= ss:
|
|
292
|
+
es = ss + 10
|
|
293
|
+
vtt.extend(
|
|
294
|
+
[
|
|
295
|
+
str(i + 1),
|
|
296
|
+
f"{sec_to_vtt_int(ss)} --> {sec_to_vtt_int(es)}",
|
|
297
|
+
f"<v {_escape_vtt_text(seg['speaker'])}>{_escape_vtt_text(seg['text'])}</v>",
|
|
298
|
+
"",
|
|
299
|
+
]
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
return "\n".join(vtt)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _escape_vtt_text(value: object) -> str:
|
|
306
|
+
"""Escape user-controlled text for a VTT cue or voice tag."""
|
|
307
|
+
return html_escape(str(value), quote=False)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# VTT parsing: content string -> structured cues
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
_VOICE_TAG_RE = re.compile(r"^<v ([^>]+)>(.*?)(?:</v>)?$", re.DOTALL)
|
|
315
|
+
"""Match a VTT voice tag: ``<v Speaker Name>text</v>`` or ``<v Speaker Name>text``."""
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def parse_cues(content: str) -> list[TranscriptCue]:
|
|
319
|
+
"""Parse VTT content into a list of structured cue objects.
|
|
320
|
+
|
|
321
|
+
Extracts each cue's ID, timestamps, speaker (from ``<v>`` voice tags),
|
|
322
|
+
and plain text content. NOTE blocks and the WEBVTT header are skipped.
|
|
323
|
+
|
|
324
|
+
Cues without a ``<v>`` voice tag have ``speaker=None``.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
content: Complete VTT file content (must start with ``WEBVTT``).
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Ordered list of :class:`TranscriptCue` objects.
|
|
331
|
+
"""
|
|
332
|
+
timestamp_re = re.compile(r"(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})")
|
|
333
|
+
|
|
334
|
+
lines = content.split("\n")
|
|
335
|
+
cues: list[TranscriptCue] = []
|
|
336
|
+
i = 0
|
|
337
|
+
|
|
338
|
+
while i < len(lines):
|
|
339
|
+
line = lines[i].strip()
|
|
340
|
+
|
|
341
|
+
# Skip NOTE blocks (single or multi-line)
|
|
342
|
+
if line.startswith("NOTE"):
|
|
343
|
+
i += 1
|
|
344
|
+
while i < len(lines) and lines[i].strip():
|
|
345
|
+
i += 1
|
|
346
|
+
i += 1 # skip the blank line after NOTE
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
# Skip WEBVTT header
|
|
350
|
+
if line == "WEBVTT":
|
|
351
|
+
i += 1
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
# Skip blank lines
|
|
355
|
+
if not line:
|
|
356
|
+
i += 1
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
# Try to match a cue ID (numeric line before timestamp)
|
|
360
|
+
cue_id: int | None = None
|
|
361
|
+
if line.isdigit():
|
|
362
|
+
cue_id = int(line)
|
|
363
|
+
i += 1
|
|
364
|
+
if i >= len(lines):
|
|
365
|
+
break
|
|
366
|
+
line = lines[i].strip()
|
|
367
|
+
|
|
368
|
+
# Try to match a timestamp line
|
|
369
|
+
ts_match = timestamp_re.match(line)
|
|
370
|
+
if not ts_match:
|
|
371
|
+
i += 1
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
start_str = ts_match.group(1)
|
|
375
|
+
end_str = ts_match.group(2)
|
|
376
|
+
start_sec = parse_vtt_timestamp(start_str)
|
|
377
|
+
end_sec = parse_vtt_timestamp(end_str)
|
|
378
|
+
|
|
379
|
+
# Collect content lines until blank line or end
|
|
380
|
+
i += 1
|
|
381
|
+
content_lines: list[str] = []
|
|
382
|
+
while i < len(lines) and lines[i].strip():
|
|
383
|
+
content_lines.append(lines[i].rstrip())
|
|
384
|
+
i += 1
|
|
385
|
+
|
|
386
|
+
# Parse speaker and text from content
|
|
387
|
+
raw_text = "\n".join(content_lines)
|
|
388
|
+
speaker, text = _extract_speaker_and_text(raw_text)
|
|
389
|
+
|
|
390
|
+
cues.append(
|
|
391
|
+
TranscriptCue(
|
|
392
|
+
id=cue_id if cue_id is not None else len(cues) + 1,
|
|
393
|
+
start=start_str,
|
|
394
|
+
end=end_str,
|
|
395
|
+
start_sec=start_sec,
|
|
396
|
+
end_sec=end_sec,
|
|
397
|
+
speaker=speaker,
|
|
398
|
+
text=text,
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
i += 1 # skip blank line after cue
|
|
403
|
+
|
|
404
|
+
return cues
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _extract_speaker_and_text(raw: str) -> tuple[str | None, str]:
|
|
408
|
+
"""Extract speaker name and plain text from cue content.
|
|
409
|
+
|
|
410
|
+
Handles:
|
|
411
|
+
- ``<v Speaker Name>text</v>`` — returns (Speaker Name, text)
|
|
412
|
+
- ``<v Speaker Name>text`` (no closing tag) — returns (Speaker Name, text)
|
|
413
|
+
- Plain text without voice tag — returns (None, text)
|
|
414
|
+
- Multi-line content where the first line has the voice tag
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
raw: Raw cue content (may be multi-line).
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Tuple of (speaker_or_None, plain_text).
|
|
421
|
+
"""
|
|
422
|
+
# Try matching the first line for a voice tag
|
|
423
|
+
first_line_end = raw.find("\n")
|
|
424
|
+
if first_line_end == -1:
|
|
425
|
+
first_line = raw
|
|
426
|
+
rest = ""
|
|
427
|
+
else:
|
|
428
|
+
first_line = raw[:first_line_end]
|
|
429
|
+
rest = raw[first_line_end + 1 :]
|
|
430
|
+
|
|
431
|
+
m = _VOICE_TAG_RE.match(first_line)
|
|
432
|
+
if m:
|
|
433
|
+
speaker = html_unescape(m.group(1))
|
|
434
|
+
text = html_unescape(m.group(2))
|
|
435
|
+
if rest:
|
|
436
|
+
text = text + "\n" + html_unescape(rest)
|
|
437
|
+
# Strip any remaining </v> tags from text
|
|
438
|
+
text = text.replace("</v>", "")
|
|
439
|
+
return speaker, text.strip()
|
|
440
|
+
|
|
441
|
+
# No voice tag — entire content is plain text
|
|
442
|
+
# Still strip any stray </v> tags
|
|
443
|
+
text = html_unescape(raw.replace("</v>", "").strip())
|
|
444
|
+
return None, text
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Infrastructure -- cross-cutting concerns: logging, signals, errors, config."""
|