Zoom Docs Summary/Transcript Exporter

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

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==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();
})();