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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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