Adds three export buttons to Zoom Docs pages (Transcript only, Summary only, or Both) that save an Obsidian-ready Markdown file with meeting title, date, time and attendees in the frontmatter
// ==UserScript==
// @name Zoom Docs Summary/Transcript Exporter
// @namespace zoom-transcript-exporter
// @version 4.1
// @description Adds three export buttons to Zoom Docs pages (Transcript only, Summary only, or Both) that save an Obsidian-ready Markdown file with meeting title, date, time and attendees in the frontmatter
// @match https://hub.zoom.us/doc/*
// @match https://docs.zoom.us/doc/*
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// Zoom Docs titles look like "Paul / Avery Connect 2026-07-23 09:00(GMT+10:00)".
// Strip a leading emoji (Zoom prefixes some doc titles with one) and split off
// the trailing date/time/timezone that Zoom appends automatically.
const TITLE_RE = /^(.*?)\s+(\d{4}-\d{2}-\d{2})\s+(\d{1,2}:\d{2})\(GMT([+-]\d{1,2}(?::\d{2})?)\)\s*$/;
function extractMeetingMeta() {
const raw = (document.querySelector('.zm-page-title-content')?.textContent || document.title || '')
.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '')
.trim();
const match = raw.match(TITLE_RE);
if (match) {
const [, title, date, time, tz] = match;
return { title: title.trim(), date, time, timezone: `GMT${tz}` };
}
// Fall back to the raw title with no parsed date/time rather than guessing.
return { title: raw, date: null, time: null, timezone: null };
}
function extractAttendees() {
const seen = new Set();
document.querySelectorAll('.zm-doc-avatar[aria-label]').forEach((el) => {
seen.add(el.getAttribute('aria-label').trim());
});
return Array.from(seen);
}
function extractSummaryBlocks() {
const container = document.querySelector('.editor-block-children-container');
if (!container) return [];
const blocks = [];
Array.from(container.children).forEach((el) => {
const type = el.getAttribute('data-block-type');
// Citation markers (footnote-style numbers linking back to the transcript)
// render as bare digits wrapped in zero-width characters — strip them
// before reading text so they don't show up as stray numbers.
const clone = el.cloneNode(true);
clone.querySelectorAll('.zm-citation').forEach((c) => c.remove());
const text = clone.textContent.replace(/[\u200B\uFEFF]/g, '').trim();
if (!text) return;
if (type === 'BLOCK_TYPE_HEADING2') {
blocks.push({ type: 'heading', text });
} else if (type === 'BLOCK_TYPE_BULLET') {
blocks.push({ type: 'bullet', text: text.replace(/^•\s*/, '') });
} else {
blocks.push({ type: 'paragraph', text });
}
});
return blocks;
}
function buildSummaryMarkdown(blocks) {
const lines = [];
blocks.forEach((b, i) => {
const prev = blocks[i - 1];
if (i > 0 && !(b.type === 'bullet' && prev.type === 'bullet')) lines.push('');
if (b.type === 'heading') lines.push(`## ${b.text}`);
else if (b.type === 'bullet') lines.push(`- ${b.text}`);
else lines.push(b.text);
});
return lines.join('\n');
}
function wrapInCallout(title, body, { collapsed = true } = {}) {
const marker = collapsed ? '-' : '+';
const quoted = body.split('\n').map((line) => (line ? `> ${line}` : '>'));
return [`> [!note]${marker} ${title}`, ...quoted].join('\n');
}
function extractTranscript() {
const timeRe = /^\d{1,2}:\d{2}:\d{2}$/;
const spans = document.querySelectorAll('span');
const messages = [];
spans.forEach((span) => {
const txt = span.textContent.trim();
if (!timeRe.test(txt)) return;
const header = span.parentElement;
if (!header) return;
const messageDiv = header.parentElement;
if (!messageDiv) return;
let speaker = '';
for (const child of header.children) {
if (child === span) continue;
const t = child.textContent.trim();
if (t) speaker = t;
}
let text = '';
for (const child of messageDiv.children) {
if (child === header) continue;
const t = child.textContent.trim();
if (t) text += (text ? '\n' : '') + t;
}
if (speaker || text) {
messages.push({ speaker, time: txt, text });
}
});
return messages;
}
function yamlString(value) {
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
function buildFrontmatter(meta, attendees) {
const lines = ['---', `title: ${yamlString(meta.title)}`];
if (meta.date) lines.push(`date: ${meta.date}`);
if (meta.time) lines.push(`time: ${yamlString(meta.time)}`);
if (meta.timezone) lines.push(`timezone: ${yamlString(meta.timezone)}`);
lines.push('attendees:');
attendees.forEach((name) => lines.push(` - ${yamlString(name)}`));
lines.push(`source: ${yamlString(location.href)}`);
lines.push('tags:', ' - zoom-transcript');
lines.push('---', '');
return lines.join('\n');
}
function buildBody(messages) {
return messages
.map((m) => `**${m.speaker}** · ${m.time}\n${m.text}`)
.join('\n\n');
}
function buildFilename(meta, suffix) {
const namePart = meta.title || document.title || 'transcript';
const datePart = meta.date ? ` ${meta.date}` : '';
const suffixPart = suffix ? ` (${suffix})` : '';
return `${namePart}${datePart}${suffixPart}`.replace(/[\/:]/g, '_') + '.md';
}
function downloadMarkdown(content, filename) {
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function exportTranscriptOnly() {
const messages = extractTranscript();
if (!messages.length) {
alert('No transcript found on this page. Make sure the Transcript panel is open before exporting.');
return;
}
const meta = extractMeetingMeta();
const attendees = extractAttendees();
const content = [buildFrontmatter(meta, attendees).trim(), buildBody(messages)].join('\n\n') + '\n';
downloadMarkdown(content, buildFilename(meta, 'Transcript'));
}
function exportSummaryOnly() {
const summaryBlocks = extractSummaryBlocks();
if (!summaryBlocks.length) {
alert('No AI summary found on this page.');
return;
}
const meta = extractMeetingMeta();
const attendees = extractAttendees();
const content = [buildFrontmatter(meta, attendees).trim(), buildSummaryMarkdown(summaryBlocks)].join('\n\n') + '\n';
downloadMarkdown(content, buildFilename(meta, 'Summary'));
}
function exportBoth() {
const messages = extractTranscript();
const summaryBlocks = extractSummaryBlocks();
if (!messages.length && !summaryBlocks.length) {
alert('No summary or transcript found on this page.');
return;
}
const meta = extractMeetingMeta();
const attendees = extractAttendees();
const sections = [buildFrontmatter(meta, attendees).trim()];
if (summaryBlocks.length) sections.push(buildSummaryMarkdown(summaryBlocks));
if (messages.length) sections.push(wrapInCallout('Transcript', buildBody(messages)));
const content = sections.join('\n\n') + '\n';
downloadMarkdown(content, buildFilename(meta));
}
function addButtons() {
if (document.getElementById('__transcript_export_container')) return;
const container = document.createElement('div');
container.id = '__transcript_export_container';
Object.assign(container.style, {
position: 'fixed',
top: '10px',
right: '10px',
zIndex: 999999,
display: 'flex',
flexDirection: 'column',
gap: '6px',
});
const buttonStyle = {
padding: '10px 14px',
background: '#2D8CFF',
color: '#fff',
border: 'none',
borderRadius: '4px',
fontSize: '14px',
cursor: 'pointer',
textAlign: 'left',
};
const buttons = [
{ label: 'Export Transcript (.md)', handler: exportTranscriptOnly },
{ label: 'Export Summary (.md)', handler: exportSummaryOnly },
{ label: 'Export Both (.md)', handler: exportBoth },
];
buttons.forEach(({ label, handler }) => {
const btn = document.createElement('button');
btn.textContent = label;
Object.assign(btn.style, buttonStyle);
btn.addEventListener('click', handler);
container.appendChild(btn);
});
document.body.appendChild(container);
}
window.addEventListener('load', addButtons);
// in case the page is already loaded when the script runs
if (document.readyState === 'complete') addButtons();
})();