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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

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

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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