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

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

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