Last active
March 4, 2026 22:49
-
-
Save ifthenelse/1c652d64f714edc896d51041b834f5c9 to your computer and use it in GitHub Desktop.
Microsoft Teams Transcript Extractor v1.6 - Extract transcripts from MS Teams chats ad download them as timestamped TXT files. Just open MS Teams for web, browse to the meeting transcript screen and run this script.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (async () => { | |
| "use strict"; | |
| const CONFIG = { | |
| selectors: { | |
| statusTitle: '[data-testid="panel-status-page-title-text"]', | |
| meetingTitle: '[data-tid="chat-title"] span[title]', | |
| meetingTime: '[data-tid="intelligent-recap-header"] span', | |
| meetingRecurrence: '[data-tid="recap-tab-list"] button[role="tab"][aria-selected="true"] span[dir="auto"]', | |
| chatTab: 'button[data-tid="tab-item-com.microsoft.chattabs.chat"]', | |
| recapTab: 'button[data-tid="tab-item-com.microsoft.chattabs.recap"]', | |
| chatViewport: '[data-tid="message-pane-list-viewport"]', | |
| chatList: ['#chat-pane-list', '[data-tid="message-pane-list-runway"]'], | |
| chatMessageBodies: '[data-tid="chat-pane-message"]', | |
| chatControlMessageBodies: '[data-tid="control-message-renderer"]', | |
| chatMessageWrapper: '[data-testid="message-wrapper"]', | |
| chatMessageContent: '[data-message-content]', | |
| chatMeetingTranscriptButton: ['button[aria-label="Transcript"]', 'button[aria-description="Transcript"]'], | |
| participantsButton: [ | |
| '[data-tid="chat-header-participant-count"]', | |
| '[data-tid="chat-header-participant-list-button"]', | |
| 'button[data-tid="chat-header-participant-list"]' | |
| ], | |
| participantsDialog: '[data-portal-node="true"] [role="dialog"]', | |
| participantsList: 'ul[data-tid="chat-header-participant-list"]', | |
| participantsVirtualGrid: '[class*="ReactVirtualized__Grid"]', | |
| participantRows: ['[data-testid^="chat-roster-item-"]', 'li[role="menuitem"]'], | |
| participantNameNodes: ['[id^="chat-roster-item-name-"]', '[data-tid^="chat-roster-item-name"]', 'span[title]'], | |
| scrollContainer: [ | |
| "#scrollToTargetTargetedFocusZone", | |
| '[data-is-scrollable="true"].ms-FocusZone' | |
| ], | |
| listSurface: ".ms-List-surface", | |
| listCell: ".ms-List-cell", | |
| listItem: '[id^="listItem-"]', | |
| messageIdAttributes: ["data-message-id", "data-client-message-id", "data-item-id", "data-id"], | |
| messagePositionAttributes: ["aria-posinset", "data-list-index", "data-item-index", "data-index", "aria-rowindex"], | |
| messageIdNodes: [ | |
| '[id^="sub-entry-"]', | |
| '[id^="listItem-"]', | |
| '[id^="Header-timestamp-"]', | |
| '[id^="Left-timestamp-"]' | |
| ], | |
| timestampNodes: [ | |
| 'time[datetime]', | |
| "time", | |
| '[id^="Header-timestamp-"]', | |
| '[id^="Left-timestamp-"]', | |
| '[data-tid="chat-pane-message-timestamp"]', | |
| '[data-tid*="timestamp"]', | |
| '[id*="timestamp"]', | |
| '[class*="timestamp"]' | |
| ], | |
| authorNodes: ['[class*="itemDisplayName-"]', '[data-tid="message-author-name"]'], | |
| textNodes: ['[id^="sub-entry-"]', '[data-tid="chat-pane-message"]'] | |
| }, | |
| transcriptSavingKeywords: ["saving", "salvat", "enregistr", "speicher", "guard"], | |
| scroll: { | |
| initialDelayMs: 400, | |
| settleDelayMs: 100, | |
| chatInitialDelayMs: 500, | |
| chatSettleDelayMs: 140, | |
| chatViewportStepRatio: 0.35, | |
| mutationTimeoutMs: 2000, | |
| viewportStepRatio: 0.9, | |
| stableLimit: 5, | |
| topStableLimit: 3, | |
| maxTopIterations: 40, | |
| maxIterations: 500 | |
| }, | |
| metadata: { | |
| rosterDelayMs: 700, | |
| participantMutationTimeoutMs: 1000, | |
| maxParticipantScrollIterations: 40 | |
| }, | |
| output: { | |
| filePrefix: "teams_transcript", | |
| // Supported values: "txt", "md", "json" | |
| formats: ["txt"] | |
| }, | |
| labels: { | |
| unknownMeetingTitle: "Unknown meeting title", | |
| unknownMeetingTime: "Unknown meeting time", | |
| unknownSpeaker: "Unknown", | |
| unknownTimestamp: "Unknown time" | |
| }, | |
| logPrefix: "[Teams Transcript]" | |
| }; | |
| /** | |
| * @typedef {Object} TranscriptMessage | |
| * @property {string} messageId | |
| * @property {string} timestampRaw | |
| * @property {string} timestampIso | |
| * @property {string} timestampFull | |
| * @property {number|null} epochMs | |
| * @property {number|null} elapsedSeconds | |
| * @property {string} kind | |
| * @property {string} author | |
| * @property {string} text | |
| * @property {number} order | |
| */ | |
| const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| const normalizeText = (value) => (value || "").replace(/\s+/g, " ").trim(); | |
| const log = (...args) => console.log(CONFIG.logPrefix, ...args); | |
| const warn = (...args) => console.warn(CONFIG.logPrefix, ...args); | |
| const error = (...args) => console.error(CONFIG.logPrefix, ...args); | |
| function queryFirst(selectors, root = document) { | |
| const list = Array.isArray(selectors) ? selectors : [selectors]; | |
| for (const selector of list) { | |
| const node = root.querySelector(selector); | |
| if (node) return node; | |
| } | |
| return null; | |
| } | |
| function queryAll(selectors, root = document) { | |
| const list = Array.isArray(selectors) ? selectors : [selectors]; | |
| for (const selector of list) { | |
| const nodes = root.querySelectorAll(selector); | |
| if (nodes.length > 0) return Array.from(nodes); | |
| } | |
| return []; | |
| } | |
| function isElementVisible(node) { | |
| if (!node || typeof node.getClientRects !== "function") return false; | |
| if (node.getClientRects().length === 0) return false; | |
| const style = window.getComputedStyle(node); | |
| if (!style) return false; | |
| if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false; | |
| return true; | |
| } | |
| function toEpoch(value) { | |
| if (!value) return null; | |
| const parsed = Date.parse(value); | |
| return Number.isNaN(parsed) ? null : parsed; | |
| } | |
| function toNumericEpoch(value) { | |
| const parsed = Number(normalizeText(value)); | |
| return Number.isFinite(parsed) && parsed > 0 ? parsed : null; | |
| } | |
| function formatEpochAsIso(epochMs) { | |
| return new Date(epochMs).toISOString(); | |
| } | |
| function formatLocalDateTime(epochMs) { | |
| const d = new Date(epochMs); | |
| const year = d.getFullYear(); | |
| const month = String(d.getMonth() + 1).padStart(2, "0"); | |
| const day = String(d.getDate()).padStart(2, "0"); | |
| const hours = String(d.getHours()).padStart(2, "0"); | |
| const minutes = String(d.getMinutes()).padStart(2, "0"); | |
| const seconds = String(d.getSeconds()).padStart(2, "0"); | |
| return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; | |
| } | |
| function getBrowserTimezoneLabel(referenceEpochMs = null) { | |
| const referenceDate = typeof referenceEpochMs === "number" ? new Date(referenceEpochMs) : new Date(); | |
| const offsetMinutes = -referenceDate.getTimezoneOffset(); | |
| const sign = offsetMinutes >= 0 ? "+" : "-"; | |
| const absolute = Math.abs(offsetMinutes); | |
| const hh = String(Math.floor(absolute / 60)).padStart(2, "0"); | |
| const mm = String(absolute % 60).padStart(2, "0"); | |
| return `UTC${sign}${hh}:${mm}`; | |
| } | |
| function formatEpochWithTimezone(epochMs, timezoneLabel) { | |
| return `${formatLocalDateTime(epochMs)} ${timezoneLabel}`; | |
| } | |
| function formatMeetingTimeFromEpochs(startEpochMs, endEpochMs, fallbackText = "") { | |
| const normalizedFallback = normalizeText(fallbackText); | |
| const referenceEpochMs = | |
| typeof startEpochMs === "number" ? startEpochMs : typeof endEpochMs === "number" ? endEpochMs : null; | |
| if (referenceEpochMs === null) { | |
| if (normalizedFallback) return normalizedFallback; | |
| return `${CONFIG.labels.unknownMeetingTime} (${getBrowserTimezoneLabel()})`; | |
| } | |
| const timezoneLabel = getBrowserTimezoneLabel(referenceEpochMs); | |
| if (typeof startEpochMs === "number" && typeof endEpochMs === "number") { | |
| return `${formatEpochWithTimezone(startEpochMs, timezoneLabel)} - ${formatEpochWithTimezone(endEpochMs, timezoneLabel)}`; | |
| } | |
| if (typeof startEpochMs === "number") return formatEpochWithTimezone(startEpochMs, timezoneLabel); | |
| return formatEpochWithTimezone(endEpochMs, timezoneLabel); | |
| } | |
| function resolveComparableEpoch(message, meetingStartEpochMs = null) { | |
| if (typeof message?.epochMs === "number") return message.epochMs; | |
| if (typeof message?.elapsedSeconds === "number" && typeof meetingStartEpochMs === "number") { | |
| return meetingStartEpochMs + message.elapsedSeconds * 1000; | |
| } | |
| return null; | |
| } | |
| function sortMessagesChronologically(messages, meetingStartEpochMs = null) { | |
| return messages | |
| .map((message, index) => ({ ...message, _timelineIndex: index })) | |
| .sort((a, b) => { | |
| const aEpoch = resolveComparableEpoch(a, meetingStartEpochMs); | |
| const bEpoch = resolveComparableEpoch(b, meetingStartEpochMs); | |
| if (aEpoch !== null && bEpoch !== null && aEpoch !== bEpoch) return aEpoch - bEpoch; | |
| if (aEpoch !== null && bEpoch === null) return -1; | |
| if (aEpoch === null && bEpoch !== null) return 1; | |
| return a._timelineIndex - b._timelineIndex; | |
| }) | |
| .map(({ _timelineIndex, ...message }) => message); | |
| } | |
| function normalizeMessageId(value) { | |
| return normalizeText(value).replace(/\s+/g, ""); | |
| } | |
| function isLikelyVolatileListId(value) { | |
| return /^listItem-\d+$/i.test(value); | |
| } | |
| function isNumericLike(value) { | |
| return /^\d+$/.test(value); | |
| } | |
| function isGuidLike(value) { | |
| return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); | |
| } | |
| function isLikelyStableIdToken(value) { | |
| if (!value) return false; | |
| if (isGuidLike(value)) return true; | |
| if (isNumericLike(value)) return false; | |
| if (value.length >= 12 && /[a-z]/i.test(value) && /[0-9]/.test(value)) return true; | |
| return false; | |
| } | |
| function normalizeCandidateNodeId(value) { | |
| const id = normalizeMessageId(value); | |
| if (!id) return ""; | |
| const lower = id.toLowerCase(); | |
| const volatilePrefixes = ["listitem-", "sub-entry-", "header-timestamp-", "left-timestamp-"]; | |
| for (const prefix of volatilePrefixes) { | |
| if (!lower.startsWith(prefix)) continue; | |
| const suffix = id.slice(prefix.length); | |
| if (!isLikelyStableIdToken(suffix)) return ""; | |
| return id; | |
| } | |
| return isLikelyStableIdToken(id) ? id : ""; | |
| } | |
| function isRelativeTimestampLabel(rawText) { | |
| return parseRelativeTimestamp(rawText) !== null; | |
| } | |
| function isSystemEventText(value) { | |
| const text = normalizeText(value).toLowerCase(); | |
| if (!text) return false; | |
| return [ | |
| "started transcription", | |
| "stopped transcription", | |
| "started recording", | |
| "stopped recording", | |
| "joined the meeting", | |
| "left the meeting" | |
| ].some((token) => text.includes(token)); | |
| } | |
| function extractTimeFromText(value) { | |
| const text = normalizeText(value); | |
| if (!text) return ""; | |
| const isoMatch = text.match(/\b\d{4}-\d{2}-\d{2}t\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:z|[+-]\d{2}:\d{2})?\b/i); | |
| if (isoMatch) return isoMatch[0]; | |
| const timeMatch = | |
| text.match(/\b\d{1,2}:\d{2}\s?(?:am|pm)\b/i) || | |
| text.match(/\b\d{1,2}:\d{2}(?::\d{2})\b/); | |
| return timeMatch ? timeMatch[0] : ""; | |
| } | |
| function extractEmailFromText(value) { | |
| const text = normalizeText(value); | |
| if (!text) return ""; | |
| const emailMatch = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); | |
| return emailMatch ? emailMatch[0] : ""; | |
| } | |
| function parseRecurrenceIndex(value) { | |
| const text = normalizeText(value); | |
| if (!text) return null; | |
| const match = text.match(/\bpart\s*(\d+)\b/i); | |
| if (!match) return null; | |
| const index = Number(match[1]); | |
| return Number.isInteger(index) && index > 0 ? index : null; | |
| } | |
| function extractDurationToken(value) { | |
| const text = normalizeText(value); | |
| if (!text) return ""; | |
| const clockMatch = text.match(/\b\d{1,2}:\d{2}(?::\d{2})?\b/); | |
| if (clockMatch) return clockMatch[0]; | |
| const verboseMatch = text.match( | |
| /\b\d+\s+hours?(?:\s+\d+\s+minutes?)?(?:\s+\d+\s+seconds?)?|\b\d+\s+minutes?(?:\s+\d+\s+seconds?)?|\b\d+\s+seconds?\b/i | |
| ); | |
| return verboseMatch ? normalizeText(verboseMatch[0]) : ""; | |
| } | |
| function parseElapsedTimestamp(rawText) { | |
| const text = normalizeText(rawText).toLowerCase(); | |
| if (!text) return null; | |
| const clockText = extractDurationToken(text); | |
| const clockMatch = clockText.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); | |
| if (clockMatch) { | |
| const first = Number(clockMatch[1]); | |
| const second = Number(clockMatch[2]); | |
| const third = clockMatch[3] ? Number(clockMatch[3]) : null; | |
| if (third !== null) return first * 3600 + second * 60 + third; | |
| return first * 60 + second; | |
| } | |
| const hours = Number((text.match(/(\d+)\s*hours?/) || [])[1] || 0); | |
| const minutes = Number((text.match(/(\d+)\s*minutes?/) || [])[1] || 0); | |
| const seconds = Number((text.match(/(\d+)\s*seconds?/) || [])[1] || 0); | |
| const total = hours * 3600 + minutes * 60 + seconds; | |
| return total > 0 ? total : null; | |
| } | |
| function parseClockTime(value) { | |
| const text = normalizeText(value); | |
| if (!text) return null; | |
| const match = text.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?$/i); | |
| if (!match) return null; | |
| let hours = Number(match[1]); | |
| const minutes = Number(match[2]); | |
| const seconds = Number(match[3] || 0); | |
| const meridiem = (match[4] || "").toLowerCase(); | |
| if (meridiem === "am" && hours === 12) hours = 0; | |
| if (meridiem === "pm" && hours < 12) hours += 12; | |
| return { hours, minutes, seconds }; | |
| } | |
| function parseMeetingTimeRange(meetingTimeText) { | |
| const text = normalizeText(meetingTimeText); | |
| if (!text) { | |
| return { | |
| startEpochMs: null, | |
| endEpochMs: null | |
| }; | |
| } | |
| const withoutTimezoneSuffix = text.replace(/\s*\([^)]*\)\s*$/, ""); | |
| const [rawStartPart, rawEndPart = ""] = withoutTimezoneSuffix.split(/\s+-\s+/); | |
| const startPart = normalizeText(rawStartPart); | |
| const endPart = normalizeText(rawEndPart); | |
| const startEpochMs = toEpoch(startPart); | |
| if (!endPart) { | |
| return { | |
| startEpochMs, | |
| endEpochMs: null | |
| }; | |
| } | |
| let endEpochMs = null; | |
| const endClock = parseClockTime(endPart); | |
| if (startEpochMs !== null && endClock) { | |
| const endDate = new Date(startEpochMs); | |
| endDate.setHours(endClock.hours, endClock.minutes, endClock.seconds, 0); | |
| if (endDate.getTime() < startEpochMs) endDate.setDate(endDate.getDate() + 1); | |
| endEpochMs = endDate.getTime(); | |
| } else { | |
| const hasCalendarToken = /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d{4})/i.test( | |
| endPart | |
| ); | |
| if (hasCalendarToken) endEpochMs = toEpoch(endPart); | |
| } | |
| return { | |
| startEpochMs, | |
| endEpochMs | |
| }; | |
| } | |
| function parseMeetingStartEpoch(meetingTimeText) { | |
| return parseMeetingTimeRange(meetingTimeText).startEpochMs; | |
| } | |
| function parseRelativeTimestamp(rawText) { | |
| const text = normalizeText(rawText).toLowerCase(); | |
| if (!text || !text.includes("ago")) return null; | |
| const compactMatch = text.match(/^(\d+)\s*([smhd])\s*ago$/); | |
| if (compactMatch) { | |
| const amount = Number(compactMatch[1]); | |
| const unit = compactMatch[2]; | |
| if (Number.isFinite(amount)) { | |
| const multipliers = { s: 1000, m: 60000, h: 3600000, d: 86400000 }; | |
| return Date.now() - amount * multipliers[unit]; | |
| } | |
| } | |
| const longMatch = text.match(/^(\d+)\s*(second|seconds|minute|minutes|hour|hours|day|days)\s*ago$/); | |
| if (longMatch) { | |
| const amount = Number(longMatch[1]); | |
| const unit = longMatch[2]; | |
| if (Number.isFinite(amount)) { | |
| const multipliers = { | |
| second: 1000, | |
| seconds: 1000, | |
| minute: 60000, | |
| minutes: 60000, | |
| hour: 3600000, | |
| hours: 3600000, | |
| day: 86400000, | |
| days: 86400000 | |
| }; | |
| return Date.now() - amount * multipliers[unit]; | |
| } | |
| } | |
| return null; | |
| } | |
| function resolveFullTimestamp(timestampIso, timestampRaw) { | |
| const isoEpoch = toEpoch(timestampIso); | |
| if (isoEpoch !== null) { | |
| return { | |
| timestampFull: formatEpochAsIso(isoEpoch), | |
| epochMs: isoEpoch, | |
| elapsedSeconds: null | |
| }; | |
| } | |
| const rawEpoch = toEpoch(timestampRaw); | |
| if (rawEpoch !== null) { | |
| return { | |
| timestampFull: formatEpochAsIso(rawEpoch), | |
| epochMs: rawEpoch, | |
| elapsedSeconds: null | |
| }; | |
| } | |
| const relativeEpoch = parseRelativeTimestamp(timestampRaw); | |
| if (relativeEpoch !== null) { | |
| return { | |
| timestampFull: formatEpochAsIso(relativeEpoch), | |
| epochMs: relativeEpoch, | |
| elapsedSeconds: null | |
| }; | |
| } | |
| const elapsedSeconds = parseElapsedTimestamp(timestampRaw); | |
| if (elapsedSeconds !== null) { | |
| return { | |
| timestampFull: normalizeText(timestampRaw), | |
| epochMs: null, | |
| elapsedSeconds | |
| }; | |
| } | |
| return { | |
| timestampFull: CONFIG.labels.unknownTimestamp, | |
| epochMs: null, | |
| elapsedSeconds: null | |
| }; | |
| } | |
| function finalizeMessageTimestamp(message, meeting) { | |
| const meetingStartEpochMs = typeof meeting.startEpochMs === "number" ? meeting.startEpochMs : null; | |
| let epochMs = typeof message.epochMs === "number" ? message.epochMs : null; | |
| if (epochMs === null && meetingStartEpochMs !== null && typeof message.elapsedSeconds === "number") { | |
| epochMs = meetingStartEpochMs + message.elapsedSeconds * 1000; | |
| } | |
| const timezoneLabel = getBrowserTimezoneLabel(epochMs !== null ? epochMs : meetingStartEpochMs); | |
| if (epochMs !== null) { | |
| return { | |
| ...message, | |
| epochMs, | |
| timestampFull: formatEpochWithTimezone(epochMs, timezoneLabel) | |
| }; | |
| } | |
| if (message.timestampRaw && message.timestampRaw !== CONFIG.labels.unknownTimestamp) { | |
| return { | |
| ...message, | |
| timestampFull: `${message.timestampRaw} (${timezoneLabel})` | |
| }; | |
| } | |
| return { | |
| ...message, | |
| timestampFull: `${CONFIG.labels.unknownTimestamp} (${timezoneLabel})` | |
| }; | |
| } | |
| function finalizeMessagesForMeeting(messages, meeting) { | |
| return messages.map((message) => finalizeMessageTimestamp(message, meeting)); | |
| } | |
| function sanitizeMeetingNameForFilename(title) { | |
| const normalized = normalizeText(title) | |
| .normalize("NFKD") | |
| .replace(/[\u0300-\u036f]/g, "") | |
| .replace(/[^A-Za-z0-9]+/g, "_") | |
| .replace(/^_+|_+$/g, "") | |
| .replace(/_+/g, "_"); | |
| return normalized || "unknown_meeting"; | |
| } | |
| function buildDownloadFilename(meetingTitle, extension, meetingStartEpochMs = null) { | |
| const safeMeetingName = sanitizeMeetingNameForFilename(meetingTitle); | |
| const baseDate = typeof meetingStartEpochMs === "number" ? new Date(meetingStartEpochMs) : new Date(); | |
| const timestamp = baseDate.toISOString().replace(/[:.]/g, "-"); | |
| return `${CONFIG.output.filePrefix}-${safeMeetingName}-${timestamp}.${extension}`; | |
| } | |
| function appendMeetingRecurrence(title, recurrenceLabel) { | |
| const normalizedTitle = normalizeText(title) || CONFIG.labels.unknownMeetingTitle; | |
| const normalizedRecurrence = normalizeText(recurrenceLabel); | |
| if (!normalizedRecurrence) return normalizedTitle; | |
| if (normalizedTitle.toLowerCase().includes(normalizedRecurrence.toLowerCase())) return normalizedTitle; | |
| return `${normalizedTitle} (${normalizedRecurrence})`; | |
| } | |
| function getMessageKindMarker(message) { | |
| if (message?.kind === "system") return "System"; | |
| if (message?.kind === "chat") { | |
| const author = normalizeText(message?.author).toLowerCase(); | |
| if (author === "system") return "System"; | |
| return "Chat"; | |
| } | |
| return "Transcript"; | |
| } | |
| function addRecurrenceEvent(eventStore, event) { | |
| if (!event) return false; | |
| const key = `${event.type}:${event.messageId || event.epochMs}`; | |
| if (eventStore.has(key)) return false; | |
| eventStore.set(key, event); | |
| return true; | |
| } | |
| function buildRecurrenceSessions(recurrenceEvents) { | |
| const events = recurrenceEvents | |
| .filter((event) => typeof event?.epochMs === "number") | |
| .slice() | |
| .sort((a, b) => a.epochMs - b.epochMs); | |
| const sessions = []; | |
| let pendingStart = null; | |
| events.forEach((event) => { | |
| if (event.type === "start") { | |
| if (pendingStart) { | |
| sessions.push({ | |
| startEpochMs: pendingStart.epochMs, | |
| endEpochMs: null, | |
| startMessageId: pendingStart.messageId || "", | |
| endMessageId: "", | |
| hasTranscript: false | |
| }); | |
| } | |
| pendingStart = event; | |
| return; | |
| } | |
| if (event.type !== "end") return; | |
| if (!pendingStart) { | |
| sessions.push({ | |
| startEpochMs: null, | |
| endEpochMs: event.epochMs, | |
| startMessageId: "", | |
| endMessageId: event.messageId || "", | |
| hasTranscript: Boolean(event.hasTranscript) | |
| }); | |
| return; | |
| } | |
| sessions.push({ | |
| startEpochMs: pendingStart.epochMs, | |
| endEpochMs: event.epochMs, | |
| startMessageId: pendingStart.messageId || "", | |
| endMessageId: event.messageId || "", | |
| hasTranscript: Boolean(event.hasTranscript) | |
| }); | |
| pendingStart = null; | |
| }); | |
| if (pendingStart) { | |
| sessions.push({ | |
| startEpochMs: pendingStart.epochMs, | |
| endEpochMs: null, | |
| startMessageId: pendingStart.messageId || "", | |
| endMessageId: "", | |
| hasTranscript: false | |
| }); | |
| } | |
| return sessions; | |
| } | |
| function pickRecurrenceSession(recurrenceSessions, recurrenceLabel) { | |
| const startAnchoredSessions = recurrenceSessions.filter((session) => typeof session?.startEpochMs === "number"); | |
| const selectableSessions = startAnchoredSessions.length > 0 ? startAnchoredSessions : recurrenceSessions; | |
| if (selectableSessions.length === 0) return null; | |
| const recurrenceIndex = parseRecurrenceIndex(recurrenceLabel); | |
| if (recurrenceIndex !== null) { | |
| return selectableSessions[recurrenceIndex - 1] || selectableSessions[selectableSessions.length - 1]; | |
| } | |
| if (selectableSessions.length === 1) return selectableSessions[0]; | |
| return selectableSessions[selectableSessions.length - 1]; | |
| } | |
| function applyRecurrenceSessionToMeeting(meeting, recurrenceSession) { | |
| if (!recurrenceSession) return meeting; | |
| const startEpochMs = | |
| typeof recurrenceSession.startEpochMs === "number" ? recurrenceSession.startEpochMs : meeting.startEpochMs; | |
| const endEpochMs = | |
| typeof recurrenceSession.endEpochMs === "number" ? recurrenceSession.endEpochMs : meeting.endEpochMs; | |
| const timezoneReferenceEpochMs = | |
| typeof startEpochMs === "number" ? startEpochMs : typeof endEpochMs === "number" ? endEpochMs : null; | |
| const timezoneLabel = getBrowserTimezoneLabel(timezoneReferenceEpochMs); | |
| return { | |
| ...meeting, | |
| startEpochMs, | |
| endEpochMs, | |
| timezoneLabel, | |
| timeRaw: formatMeetingTimeFromEpochs(startEpochMs, endEpochMs, meeting.timeRaw), | |
| time: formatMeetingTimeFromEpochs(startEpochMs, endEpochMs, meeting.time) | |
| }; | |
| } | |
| function filterMessagesForMeetingTimeline(messages, meeting) { | |
| return messages.filter((message) => isWithinMeetingTimeline(message.epochMs, meeting)); | |
| } | |
| function buildMeetingSelections(baseMeeting, recurrenceSessions) { | |
| const normalizedBaseTitle = normalizeText(baseMeeting?.title) || CONFIG.labels.unknownMeetingTitle; | |
| return recurrenceSessions.map((session, index) => { | |
| const recurrenceLabel = `Part ${index + 1}`; | |
| const title = appendMeetingRecurrence(normalizedBaseTitle, recurrenceLabel); | |
| const time = formatMeetingTimeFromEpochs(session.startEpochMs, session.endEpochMs); | |
| const labelTime = formatMeetingTimeFromEpochs(session.startEpochMs, session.endEpochMs, CONFIG.labels.unknownMeetingTime); | |
| const referenceEpochMs = | |
| typeof session.startEpochMs === "number" ? session.startEpochMs : typeof session.endEpochMs === "number" ? session.endEpochMs : null; | |
| const timezoneLabel = getBrowserTimezoneLabel(referenceEpochMs); | |
| const canExportChat = typeof session.startEpochMs === "number" && typeof session.endEpochMs === "number"; | |
| const canExportTranscript = Boolean(typeof session.startEpochMs === "number" && session.hasTranscript && session.endMessageId); | |
| return { | |
| id: session.endMessageId || session.startMessageId || `meeting-${index + 1}`, | |
| label: `${title} | ${labelTime}`, | |
| defaults: { | |
| chat: canExportChat, | |
| transcript: canExportTranscript | |
| }, | |
| disabled: { | |
| chat: !canExportChat, | |
| transcript: !canExportTranscript | |
| }, | |
| meeting: { | |
| title, | |
| recurrence: recurrenceLabel, | |
| time, | |
| timeRaw: time, | |
| timezoneLabel, | |
| startEpochMs: session.startEpochMs, | |
| endEpochMs: session.endEpochMs, | |
| participants: Array.isArray(baseMeeting?.participants) ? baseMeeting.participants : [] | |
| }, | |
| transcriptBlockMessageId: session.endMessageId || "" | |
| }; | |
| }); | |
| } | |
| function deriveMeetingTimelineBounds(meeting, transcriptMessages) { | |
| const timedTranscriptMessages = transcriptMessages.filter((message) => typeof message.epochMs === "number"); | |
| const firstTranscriptEpochMs = timedTranscriptMessages[0]?.epochMs ?? null; | |
| const lastTranscriptEpochMs = timedTranscriptMessages[timedTranscriptMessages.length - 1]?.epochMs ?? null; | |
| const startCandidates = [meeting.startEpochMs, firstTranscriptEpochMs].filter((value) => typeof value === "number"); | |
| const endCandidates = [meeting.endEpochMs, lastTranscriptEpochMs].filter((value) => typeof value === "number"); | |
| return { | |
| ...meeting, | |
| timelineStartEpochMs: startCandidates.length > 0 ? Math.min(...startCandidates) : null, | |
| timelineEndEpochMs: endCandidates.length > 0 ? Math.max(...endCandidates) : null | |
| }; | |
| } | |
| function isWithinMeetingTimeline(epochMs, meeting) { | |
| if (typeof epochMs !== "number") return false; | |
| const startEpochMs = | |
| typeof meeting?.timelineStartEpochMs === "number" | |
| ? meeting.timelineStartEpochMs | |
| : typeof meeting?.startEpochMs === "number" | |
| ? meeting.startEpochMs | |
| : null; | |
| const endEpochMs = | |
| typeof meeting?.timelineEndEpochMs === "number" | |
| ? meeting.timelineEndEpochMs | |
| : typeof meeting?.endEpochMs === "number" | |
| ? meeting.endEpochMs | |
| : null; | |
| if (startEpochMs === null && endEpochMs === null) return false; | |
| if (startEpochMs !== null && epochMs < startEpochMs) return false; | |
| if (endEpochMs !== null && epochMs > endEpochMs) return false; | |
| return true; | |
| } | |
| function isAtBottom(container) { | |
| return container.scrollTop + container.clientHeight >= container.scrollHeight - 2; | |
| } | |
| function waitForDomChange(target, timeoutMs) { | |
| return new Promise((resolve) => { | |
| let done = false; | |
| const observer = new MutationObserver(() => { | |
| if (done) return; | |
| done = true; | |
| observer.disconnect(); | |
| resolve(true); | |
| }); | |
| observer.observe(target, { childList: true, subtree: true }); | |
| setTimeout(() => { | |
| if (done) return; | |
| done = true; | |
| observer.disconnect(); | |
| resolve(false); | |
| }, timeoutMs); | |
| }); | |
| } | |
| async function waitForElement(selectors, root = document, timeoutMs = 2000) { | |
| const deadline = Date.now() + timeoutMs; | |
| while (Date.now() < deadline) { | |
| const node = queryFirst(selectors, root); | |
| if (node) return node; | |
| await sleep(50); | |
| } | |
| return null; | |
| } | |
| async function waitForResult(getter, timeoutMs = 2000, pollMs = 50) { | |
| const deadline = Date.now() + timeoutMs; | |
| while (Date.now() < deadline) { | |
| const value = getter(); | |
| if (value) return value; | |
| await sleep(pollMs); | |
| } | |
| return null; | |
| } | |
| function removeExistingSelectionDialog() { | |
| const existing = document.getElementById("teams-transcript-selection-overlay"); | |
| if (existing) existing.remove(); | |
| } | |
| function createSelectionDialog() { | |
| removeExistingSelectionDialog(); | |
| const state = { | |
| stopRequested: false, | |
| startResolver: null, | |
| itemBindings: [], | |
| started: false, | |
| closed: false, | |
| selectionInputsLocked: false | |
| }; | |
| const overlay = document.createElement("div"); | |
| overlay.id = "teams-transcript-selection-overlay"; | |
| Object.assign(overlay.style, { | |
| position: "fixed", | |
| inset: "0", | |
| background: "rgba(0, 0, 0, 0.48)", | |
| zIndex: "2147483647", | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| padding: "24px", | |
| boxSizing: "border-box", | |
| fontFamily: "Segoe UI, sans-serif" | |
| }); | |
| const panel = document.createElement("div"); | |
| Object.assign(panel.style, { | |
| width: "min(860px, 100%)", | |
| maxHeight: "min(82vh, 900px)", | |
| background: "#ffffff", | |
| color: "#111827", | |
| borderRadius: "14px", | |
| boxShadow: "0 24px 60px rgba(0, 0, 0, 0.28)", | |
| display: "flex", | |
| flexDirection: "column", | |
| overflow: "hidden" | |
| }); | |
| const header = document.createElement("div"); | |
| Object.assign(header.style, { | |
| padding: "18px 20px 12px", | |
| borderBottom: "1px solid #e5e7eb" | |
| }); | |
| const title = document.createElement("div"); | |
| title.textContent = "Teams Transcript Export"; | |
| Object.assign(title.style, { | |
| fontSize: "18px", | |
| fontWeight: "600" | |
| }); | |
| const subtitle = document.createElement("div"); | |
| subtitle.textContent = "Select which recurrences to export."; | |
| Object.assign(subtitle.style, { | |
| fontSize: "13px", | |
| color: "#4b5563", | |
| marginTop: "4px" | |
| }); | |
| header.appendChild(title); | |
| header.appendChild(subtitle); | |
| const statusRow = document.createElement("div"); | |
| Object.assign(statusRow.style, { | |
| display: "flex", | |
| alignItems: "center", | |
| gap: "10px", | |
| padding: "12px 20px", | |
| borderBottom: "1px solid #f3f4f6", | |
| fontSize: "13px" | |
| }); | |
| const indicator = document.createElement("span"); | |
| Object.assign(indicator.style, { | |
| width: "10px", | |
| height: "10px", | |
| borderRadius: "999px", | |
| background: "#9ca3af", | |
| flex: "0 0 auto" | |
| }); | |
| const statusText = document.createElement("span"); | |
| statusText.textContent = "Preparing..."; | |
| statusRow.appendChild(indicator); | |
| statusRow.appendChild(statusText); | |
| const body = document.createElement("div"); | |
| Object.assign(body.style, { | |
| padding: "16px 20px", | |
| overflow: "auto", | |
| flex: "1 1 auto", | |
| background: "#f9fafb" | |
| }); | |
| const emptyState = document.createElement("div"); | |
| emptyState.textContent = "Retrieving chat timeline..."; | |
| Object.assign(emptyState.style, { | |
| padding: "16px", | |
| border: "1px dashed #d1d5db", | |
| borderRadius: "10px", | |
| background: "#ffffff", | |
| color: "#6b7280", | |
| fontSize: "13px" | |
| }); | |
| const listContainer = document.createElement("div"); | |
| Object.assign(listContainer.style, { | |
| display: "flex", | |
| flexDirection: "column", | |
| gap: "12px" | |
| }); | |
| body.appendChild(emptyState); | |
| body.appendChild(listContainer); | |
| const footer = document.createElement("div"); | |
| Object.assign(footer.style, { | |
| padding: "14px 20px", | |
| borderTop: "1px solid #e5e7eb", | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "flex-end", | |
| gap: "10px", | |
| background: "#ffffff" | |
| }); | |
| const closeButton = document.createElement("button"); | |
| closeButton.type = "button"; | |
| closeButton.textContent = "Close"; | |
| const stopButton = document.createElement("button"); | |
| stopButton.type = "button"; | |
| stopButton.textContent = "Stop"; | |
| const startButton = document.createElement("button"); | |
| startButton.type = "button"; | |
| startButton.textContent = "Start"; | |
| [closeButton, stopButton, startButton].forEach((button) => { | |
| Object.assign(button.style, { | |
| borderRadius: "8px", | |
| border: "1px solid #d1d5db", | |
| padding: "8px 14px", | |
| fontSize: "13px", | |
| fontWeight: "600", | |
| cursor: "pointer", | |
| background: "#ffffff", | |
| color: "#111827" | |
| }); | |
| }); | |
| startButton.style.background = "#2563eb"; | |
| startButton.style.borderColor = "#2563eb"; | |
| startButton.style.color = "#ffffff"; | |
| footer.appendChild(closeButton); | |
| footer.appendChild(stopButton); | |
| footer.appendChild(startButton); | |
| panel.appendChild(header); | |
| panel.appendChild(statusRow); | |
| panel.appendChild(body); | |
| panel.appendChild(footer); | |
| overlay.appendChild(panel); | |
| document.body.appendChild(overlay); | |
| const setButtonDisabled = (button, disabled) => { | |
| button.disabled = disabled; | |
| button.style.opacity = disabled ? "0.55" : "1"; | |
| button.style.cursor = disabled ? "not-allowed" : "pointer"; | |
| }; | |
| setButtonDisabled(closeButton, false); | |
| setButtonDisabled(stopButton, false); | |
| setButtonDisabled(startButton, true); | |
| const updateIndicator = (busy) => { | |
| indicator.style.background = busy ? "#2563eb" : "#9ca3af"; | |
| indicator.style.boxShadow = busy ? "0 0 0 6px rgba(37, 99, 235, 0.16)" : "none"; | |
| }; | |
| const collectSelection = () => | |
| state.itemBindings | |
| .map((binding) => ({ | |
| ...binding.item, | |
| includeChat: binding.chatCheckbox.checked && !binding.chatCheckbox.disabled, | |
| includeTranscript: binding.transcriptCheckbox.checked && !binding.transcriptCheckbox.disabled | |
| })) | |
| .filter((item) => item.includeChat || item.includeTranscript); | |
| const setSelectionInputsDisabled = (disabled) => { | |
| state.selectionInputsLocked = disabled; | |
| state.itemBindings.forEach((binding) => { | |
| binding.chatCheckbox.disabled = disabled || binding.chatDefaultDisabled; | |
| binding.transcriptCheckbox.disabled = disabled || binding.transcriptDefaultDisabled; | |
| binding.syncMaster(); | |
| }); | |
| }; | |
| closeButton.addEventListener("click", () => { | |
| if (state.closed) return; | |
| if (state.started && !state.stopRequested) return; | |
| state.closed = true; | |
| if (state.startResolver) { | |
| const resolver = state.startResolver; | |
| state.startResolver = null; | |
| resolver(null); | |
| } | |
| overlay.remove(); | |
| }); | |
| stopButton.addEventListener("click", () => { | |
| if (stopButton.disabled || state.closed || state.stopRequested) return; | |
| state.stopRequested = true; | |
| setButtonDisabled(stopButton, true); | |
| statusText.textContent = "Stopping after the current step..."; | |
| updateIndicator(true); | |
| }); | |
| startButton.addEventListener("click", () => { | |
| if (startButton.disabled || !state.startResolver) return; | |
| const selection = collectSelection(); | |
| if (selection.length === 0) { | |
| statusText.textContent = "Select at least one Chat or Transcript source."; | |
| updateIndicator(false); | |
| return; | |
| } | |
| state.started = true; | |
| const resolver = state.startResolver; | |
| state.startResolver = null; | |
| resolver(selection); | |
| }); | |
| return { | |
| setStatus(text, busy = false) { | |
| if (state.closed) return; | |
| statusText.textContent = text; | |
| updateIndicator(busy); | |
| }, | |
| setMeetings(items) { | |
| if (state.closed) return; | |
| state.itemBindings = []; | |
| listContainer.replaceChildren(); | |
| if (!items.length) { | |
| emptyState.textContent = "No completed meeting recurrences were found in the chat timeline."; | |
| emptyState.style.display = ""; | |
| setButtonDisabled(startButton, true); | |
| setButtonDisabled(stopButton, true); | |
| setButtonDisabled(closeButton, false); | |
| return; | |
| } | |
| emptyState.style.display = "none"; | |
| items.forEach((item) => { | |
| const card = document.createElement("div"); | |
| Object.assign(card.style, { | |
| background: "#ffffff", | |
| border: "1px solid #e5e7eb", | |
| borderRadius: "10px", | |
| padding: "12px 14px" | |
| }); | |
| const topRow = document.createElement("label"); | |
| Object.assign(topRow.style, { | |
| display: "flex", | |
| alignItems: "flex-start", | |
| gap: "10px", | |
| fontSize: "13px", | |
| fontWeight: "600", | |
| cursor: "pointer" | |
| }); | |
| const masterCheckbox = document.createElement("input"); | |
| masterCheckbox.type = "checkbox"; | |
| const labelWrap = document.createElement("div"); | |
| labelWrap.style.flex = "1 1 auto"; | |
| const label = document.createElement("div"); | |
| label.textContent = item.label; | |
| labelWrap.appendChild(label); | |
| const childrenRow = document.createElement("div"); | |
| Object.assign(childrenRow.style, { | |
| display: "flex", | |
| alignItems: "center", | |
| gap: "18px", | |
| marginTop: "10px", | |
| marginLeft: "26px", | |
| fontSize: "12px", | |
| color: "#374151" | |
| }); | |
| const makeChildToggle = (text, checked, disabled) => { | |
| const childLabel = document.createElement("label"); | |
| Object.assign(childLabel.style, { | |
| display: "flex", | |
| alignItems: "center", | |
| gap: "6px", | |
| cursor: disabled ? "not-allowed" : "pointer", | |
| opacity: disabled ? "0.55" : "1" | |
| }); | |
| const checkbox = document.createElement("input"); | |
| checkbox.type = "checkbox"; | |
| checkbox.checked = checked; | |
| checkbox.disabled = disabled; | |
| const caption = document.createElement("span"); | |
| caption.textContent = text; | |
| childLabel.appendChild(checkbox); | |
| childLabel.appendChild(caption); | |
| return { childLabel, checkbox }; | |
| }; | |
| const chatToggle = makeChildToggle("Chat", item.defaults.chat, item.disabled.chat); | |
| const transcriptToggle = makeChildToggle("Transcript", item.defaults.transcript, item.disabled.transcript); | |
| const syncMaster = () => { | |
| const enabledChildren = [chatToggle.checkbox, transcriptToggle.checkbox].filter((checkbox) => !checkbox.disabled); | |
| if (enabledChildren.length === 0) { | |
| masterCheckbox.checked = false; | |
| masterCheckbox.indeterminate = false; | |
| masterCheckbox.disabled = true; | |
| return; | |
| } | |
| const checkedCount = enabledChildren.filter((checkbox) => checkbox.checked).length; | |
| masterCheckbox.disabled = state.selectionInputsLocked; | |
| masterCheckbox.checked = checkedCount === enabledChildren.length; | |
| masterCheckbox.indeterminate = checkedCount > 0 && checkedCount < enabledChildren.length; | |
| }; | |
| masterCheckbox.addEventListener("change", () => { | |
| [chatToggle.checkbox, transcriptToggle.checkbox] | |
| .filter((checkbox) => !checkbox.disabled) | |
| .forEach((checkbox) => { | |
| checkbox.checked = masterCheckbox.checked; | |
| }); | |
| syncMaster(); | |
| }); | |
| [chatToggle.checkbox, transcriptToggle.checkbox].forEach((checkbox) => { | |
| checkbox.addEventListener("change", syncMaster); | |
| }); | |
| syncMaster(); | |
| topRow.appendChild(masterCheckbox); | |
| topRow.appendChild(labelWrap); | |
| childrenRow.appendChild(chatToggle.childLabel); | |
| childrenRow.appendChild(transcriptToggle.childLabel); | |
| card.appendChild(topRow); | |
| card.appendChild(childrenRow); | |
| listContainer.appendChild(card); | |
| state.itemBindings.push({ | |
| item, | |
| masterCheckbox, | |
| chatCheckbox: chatToggle.checkbox, | |
| transcriptCheckbox: transcriptToggle.checkbox, | |
| chatDefaultDisabled: item.disabled.chat, | |
| transcriptDefaultDisabled: item.disabled.transcript, | |
| syncMaster | |
| }); | |
| }); | |
| setSelectionInputsDisabled(state.selectionInputsLocked); | |
| setButtonDisabled(startButton, false); | |
| setButtonDisabled(stopButton, true); | |
| setButtonDisabled(closeButton, false); | |
| }, | |
| waitForStart() { | |
| if (state.closed || state.stopRequested) { | |
| return Promise.resolve(null); | |
| } | |
| return new Promise((resolve) => { | |
| state.startResolver = resolve; | |
| }); | |
| }, | |
| beginProcessing() { | |
| if (state.closed) return; | |
| state.stopRequested = false; | |
| state.started = true; | |
| setSelectionInputsDisabled(true); | |
| setButtonDisabled(startButton, true); | |
| setButtonDisabled(stopButton, false); | |
| setButtonDisabled(closeButton, true); | |
| }, | |
| finish(success, message) { | |
| if (state.closed) return; | |
| if (success) { | |
| state.closed = true; | |
| overlay.remove(); | |
| return; | |
| } | |
| setSelectionInputsDisabled(false); | |
| setButtonDisabled(stopButton, true); | |
| setButtonDisabled(closeButton, false); | |
| if (message) this.setStatus(message, false); | |
| }, | |
| wasStopped() { | |
| return state.stopRequested; | |
| }, | |
| isClosed() { | |
| return state.closed; | |
| }, | |
| remove() { | |
| state.closed = true; | |
| overlay.remove(); | |
| } | |
| }; | |
| } | |
| function cleanupExtractedRichText(value) { | |
| return (value || "") | |
| .replace(/\u00a0/g, " ") | |
| .replace(/[ \t\f\v]+/g, " ") | |
| .replace(/ *\n */g, "\n") | |
| .replace(/\n{3,}/g, "\n\n") | |
| .trim(); | |
| } | |
| function escapeCsvValue(value) { | |
| const text = cleanupExtractedRichText(value); | |
| if (!/[",\n]/.test(text)) return text; | |
| return `"${text.replace(/"/g, '""')}"`; | |
| } | |
| function formatAttachmentEntry(name, url) { | |
| const normalizedName = normalizeText(name) || "attachment"; | |
| const normalizedUrl = normalizeText(url); | |
| return normalizedUrl ? `<${normalizedName}> (${normalizedUrl})` : `<${normalizedName}>`; | |
| } | |
| function getBestAttachmentUrl(element, attributeNames) { | |
| for (const attributeName of attributeNames) { | |
| const value = normalizeText(element.getAttribute(attributeName)); | |
| if (!value || value.startsWith("blob:")) continue; | |
| return value; | |
| } | |
| return ""; | |
| } | |
| function formatAttachmentFromElement(element) { | |
| const tagName = element.tagName.toLowerCase(); | |
| if (tagName === "img") { | |
| const url = getBestAttachmentUrl(element, ["data-gallery-src", "data-orig-src", "src"]); | |
| const name = | |
| normalizeText(element.getAttribute("title")) || | |
| normalizeText(element.getAttribute("alt")) || | |
| normalizeText(element.getAttribute("aria-label")) || | |
| "image"; | |
| return formatAttachmentEntry(name, url); | |
| } | |
| if (tagName === "a") { | |
| const url = getBestAttachmentUrl(element, ["href"]); | |
| if (!url || /^javascript:/i.test(url)) return ""; | |
| const name = | |
| normalizeText(element.innerText || element.textContent) || | |
| normalizeText(element.getAttribute("title")) || | |
| normalizeText(element.getAttribute("aria-label")) || | |
| url; | |
| return formatAttachmentEntry(name, url); | |
| } | |
| return ""; | |
| } | |
| function extractRichTextContent(node) { | |
| if (!node) return ""; | |
| const parts = []; | |
| const blockTags = new Set(["p", "section", "article", "ul", "ol", "li", "pre", "blockquote"]); | |
| const pushNewline = () => { | |
| if (parts.length === 0) return; | |
| const lastPart = String(parts[parts.length - 1] || ""); | |
| if (!lastPart.endsWith("\n")) parts.push("\n"); | |
| }; | |
| const walk = (current) => { | |
| if (!current) return; | |
| if (current.nodeType === Node.TEXT_NODE) { | |
| parts.push(current.textContent || ""); | |
| return; | |
| } | |
| if (current.nodeType !== Node.ELEMENT_NODE) return; | |
| const element = /** @type {HTMLElement} */ (current); | |
| const tagName = element.tagName.toLowerCase(); | |
| if (tagName === "br") { | |
| parts.push("\n"); | |
| return; | |
| } | |
| if (tagName === "table") { | |
| pushNewline(); | |
| parts.push(extractTableAsCsv(element)); | |
| parts.push("\n"); | |
| return; | |
| } | |
| const attachmentText = formatAttachmentFromElement(element); | |
| if (attachmentText) { | |
| parts.push(attachmentText); | |
| return; | |
| } | |
| const isBlock = blockTags.has(tagName); | |
| if (isBlock) pushNewline(); | |
| Array.from(element.childNodes).forEach((child) => walk(child)); | |
| if (isBlock) pushNewline(); | |
| }; | |
| Array.from(node.childNodes).forEach((child) => walk(child)); | |
| return cleanupExtractedRichText(parts.join("")); | |
| } | |
| function extractTableAsCsv(tableNode) { | |
| const rows = Array.from(tableNode.querySelectorAll("tr")); | |
| return rows | |
| .map((row) => | |
| Array.from(row.cells) | |
| .map((cell) => escapeCsvValue(extractRichTextContent(cell))) | |
| .join(",") | |
| ) | |
| .join("\n"); | |
| } | |
| const transcriptState = { | |
| isSaving() { | |
| const titleNode = queryFirst(CONFIG.selectors.statusTitle); | |
| const titleText = normalizeText(titleNode?.innerText || titleNode?.textContent).toLowerCase(); | |
| if (!titleText) return false; | |
| return ( | |
| titleText.includes("transcript") && | |
| CONFIG.transcriptSavingKeywords.some((keyword) => titleText.includes(keyword)) | |
| ); | |
| } | |
| }; | |
| class TranscriptStore { | |
| constructor() { | |
| this._messages = new Map(); | |
| this._order = 0; | |
| this._duplicates = 0; | |
| } | |
| get size() { | |
| return this._messages.size; | |
| } | |
| get duplicateCount() { | |
| return this._duplicates; | |
| } | |
| buildFallbackKey(message) { | |
| const base = `${message.author}|${message.text}`; | |
| if (message.timestampIso) { | |
| return `fallback:${message.timestampIso}|${base}`; | |
| } | |
| if (message.timestampRaw && !isRelativeTimestampLabel(message.timestampRaw)) { | |
| return `fallback:${message.timestampRaw}|${base}`; | |
| } | |
| return `fallback:${base}`; | |
| } | |
| add(message) { | |
| const key = message.messageId ? `id:${message.messageId}` : this.buildFallbackKey(message); | |
| if (this._messages.has(key)) { | |
| this._duplicates += 1; | |
| return false; | |
| } | |
| this._messages.set(key, { ...message, order: this._order++ }); | |
| return true; | |
| } | |
| toSortedArray(meetingStartEpochMs = null) { | |
| return sortMessagesChronologically(Array.from(this._messages.values()), meetingStartEpochMs); | |
| } | |
| } | |
| const extractor = { | |
| createContext() { | |
| return { | |
| lastAuthor: CONFIG.labels.unknownSpeaker, | |
| lastTimestamp: "", | |
| lastTimestampIso: "" | |
| }; | |
| }, | |
| extractMessageId(item, cell) { | |
| for (const attr of CONFIG.selectors.messageIdAttributes) { | |
| const direct = normalizeMessageId(item.getAttribute(attr)); | |
| if (direct) return `attr:${attr}:${direct}`; | |
| const nestedNode = queryFirst(`[${attr}]`, item); | |
| const nested = normalizeMessageId(nestedNode?.getAttribute(attr)); | |
| if (nested) return `attr:${attr}:${nested}`; | |
| } | |
| for (const attr of CONFIG.selectors.messagePositionAttributes) { | |
| const value = normalizeText(item.getAttribute(attr) || cell?.getAttribute(attr)); | |
| if (value && isNumericLike(value)) { | |
| return `pos:${attr}:${value}`; | |
| } | |
| } | |
| const nodeIds = queryAll(CONFIG.selectors.messageIdNodes, item) | |
| .map((node) => normalizeCandidateNodeId(node.getAttribute("id"))) | |
| .filter(Boolean); | |
| if (nodeIds.length > 0) return `node:${nodeIds.sort()[0]}`; | |
| const itemId = normalizeCandidateNodeId(item.id); | |
| if (itemId && !isLikelyVolatileListId(itemId)) return `node:${itemId}`; | |
| return ""; | |
| }, | |
| extractTimestamp(item, context) { | |
| let timestampText = ""; | |
| let timestampIso = ""; | |
| const timestampNode = queryFirst(CONFIG.selectors.timestampNodes, item); | |
| if (timestampNode) { | |
| timestampIso = normalizeText(timestampNode.getAttribute("datetime")); | |
| timestampText = normalizeText(timestampNode.innerText || timestampNode.textContent); | |
| if (!timestampText) { | |
| timestampText = extractDurationToken(timestampNode.getAttribute("aria-label")); | |
| } | |
| } | |
| if (!timestampText) { | |
| const relativeNode = queryFirst( | |
| ['[id^="Header-timestamp-"]', '[id^="Left-timestamp-"]', '[class*="screenReaderFriendlyHiddenTag"]'], | |
| item | |
| ); | |
| const relativeText = normalizeText( | |
| relativeNode?.innerText || relativeNode?.textContent || relativeNode?.getAttribute("aria-label") | |
| ); | |
| if (relativeText) { | |
| timestampText = extractDurationToken(relativeText) || relativeText; | |
| } | |
| } | |
| if (!timestampText) { | |
| const entryNode = queryFirst('[id^="entry-"]', item); | |
| const entryAriaLabel = normalizeText(entryNode?.getAttribute("aria-label")); | |
| if (entryAriaLabel) { | |
| timestampText = extractDurationToken(entryAriaLabel) || entryAriaLabel; | |
| } | |
| } | |
| if (!timestampText) { | |
| const ariaLabelTime = extractTimeFromText(item.getAttribute("aria-label")); | |
| if (ariaLabelTime) timestampText = ariaLabelTime; | |
| } | |
| if (!timestampText) { | |
| const itemTextTime = extractTimeFromText(item.innerText || item.textContent); | |
| if (itemTextTime) timestampText = itemTextTime; | |
| } | |
| if (timestampIso) context.lastTimestampIso = timestampIso; | |
| if (timestampText) context.lastTimestamp = timestampText; | |
| return { | |
| timestampRaw: timestampText || context.lastTimestamp || timestampIso || CONFIG.labels.unknownTimestamp, | |
| timestampIso: timestampIso || context.lastTimestampIso || "" | |
| }; | |
| }, | |
| extractAuthor(item, context, text) { | |
| const authorNode = queryFirst(CONFIG.selectors.authorNodes, item); | |
| const authorText = normalizeText(authorNode?.innerText || authorNode?.textContent); | |
| if (authorText) context.lastAuthor = authorText; | |
| if (!authorText && isSystemEventText(text)) return "System"; | |
| return context.lastAuthor || CONFIG.labels.unknownSpeaker; | |
| }, | |
| extractText(item) { | |
| const textNodes = queryAll(CONFIG.selectors.textNodes, item); | |
| if (textNodes.length === 0) return ""; | |
| const chunks = textNodes | |
| .map((node) => normalizeText(node.innerText || node.textContent)) | |
| .filter(Boolean); | |
| return normalizeText(chunks.join(" ")); | |
| }, | |
| extractVisible(listSurface, store, context) { | |
| const cells = listSurface.querySelectorAll(CONFIG.selectors.listCell); | |
| cells.forEach((cell) => { | |
| const item = | |
| (cell.matches(CONFIG.selectors.listItem) ? cell : null) || | |
| queryFirst(CONFIG.selectors.listItem, cell); | |
| if (!item) return; | |
| const { timestampRaw, timestampIso } = this.extractTimestamp(item, context); | |
| const text = this.extractText(item); | |
| if (!text) return; | |
| const author = this.extractAuthor(item, context, text); | |
| const { timestampFull, epochMs, elapsedSeconds } = resolveFullTimestamp(timestampIso, timestampRaw); | |
| const messageId = this.extractMessageId(item, cell); | |
| store.add({ | |
| messageId, | |
| timestampRaw, | |
| timestampIso, | |
| timestampFull, | |
| epochMs, | |
| elapsedSeconds, | |
| kind: isSystemEventText(text) ? "system" : "transcript", | |
| author, | |
| text | |
| }); | |
| }); | |
| } | |
| }; | |
| const chatExtractor = { | |
| extractMessageId(messageBody) { | |
| return normalizeMessageId(messageBody.getAttribute("data-mid")); | |
| }, | |
| extractTimestamp(wrapper) { | |
| const timestampNode = queryFirst("time[datetime]", wrapper); | |
| const timestampIso = normalizeText(timestampNode?.getAttribute("datetime")); | |
| const timestampRaw = normalizeText(timestampNode?.innerText || timestampNode?.textContent); | |
| return { | |
| timestampRaw: timestampRaw || timestampIso || CONFIG.labels.unknownTimestamp, | |
| timestampIso | |
| }; | |
| }, | |
| extractAuthor(wrapper) { | |
| const authorNode = queryFirst('[data-tid="message-author-name"]', wrapper); | |
| return normalizeText(authorNode?.innerText || authorNode?.textContent) || CONFIG.labels.unknownSpeaker; | |
| }, | |
| extractText(messageBody) { | |
| const contentNode = queryFirst(CONFIG.selectors.chatMessageContent, messageBody) || messageBody; | |
| return extractRichTextContent(contentNode); | |
| }, | |
| extractControlSummaryText(controlMessageBody) { | |
| const contentNode = queryFirst(CONFIG.selectors.chatMessageContent, controlMessageBody) || controlMessageBody; | |
| const firstBlock = contentNode.firstElementChild; | |
| const text = normalizeText(firstBlock?.innerText || firstBlock?.textContent || contentNode.innerText || contentNode.textContent); | |
| return text; | |
| }, | |
| extractControlMessage(controlMessageBody) { | |
| const messageId = this.extractMessageId(controlMessageBody); | |
| const epochMs = toNumericEpoch(controlMessageBody.getAttribute("data-mid")); | |
| const timestampIso = typeof epochMs === "number" ? new Date(epochMs).toISOString() : ""; | |
| const timestampRaw = extractTimeFromText(this.extractControlSummaryText(controlMessageBody)) || timestampIso || CONFIG.labels.unknownTimestamp; | |
| const text = this.extractControlSummaryText(controlMessageBody); | |
| if (!text) return null; | |
| const { timestampFull, elapsedSeconds } = resolveFullTimestamp(timestampIso, timestampRaw); | |
| return { | |
| messageId, | |
| timestampRaw, | |
| timestampIso, | |
| timestampFull, | |
| epochMs, | |
| elapsedSeconds, | |
| kind: "system", | |
| author: "System", | |
| text | |
| }; | |
| }, | |
| extractControlEvent(controlMessageBody) { | |
| const text = this.extractControlSummaryText(controlMessageBody); | |
| if (!text) return null; | |
| const lower = text.toLowerCase(); | |
| let type = null; | |
| if (lower.includes("meeting started")) type = "start"; | |
| if (lower.includes("meeting ended")) type = "end"; | |
| if (!type) return null; | |
| const epochMs = toNumericEpoch(controlMessageBody.getAttribute("data-mid")); | |
| if (epochMs === null) return null; | |
| return { | |
| type, | |
| messageId: normalizeMessageId(controlMessageBody.getAttribute("data-mid")), | |
| epochMs, | |
| text, | |
| hasTranscript: type === "end" && Boolean(queryFirst(CONFIG.selectors.chatMeetingTranscriptButton, controlMessageBody)) | |
| }; | |
| }, | |
| extractVisible(chatRoot, store, recurrenceEventStore) { | |
| const messageBodies = queryAll(CONFIG.selectors.chatMessageBodies, chatRoot); | |
| messageBodies.forEach((messageBody) => { | |
| const wrapper = messageBody.closest(CONFIG.selectors.chatMessageWrapper) || messageBody; | |
| const { timestampRaw, timestampIso } = this.extractTimestamp(wrapper); | |
| const { timestampFull, epochMs, elapsedSeconds } = resolveFullTimestamp(timestampIso, timestampRaw); | |
| const text = this.extractText(messageBody); | |
| if (!text) return; | |
| store.add({ | |
| messageId: this.extractMessageId(messageBody), | |
| timestampRaw, | |
| timestampIso, | |
| timestampFull, | |
| epochMs, | |
| elapsedSeconds, | |
| kind: "chat", | |
| author: this.extractAuthor(wrapper), | |
| text | |
| }); | |
| }); | |
| const controlMessageBodies = queryAll(CONFIG.selectors.chatControlMessageBodies, chatRoot); | |
| controlMessageBodies.forEach((controlMessageBody) => { | |
| const controlMessage = this.extractControlMessage(controlMessageBody); | |
| if (controlMessage) store.add(controlMessage); | |
| addRecurrenceEvent(recurrenceEventStore, this.extractControlEvent(controlMessageBody)); | |
| }); | |
| } | |
| }; | |
| const metadata = { | |
| getMeetingTitle() { | |
| const node = queryFirst(CONFIG.selectors.meetingTitle); | |
| const byTitle = normalizeText(node?.getAttribute("title")); | |
| const byText = normalizeText(node?.innerText || node?.textContent); | |
| return byTitle || byText || CONFIG.labels.unknownMeetingTitle; | |
| }, | |
| getMeetingRecurrence() { | |
| const node = queryFirst(CONFIG.selectors.meetingRecurrence); | |
| const byText = normalizeText(node?.innerText || node?.textContent); | |
| return byText; | |
| }, | |
| getMeetingTime() { | |
| const node = queryFirst(CONFIG.selectors.meetingTime); | |
| const byDateTime = normalizeText(node?.getAttribute("datetime")); | |
| const byText = normalizeText(node?.innerText || node?.textContent); | |
| const rawTime = byDateTime || byText; | |
| if (!rawTime) { | |
| const timezoneLabel = getBrowserTimezoneLabel(); | |
| return { | |
| raw: "", | |
| display: `${CONFIG.labels.unknownMeetingTime} (${timezoneLabel})`, | |
| timezoneLabel, | |
| startEpochMs: null, | |
| endEpochMs: null | |
| }; | |
| } | |
| const { startEpochMs, endEpochMs } = parseMeetingTimeRange(rawTime); | |
| const timezoneLabel = getBrowserTimezoneLabel(startEpochMs); | |
| return { | |
| raw: rawTime, | |
| display: `${rawTime} (${timezoneLabel})`, | |
| timezoneLabel, | |
| startEpochMs, | |
| endEpochMs | |
| }; | |
| }, | |
| findParticipantsDialog() { | |
| const dialogs = queryAll(CONFIG.selectors.participantsDialog); | |
| return dialogs.find((dialog) => Boolean(queryFirst(CONFIG.selectors.participantsList, dialog))) || null; | |
| }, | |
| parseParticipantLabel(row) { | |
| const nameNode = queryFirst(CONFIG.selectors.participantNameNodes, row); | |
| const name = | |
| normalizeText(nameNode?.getAttribute("title")) || | |
| normalizeText(nameNode?.innerText || nameNode?.textContent); | |
| if (!name) return ""; | |
| const mailtoLink = queryFirst('a[href^="mailto:"]', row); | |
| const fromMailto = normalizeText(mailtoLink?.getAttribute("href")).replace(/^mailto:/i, ""); | |
| const attributePool = Array.from(row.querySelectorAll("[title], [aria-label]")) | |
| .map((node) => normalizeText(node.getAttribute("title") || node.getAttribute("aria-label"))) | |
| .filter(Boolean) | |
| .join(" "); | |
| const email = | |
| extractEmailFromText(fromMailto) || | |
| extractEmailFromText(attributePool) || | |
| extractEmailFromText(row.getAttribute("aria-label")) || | |
| extractEmailFromText(row.innerText || row.textContent); | |
| return email ? `${name} <${email}>` : name; | |
| }, | |
| collectVisibleParticipants(root, participantsMap) { | |
| const rows = queryAll(CONFIG.selectors.participantRows, root); | |
| rows.forEach((row) => { | |
| const label = this.parseParticipantLabel(row); | |
| if (!label) return; | |
| const key = label.toLowerCase(); | |
| if (!participantsMap.has(key)) participantsMap.set(key, label); | |
| }); | |
| }, | |
| async getParticipants() { | |
| const participantsButton = queryFirst(CONFIG.selectors.participantsButton); | |
| let opened = false; | |
| const participantsMap = new Map(); | |
| if (participantsButton) { | |
| const wasExpanded = participantsButton.getAttribute("aria-expanded") === "true"; | |
| if (!wasExpanded) { | |
| participantsButton.click(); | |
| opened = true; | |
| await sleep(CONFIG.metadata.rosterDelayMs); | |
| } | |
| } | |
| const dialog = this.findParticipantsDialog(); | |
| const listRoot = (dialog && queryFirst(CONFIG.selectors.participantsList, dialog)) || dialog; | |
| const fallbackRoot = listRoot || document; | |
| this.collectVisibleParticipants(fallbackRoot, participantsMap); | |
| const virtualGrid = queryFirst(CONFIG.selectors.participantsVirtualGrid, fallbackRoot); | |
| if (virtualGrid) { | |
| let iterations = 0; | |
| let stableCount = 0; | |
| let lastSize = participantsMap.size; | |
| while (iterations < CONFIG.metadata.maxParticipantScrollIterations) { | |
| iterations += 1; | |
| const maxTop = Math.max(virtualGrid.scrollHeight - virtualGrid.clientHeight, 0); | |
| const nextTop = Math.min(virtualGrid.scrollTop + virtualGrid.clientHeight, maxTop); | |
| if (nextTop === virtualGrid.scrollTop) break; | |
| virtualGrid.scrollTop = nextTop; | |
| await waitForDomChange(fallbackRoot, CONFIG.metadata.participantMutationTimeoutMs); | |
| await sleep(60); | |
| this.collectVisibleParticipants(fallbackRoot, participantsMap); | |
| if (participantsMap.size === lastSize) stableCount += 1; | |
| else stableCount = 0; | |
| lastSize = participantsMap.size; | |
| if (stableCount >= 2) break; | |
| } | |
| } | |
| if (opened) participantsButton.click(); | |
| return Array.from(participantsMap.values()); | |
| }, | |
| async collect() { | |
| const meetingTime = this.getMeetingTime(); | |
| const recurrence = this.getMeetingRecurrence(); | |
| return { | |
| title: appendMeetingRecurrence(this.getMeetingTitle(), recurrence), | |
| recurrence, | |
| time: meetingTime.display, | |
| timeRaw: meetingTime.raw, | |
| timezoneLabel: meetingTime.timezoneLabel, | |
| startEpochMs: meetingTime.startEpochMs, | |
| endEpochMs: meetingTime.endEpochMs, | |
| participants: await this.getParticipants() | |
| }; | |
| } | |
| }; | |
| const scrollEngine = { | |
| findContainer() { | |
| const selectors = Array.isArray(CONFIG.selectors.scrollContainer) | |
| ? CONFIG.selectors.scrollContainer | |
| : [CONFIG.selectors.scrollContainer]; | |
| const seen = new Set(); | |
| const candidates = []; | |
| selectors.forEach((selector) => { | |
| document.querySelectorAll(selector).forEach((node) => { | |
| if (seen.has(node)) return; | |
| seen.add(node); | |
| candidates.push(node); | |
| }); | |
| }); | |
| const visibleCandidates = candidates.filter((node) => isElementVisible(node)); | |
| const rankedCandidates = (visibleCandidates.length > 0 ? visibleCandidates : candidates).slice().sort((a, b) => { | |
| const aRect = a.getBoundingClientRect(); | |
| const bRect = b.getBoundingClientRect(); | |
| return bRect.width * bRect.height - aRect.width * aRect.height; | |
| }); | |
| return rankedCandidates[0] || null; | |
| }, | |
| async moveToTopAndSettle(scrollContainer, listSurface, shouldStop = null) { | |
| let stableCount = 0; | |
| let iterations = 0; | |
| while (iterations < CONFIG.scroll.maxTopIterations) { | |
| if (typeof shouldStop === "function" && shouldStop()) return false; | |
| iterations += 1; | |
| const beforeTop = scrollContainer.scrollTop; | |
| const beforeHeight = scrollContainer.scrollHeight; | |
| scrollContainer.scrollTop = 0; | |
| await waitForDomChange(listSurface, CONFIG.scroll.mutationTimeoutMs); | |
| await sleep(CONFIG.scroll.settleDelayMs); | |
| if (typeof shouldStop === "function" && shouldStop()) return false; | |
| const atTop = scrollContainer.scrollTop <= 1; | |
| const cannotMoveFurtherUp = beforeTop <= 1 || Math.abs(scrollContainer.scrollTop - beforeTop) < 1; | |
| const heightStable = Math.abs(scrollContainer.scrollHeight - beforeHeight) < 2; | |
| if (atTop && (cannotMoveFurtherUp || heightStable)) { | |
| stableCount += 1; | |
| } else { | |
| stableCount = 0; | |
| } | |
| if (stableCount >= CONFIG.scroll.topStableLimit) { | |
| return true; | |
| } | |
| } | |
| warn(`Top settle reached max iterations (${CONFIG.scroll.maxTopIterations}).`); | |
| return true; | |
| }, | |
| async collectAllWith(scrollContainer, listSurface, collectVisible, getSize, options = {}) { | |
| const initialDelayMs = options.initialDelayMs ?? CONFIG.scroll.initialDelayMs; | |
| const settleDelayMs = options.settleDelayMs ?? CONFIG.scroll.settleDelayMs; | |
| const viewportStepRatio = options.viewportStepRatio ?? CONFIG.scroll.viewportStepRatio; | |
| const shouldStop = typeof options.shouldStop === "function" ? options.shouldStop : null; | |
| const settled = await this.moveToTopAndSettle(scrollContainer, listSurface, shouldStop); | |
| if (settled === false) return false; | |
| await sleep(initialDelayMs); | |
| if (shouldStop && shouldStop()) return false; | |
| collectVisible(); | |
| let stableCount = 0; | |
| let iterations = 0; | |
| while (iterations < CONFIG.scroll.maxIterations) { | |
| if (shouldStop && shouldStop()) return false; | |
| iterations += 1; | |
| const beforeTop = scrollContainer.scrollTop; | |
| const beforeHeight = scrollContainer.scrollHeight; | |
| const beforeSize = getSize(); | |
| const maxTop = Math.max(scrollContainer.scrollHeight - scrollContainer.clientHeight, 0); | |
| const step = Math.max(1, Math.floor(scrollContainer.clientHeight * viewportStepRatio)); | |
| const nextTop = Math.min(beforeTop + step, maxTop); | |
| scrollContainer.scrollTop = nextTop; | |
| await waitForDomChange(listSurface, CONFIG.scroll.mutationTimeoutMs); | |
| await sleep(settleDelayMs); | |
| if (shouldStop && shouldStop()) return false; | |
| collectVisible(); | |
| const atBottom = isAtBottom(scrollContainer); | |
| const noNewMessages = getSize() === beforeSize; | |
| const heightStable = Math.abs(scrollContainer.scrollHeight - beforeHeight) < 2; | |
| const cannotMoveFurther = Math.abs(scrollContainer.scrollTop - beforeTop) < 1; | |
| if (atBottom && noNewMessages && (heightStable || cannotMoveFurther)) { | |
| stableCount += 1; | |
| } else { | |
| stableCount = 0; | |
| } | |
| if (stableCount >= CONFIG.scroll.stableLimit) break; | |
| } | |
| if (iterations >= CONFIG.scroll.maxIterations) { | |
| warn(`Stopped after max iterations (${CONFIG.scroll.maxIterations}).`); | |
| } | |
| return true; | |
| }, | |
| async collectAll(scrollContainer, listSurface, store, context, options = {}) { | |
| return this.collectAllWith( | |
| scrollContainer, | |
| listSurface, | |
| () => extractor.extractVisible(listSurface, store, context), | |
| () => store.size, | |
| options | |
| ); | |
| } | |
| }; | |
| const chatEngine = { | |
| async activate() { | |
| const chatTab = queryFirst(CONFIG.selectors.chatTab); | |
| if (!chatTab) { | |
| warn("Chat tab not found."); | |
| return null; | |
| } | |
| if (chatTab.getAttribute("aria-selected") !== "true") { | |
| chatTab.click(); | |
| await sleep(CONFIG.scroll.initialDelayMs); | |
| } | |
| const chatViewport = await waitForElement(CONFIG.selectors.chatViewport, document, CONFIG.scroll.mutationTimeoutMs); | |
| const chatRoot = | |
| (chatViewport && (await waitForElement(CONFIG.selectors.chatList, chatViewport, CONFIG.scroll.mutationTimeoutMs))) || | |
| chatViewport; | |
| if (!chatViewport || !chatRoot) { | |
| warn("Chat message pane not found."); | |
| return null; | |
| } | |
| return { | |
| chatViewport, | |
| chatRoot | |
| }; | |
| }, | |
| async collect(shouldStop = null) { | |
| const roots = await this.activate(); | |
| if (!roots) { | |
| return { | |
| messages: [], | |
| recurrenceEvents: [], | |
| duplicateCount: 0, | |
| stopped: false | |
| }; | |
| } | |
| const { chatViewport, chatRoot } = roots; | |
| const store = new TranscriptStore(); | |
| const recurrenceEventStore = new Map(); | |
| const completed = await scrollEngine.collectAllWith( | |
| chatViewport, | |
| chatRoot, | |
| () => chatExtractor.extractVisible(chatRoot, store, recurrenceEventStore), | |
| () => store.size, | |
| { | |
| initialDelayMs: CONFIG.scroll.chatInitialDelayMs, | |
| settleDelayMs: CONFIG.scroll.chatSettleDelayMs, | |
| viewportStepRatio: CONFIG.scroll.chatViewportStepRatio, | |
| shouldStop | |
| } | |
| ); | |
| return { | |
| messages: store.toSortedArray().map(({ order, ...rest }) => rest), | |
| recurrenceEvents: Array.from(recurrenceEventStore.values()).sort((a, b) => a.epochMs - b.epochMs), | |
| duplicateCount: store.duplicateCount, | |
| stopped: completed === false | |
| }; | |
| }, | |
| async findTranscriptButtonForMeetingBlock(targetMessageId, shouldStop = null) { | |
| const normalizedMessageId = normalizeMessageId(targetMessageId); | |
| if (!normalizedMessageId) return null; | |
| const roots = await this.activate(); | |
| if (!roots) return null; | |
| const { chatViewport, chatRoot } = roots; | |
| const settled = await scrollEngine.moveToTopAndSettle(chatViewport, chatRoot, shouldStop); | |
| if (settled === false) return null; | |
| await sleep(CONFIG.scroll.chatInitialDelayMs); | |
| if (typeof shouldStop === "function" && shouldStop()) return null; | |
| let stableCount = 0; | |
| let iterations = 0; | |
| while (iterations < CONFIG.scroll.maxIterations) { | |
| if (typeof shouldStop === "function" && shouldStop()) return null; | |
| iterations += 1; | |
| const block = queryFirst(`[data-tid="control-message-renderer"][data-mid="${normalizedMessageId}"]`, chatRoot); | |
| if (block) { | |
| const button = queryFirst(CONFIG.selectors.chatMeetingTranscriptButton, block); | |
| return button || null; | |
| } | |
| const beforeTop = chatViewport.scrollTop; | |
| const maxTop = Math.max(chatViewport.scrollHeight - chatViewport.clientHeight, 0); | |
| const step = Math.max(1, Math.floor(chatViewport.clientHeight * CONFIG.scroll.chatViewportStepRatio)); | |
| const nextTop = Math.min(beforeTop + step, maxTop); | |
| chatViewport.scrollTop = nextTop; | |
| await waitForDomChange(chatRoot, CONFIG.scroll.mutationTimeoutMs); | |
| await sleep(CONFIG.scroll.chatSettleDelayMs); | |
| if (typeof shouldStop === "function" && shouldStop()) return null; | |
| const atBottom = isAtBottom(chatViewport); | |
| const cannotMoveFurther = Math.abs(chatViewport.scrollTop - beforeTop) < 1; | |
| if (atBottom && cannotMoveFurther) stableCount += 1; | |
| else stableCount = 0; | |
| if (stableCount >= CONFIG.scroll.stableLimit) break; | |
| } | |
| return null; | |
| } | |
| }; | |
| async function extractTranscriptForMeetingSelection(selection, shouldStop = null) { | |
| if (typeof shouldStop === "function" && shouldStop()) return []; | |
| const transcriptButton = await chatEngine.findTranscriptButtonForMeetingBlock(selection.transcriptBlockMessageId, shouldStop); | |
| if (!transcriptButton) { | |
| if (typeof shouldStop === "function" && shouldStop()) return []; | |
| warn(`Transcript button not found for ${selection.meeting.title}.`); | |
| return []; | |
| } | |
| transcriptButton.click(); | |
| await sleep(CONFIG.scroll.initialDelayMs * 2); | |
| if (typeof shouldStop === "function" && shouldStop()) { | |
| await chatEngine.activate(); | |
| return []; | |
| } | |
| if (transcriptState.isSaving()) { | |
| warn(`Transcript is still being saved for ${selection.meeting.title}.`); | |
| await chatEngine.activate(); | |
| return []; | |
| } | |
| const scrollContainer = await waitForResult(() => { | |
| const recapTab = queryFirst(CONFIG.selectors.recapTab); | |
| if (recapTab && recapTab.getAttribute("aria-selected") !== "true") return null; | |
| return scrollEngine.findContainer(); | |
| }, 5000); | |
| if (!scrollContainer) { | |
| warn(`Transcript panel not found for ${selection.meeting.title}.`); | |
| await chatEngine.activate(); | |
| return []; | |
| } | |
| const listSurface = queryFirst(CONFIG.selectors.listSurface, scrollContainer) || scrollContainer; | |
| const transcriptStore = new TranscriptStore(); | |
| const transcriptContext = extractor.createContext(); | |
| await scrollEngine.collectAll(scrollContainer, listSurface, transcriptStore, transcriptContext, { shouldStop }); | |
| const transcriptMessages = transcriptStore | |
| .toSortedArray(selection.meeting.startEpochMs) | |
| .map(({ order, ...rest }) => rest); | |
| await chatEngine.activate(); | |
| return transcriptMessages; | |
| } | |
| const builder = { | |
| toTxt(meeting, messages) { | |
| const participantsText = | |
| meeting.participants.length > 0 | |
| ? meeting.participants.map((name) => `- ${name}`).join("\n") | |
| : "- Unknown participants"; | |
| const body = messages | |
| .map((msg) => `[${getMessageKindMarker(msg)} ${msg.timestampFull}] ${msg.author}: ${msg.text}`) | |
| .join("\n\n"); | |
| return [ | |
| "==================================================", | |
| `MEETING TITLE: ${meeting.title}`, | |
| "", | |
| `MEETING TIME: ${meeting.time}`, | |
| "", | |
| "PARTICIPANTS:", | |
| participantsText, | |
| "==================================================", | |
| "", | |
| body | |
| ].join("\n"); | |
| }, | |
| toMd(meeting, messages) { | |
| const participantsSection = | |
| meeting.participants.length > 0 | |
| ? meeting.participants.map((name) => `- ${name}`).join("\n") | |
| : "- Unknown participants"; | |
| const timelineSection = messages | |
| .map((msg) => `[${getMessageKindMarker(msg)} ${msg.timestampFull}] ${msg.author}: ${msg.text}`) | |
| .join("\n"); | |
| return [ | |
| `# ${meeting.title}`, | |
| "", | |
| `**Meeting time:** ${meeting.time}`, | |
| "", | |
| "## Participants", | |
| participantsSection, | |
| "", | |
| "## Timeline", | |
| timelineSection | |
| ].join("\n"); | |
| }, | |
| toJson(meeting, messages) { | |
| const payload = { | |
| meeting, | |
| messages: messages.map((msg) => ({ | |
| kind: msg.kind, | |
| messageId: msg.messageId, | |
| timestampRaw: msg.timestampRaw, | |
| timestampIso: msg.timestampIso, | |
| timestampFull: msg.timestampFull, | |
| author: msg.author, | |
| text: msg.text | |
| })) | |
| }; | |
| return JSON.stringify(payload, null, 2); | |
| } | |
| }; | |
| const exporter = { | |
| download(content, extension, meeting) { | |
| const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| const meetingTitle = meeting?.title || CONFIG.labels.unknownMeetingTitle; | |
| const meetingStartEpochMs = typeof meeting?.startEpochMs === "number" ? meeting.startEpochMs : null; | |
| a.href = url; | |
| a.download = buildDownloadFilename(meetingTitle, extension, meetingStartEpochMs); | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| }; | |
| const dialog = createSelectionDialog(); | |
| dialog.setStatus("Retrieving chat timeline...", true); | |
| const shouldStopWork = () => dialog.wasStopped() || dialog.isClosed(); | |
| log("Starting chat timeline scan..."); | |
| const { | |
| messages: allChatMessages, | |
| recurrenceEvents, | |
| duplicateCount: chatDuplicateCount, | |
| stopped: chatScanStopped | |
| } = await chatEngine.collect(shouldStopWork); | |
| if (dialog.isClosed()) return; | |
| if (chatScanStopped || dialog.wasStopped()) { | |
| dialog.finish(false, "Process stopped before export."); | |
| return; | |
| } | |
| const meeting = await metadata.collect(); | |
| if (dialog.isClosed()) return; | |
| if (dialog.wasStopped()) { | |
| dialog.finish(false, "Process stopped before export."); | |
| return; | |
| } | |
| const recurrenceSessions = buildRecurrenceSessions(recurrenceEvents); | |
| const meetingSelections = buildMeetingSelections(meeting, recurrenceSessions).filter( | |
| (item) => !item.disabled.chat || !item.disabled.transcript | |
| ); | |
| log("Meeting recurrences detected in chat:", recurrenceSessions.length); | |
| log("Timeline messages extracted:", allChatMessages.length); | |
| log("Timeline duplicates skipped:", chatDuplicateCount); | |
| dialog.setMeetings(meetingSelections); | |
| dialog.setStatus( | |
| meetingSelections.length > 0 ? "Choose the recurrences to export, then press Start." : "No completed meetings were found.", | |
| false | |
| ); | |
| const selectedMeetings = await dialog.waitForStart(); | |
| if (!selectedMeetings) return; | |
| dialog.beginProcessing(); | |
| const supportedFormats = new Set(["txt", "md", "json"]); | |
| const selectedFormats = CONFIG.output.formats.filter((format) => supportedFormats.has(format)); | |
| if (selectedFormats.length === 0) selectedFormats.push("txt"); | |
| let completed = true; | |
| for (let index = 0; index < selectedMeetings.length; index += 1) { | |
| if (dialog.wasStopped()) { | |
| completed = false; | |
| break; | |
| } | |
| const selection = selectedMeetings[index]; | |
| dialog.setStatus(`Exporting ${index + 1}/${selectedMeetings.length}: ${selection.meeting.title}`, true); | |
| const combinedMessages = []; | |
| if (selection.includeChat) { | |
| const chatMessages = filterMessagesForMeetingTimeline(allChatMessages, selection.meeting); | |
| const finalizedChatMessages = finalizeMessagesForMeeting(chatMessages, selection.meeting); | |
| combinedMessages.push(...finalizedChatMessages); | |
| } | |
| if (selection.includeTranscript) { | |
| const transcriptMessages = await extractTranscriptForMeetingSelection(selection, shouldStopWork); | |
| if (dialog.wasStopped()) { | |
| completed = false; | |
| break; | |
| } | |
| const finalizedTranscriptMessages = finalizeMessagesForMeeting(transcriptMessages, selection.meeting); | |
| combinedMessages.push(...finalizedTranscriptMessages); | |
| } | |
| if (combinedMessages.length === 0) { | |
| warn(`No exportable messages found for ${selection.meeting.title}.`); | |
| continue; | |
| } | |
| const finalizedMessages = sortMessagesChronologically(combinedMessages, selection.meeting.startEpochMs); | |
| for (const format of selectedFormats) { | |
| if (format === "txt") exporter.download(builder.toTxt(selection.meeting, finalizedMessages), "txt", selection.meeting); | |
| if (format === "md") exporter.download(builder.toMd(selection.meeting, finalizedMessages), "md", selection.meeting); | |
| if (format === "json") exporter.download(builder.toJson(selection.meeting, finalizedMessages), "json", selection.meeting); | |
| } | |
| } | |
| if (!completed) { | |
| dialog.finish(false, "Process stopped. Already-downloaded files were kept."); | |
| return; | |
| } | |
| dialog.finish(true); | |
| log(`Download completed (${selectedMeetings.length} meeting(s), ${selectedFormats.join(", ")}).`); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment