Copilot Conversation Exporter

Export Microsoft Copilot conversations to Markdown or JSON (optionally includes AI Thoughts when present)

// ==UserScript==
// @name         Copilot Conversation Exporter
// @namespace    https://github.com/NoahTheGinger/Userscripts/
// @version      2.0.0
// @description  Export Microsoft Copilot conversations to Markdown or JSON (optionally includes AI Thoughts when present)
// @author       NoahTheGinger
// @match        https://copilot.microsoft.com/*
// @grant        none
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ==========
  // Config
  // ==========
  const ORIGIN = new URL(location.href).origin; // https://copilot.microsoft.com
  const API_BASE = `${ORIGIN}/c/api`;
  const API_VERSION = '2'; // history endpoint supports ?api-version=2

  // ==========
  // Auth capturing (best-effort)
  // ==========
  let capturedAuthToken = null;

  function tryCaptureAuthFromHeaders(headersLike) {
    try {
      if (!headersLike) return;
      const headers = headersLike instanceof Headers ? headersLike : new Headers(headersLike);
      const auth = headers.get('Authorization') || headers.get('authorization');
      if (auth && auth.startsWith('Bearer ')) {
        capturedAuthToken = auth.replace(/^Bearer\s+/, '');
      }
    } catch {}
  }

  // Patch fetch at document-start to sniff Authorization headers sent by the app
  (function patchFetch() {
    const origFetch = window.fetch;
    window.fetch = function patchedFetch(input, init) {
      try {
        if (init && init.headers) tryCaptureAuthFromHeaders(init.headers);
        if (input && typeof input === 'object' && input.headers) tryCaptureAuthFromHeaders(input.headers);
      } catch {}
      return origFetch.apply(this, arguments);
    };
  })();

  // Patch XMLHttpRequest to sniff Authorization headers
  (function patchXHR() {
    const origOpen = XMLHttpRequest.prototype.open;
    const origSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;

    XMLHttpRequest.prototype.open = function (method, url) {
      this._url = url;
      this._method = method;
      return origOpen.apply(this, arguments);
    };

    XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
      try {
        if (typeof name === 'string' && name.toLowerCase() === 'authorization' && value && value.startsWith('Bearer ')) {
          capturedAuthToken = value.replace(/^Bearer\s+/, '');
        }
      } catch {}
      return origSetRequestHeader.apply(this, arguments);
    };
  })();

  // ==========
  // Utilities
  // ==========
  function sanitizeFilename(title) {
    return title.replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').slice(0, 200);
  }

  function downloadFile(filename, mimeType, content) {
    const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.style.display = 'none';
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    }, 0);
  }

  function standardizeLineBreaks(text) {
    return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
  }

  function getCurrentTimestamp() {
    return new Date().toISOString().slice(0, 19).replace(/:/g, '-');
  }

  function getChatIdFromUrl() {
    // Expected: https://copilot.microsoft.com/chats/{chatId}
    const m = location.pathname.match(/^\/chats\/([A-Za-z0-9_-]+)/i);
    return m ? m[1] : null;
  }

  function getUILanguage() {
    return document.documentElement.getAttribute('lang') || navigator.language || 'en-US';
  }

  async function ensureSession() {
    // Lightly poke the start endpoint (this sometimes ensures cookies/session are initialized)
    try {
      await fetch(`${API_BASE}/start`, {
        method: 'POST',
        credentials: 'include'
      });
    } catch {}
  }

  async function fetchJSON(url, options = {}) {
    const opts = {
      method: 'GET',
      credentials: 'include',
      headers: {},
      ...options
    };

    opts.headers = new Headers(opts.headers);

    // Prefer captured Authorization token if available
    if (capturedAuthToken && !opts.headers.has('Authorization')) {
      opts.headers.set('Authorization', `Bearer ${capturedAuthToken}`);
    }

    // Send UI language if available (consistent with site)
    const lang = getUILanguage();
    if (!opts.headers.has('x-search-uilang') && lang) {
      opts.headers.set('x-search-uilang', lang.toLowerCase());
    }

    const res = await fetch(url, opts);
    if (!res.ok) {
      let detail = res.statusText || '';
      try {
        const data = await res.json();
        detail = data?.error || data?.message || JSON.stringify(data) || detail;
      } catch {}
      throw new Error(`Request failed (${res.status}): ${detail}`);
    }
    return res.json();
  }

  async function fetchConversationHistory(chatId) {
    if (!chatId) throw new Error('No chat ID found in URL');

    // Warm session first (harmless if already warm)
    await ensureSession();

    const url = `${API_BASE}/conversations/${encodeURIComponent(chatId)}/history?api-version=${API_VERSION}`;

    try {
      return await fetchJSON(url);
    } catch (err) {
      // Retry after warming session again (in case of race/expiry)
      await ensureSession();
      return await fetchJSON(url);
    }
  }

  async function fetchConversationsList() {
    const url = `${API_BASE}/conversations${API_VERSION ? `?api-version=${API_VERSION}` : ''}`;
    try {
      return await fetchJSON(url);
    } catch {
      return null;
    }
  }

  async function lookupConversationTitle(chatId) {
    if (!chatId) return 'Copilot Conversation';
    const list = await fetchConversationsList();
    // Expected shape: maybe { results: [ { id, title, ... }, ... ] } or just an array
    try {
      const items = Array.isArray(list?.results) ? list.results : (Array.isArray(list) ? list : []);
      const found = items.find(x => x?.id === chatId);
      return found?.title || 'Copilot Conversation';
    } catch {
      return 'Copilot Conversation';
    }
  }

  // ==========
  // Transform Copilot history -> Markdown
  // ==========
  function transformAuthor(author) {
    // In history JSON: author: { type: "ai" | "human" }
    if (!author || !author.type) return 'Unknown';
    return author.type === 'ai' ? 'Assistant' : author.type === 'human' ? 'User' : author.type;
  }

  function sortByCreatedAtAsc(results) {
    try {
      return [...results].sort((a, b) => {
        const at = new Date(a.createdAt).getTime() || 0;
        const bt = new Date(b.createdAt).getTime() || 0;
        return at - bt;
      });
    } catch {
      return results;
    }
  }

  function partsToMarkdown(parts) {
    if (!Array.isArray(parts)) return { body: '', thoughts: '' };

    const textBlocks = [];
    const imageBlocks = [];
    const citations = [];
    const thoughtsBlocks = [];

    for (const part of parts) {
      switch (part?.type) {
        case 'text':
          if (typeof part.text === 'string' && part.text.trim()) {
            textBlocks.push(part.text);
          }
          break;
        case 'image': {
          // Embed image, optionally include prompt as a caption line
          if (part.url) {
            const alt = 'image';
            imageBlocks.push(`![${alt}](${part.url})`);
            if (part.prompt && String(part.prompt).trim()) {
              imageBlocks.push(`*Prompt: ${part.prompt}*`);
            }
          }
          break;
        }
        case 'citation':
          // Collect for optional "Sources" listing; avoid exact duplicates
          if (part.url) {
            const key = `${part.title || part.url}::${part.url}`;
            if (!citations.some(c => c._k === key)) {
              citations.push({ _k: key, title: part.title || part.url, url: part.url });
            }
          }
          break;
        case 'chainOfThought':
          // Reasoning content (include only if present)
          if (typeof part.text === 'string' && part.text.trim()) {
            thoughtsBlocks.push(part.text);
          }
          if (part.screenshotUrl) {
            thoughtsBlocks.push(`![screenshot](${part.screenshotUrl})`);
          }
          break;
        default:
          // Ignore unsupported part types silently
          break;
      }
    }

    const sections = [];

    if (textBlocks.length) {
      sections.push(textBlocks.join('\n\n'));
    }

    if (imageBlocks.length) {
      sections.push(imageBlocks.join('\n\n'));
    }

    // If you prefer to show citations, uncomment the block below to include a sources list:
    // if (citations.length) {
    //   const list = citations.map(c => `- [${c.title}](${c.url})`).join('\n');
    //   sections.push(`#### Sources\n${list}`);
    // }

    const body = sections.filter(Boolean).join('\n\n');
    const thoughts = thoughtsBlocks.length ? thoughtsBlocks.join('\n\n') : '';

    return { body, thoughts };
  }

    // ----------------------------------------------------------
    //  Updated markdown conversion – thoughts come first
    // ----------------------------------------------------------
  function conversationToMarkdown({ title, results }) {
    // Ensure the messages are in chronological order
    const sorted = sortByCreatedAtAsc(results || []);

    const lines = [];

    // Title
    lines.push(`# ${title || 'Copilot Conversation'}`);
    lines.push(''); // blank line after title

    // Walk through each message
    for (const msg of sorted) {
      const authorLabel = transformAuthor(msg.author);
      const { body, thoughts } = partsToMarkdown(msg.content);

      const hasBody    = body    && body.trim().length   > 0;
      const hasThought = thoughts && thoughts.trim().length > 0;

      // Skip totally empty turns
      if (!hasBody && !hasThought) continue;

      // -----------------------------------------------------------------
      // Assistant – we want Thoughts *before* the answer
      // -----------------------------------------------------------------
      if (authorLabel === 'Assistant') {
        // 1. Thoughts (if any)
        if (hasThought) {
          lines.push('#### Thoughts');
          lines.push(thoughts);
          lines.push(''); // blank line after thoughts
        }

        // 2. Assistant answer (if any)
        if (hasBody) {
          lines.push(`#### ${authorLabel}:`);
          lines.push(body);
          lines.push(''); // blank line after answer
        }

        continue; // everything for this turn is done
      }

      // -----------------------------------------------------------------
      // Any other speaker (User, Tool, …) – keep the old order
      // -----------------------------------------------------------------
      lines.push(`#### ${authorLabel}:`);
      if (hasBody) lines.push(body);
      // (No “Thoughts” section for non‑assistant messages)
      lines.push(''); // blank line after the turn
    }

    // Trim trailing new‑lines but keep a final newline for nice file endings
    return lines.join('\n').trim() + '\n';
  }


  // ==========
  // Exporters
  // ==========
  async function exportToMarkdown() {
    try {
      const chatId = getChatIdFromUrl();
      if (!chatId) {
        alert('Open a conversation page first (URL should be /chats/{id}).');
        return;
      }

      console.log('[Copilot Exporter] Fetching history...');
      const history = await fetchConversationHistory(chatId);

      console.log('[Copilot Exporter] Looking up title...');
      const title = await lookupConversationTitle(chatId);

      console.log('[Copilot Exporter] Converting to Markdown...');
      const md = conversationToMarkdown({
        title: title || 'Copilot Conversation',
        results: Array.isArray(history?.results) ? history.results : []
      });

      const safeTitle = sanitizeFilename(title || 'Copilot Conversation');
      const timestamp = getCurrentTimestamp();
      const filename = `${safeTitle}_${timestamp}.md`;

      downloadFile(filename, 'text/markdown', standardizeLineBreaks(md));
      console.log('[Copilot Exporter] Markdown export complete.');
    } catch (err) {
      console.error('[Copilot Exporter] Markdown export failed:', err);
      alert(`Markdown export failed: ${err.message}`);
    }
  }

  async function exportToJSON() {
    try {
      const chatId = getChatIdFromUrl();
      if (!chatId) {
        alert('Open a conversation page first (URL should be /chats/{id}).');
        return;
      }

      console.log('[Copilot Exporter] Fetching history (JSON)...');
      const history = await fetchConversationHistory(chatId);

      console.log('[Copilot Exporter] Looking up title...');
      const title = await lookupConversationTitle(chatId);

      const jsonContent = JSON.stringify(history, null, 2);
      const safeTitle = sanitizeFilename(title || 'Copilot Conversation');
      const timestamp = getCurrentTimestamp();
      const filename = `${safeTitle}_${timestamp}.json`;

      downloadFile(filename, 'application/json', jsonContent);
      console.log('[Copilot Exporter] JSON export complete.');
    } catch (err) {
      console.error('[Copilot Exporter] JSON export failed:', err);
      alert(`JSON export failed: ${err.message}`);
    }
  }

  // ==========
  // UI (Export button + modal)
  // ==========
  function showExportDialog() {
    const modal = document.createElement('div');
    modal.style.cssText = `
      position: fixed; inset: 0;
      background: rgba(0,0,0,0.5);
      z-index: 2147483647;
      display: flex; align-items: center; justify-content: center;
    `;

    const dialog = document.createElement('div');
    dialog.style.cssText = `
      background: white;
      border-radius: 8px;
      padding: 24px;
      min-width: 300px;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
    `;

    dialog.innerHTML = `
      <h3 style="margin:0 0 16px 0; color:#333; font-size:18px;">Choose Export Format</h3>
      <p style="margin:0 0 20px 0; color:#666; font-size:14px;">Select the format you'd like to export this conversation in:</p>
      <div style="display:flex; gap:12px; justify-content:flex-end;">
        <button id="export-markdown-btn" style="
          background:#10a37f; color:white; border:none; border-radius:6px;
          padding:8px 16px; font-size:14px; cursor:pointer;
        ">Markdown (.md)</button>
        <button id="export-json-btn" style="
          background:#2563eb; color:white; border:none; border-radius:6px;
          padding:8px 16px; font-size:14px; cursor:pointer;
        ">JSON (.json)</button>
        <button id="export-cancel-btn" style="
          background:#6b7280; color:white; border:none; border-radius:6px;
          padding:8px 16px; font-size:14px; cursor:pointer;
        ">Cancel</button>
      </div>
    `;

    modal.appendChild(dialog);
    document.body.appendChild(modal);

    const markdownBtn = dialog.querySelector('#export-markdown-btn');
    const jsonBtn = dialog.querySelector('#export-json-btn');
    const cancelBtn = dialog.querySelector('#export-cancel-btn');

    markdownBtn.addEventListener('mouseenter', () => markdownBtn.style.background = '#0d9568');
    markdownBtn.addEventListener('mouseleave', () => markdownBtn.style.background = '#10a37f');

    jsonBtn.addEventListener('mouseenter', () => jsonBtn.style.background = '#1d4ed8');
    jsonBtn.addEventListener('mouseleave', () => jsonBtn.style.background = '#2563eb');

    cancelBtn.addEventListener('mouseenter', () => cancelBtn.style.background = '#4b5563');
    cancelBtn.addEventListener('mouseleave', () => cancelBtn.style.background = '#6b7280');

    markdownBtn.addEventListener('click', () => {
      document.body.removeChild(modal);
      exportToMarkdown();
    });
    jsonBtn.addEventListener('click', () => {
      document.body.removeChild(modal);
      exportToJSON();
    });
    cancelBtn.addEventListener('click', () => document.body.removeChild(modal));

    const onBgClick = (e) => {
      if (e.target === modal) {
        document.body.removeChild(modal);
        window.removeEventListener('click', onBgClick);
      }
    };
    window.addEventListener('click', onBgClick);

    const handleEscape = (e) => {
      if (e.key === 'Escape') {
        document.body.removeChild(modal);
        document.removeEventListener('keydown', handleEscape);
      }
    };
    document.addEventListener('keydown', handleEscape);
  }

  function createExportButton() {
    const btn = document.createElement('button');
    btn.id = 'copilot-export-btn';
    btn.textContent = 'Export';
    btn.title = 'Export conversation to Markdown or JSON';
    btn.style.cssText = `
      position: fixed; bottom: 20px; right: 20px;
      z-index: 2147483646;
      background: #10a37f; color: white;
      border: none; border-radius: 6px;
      padding: 8px 12px; font-size: 14px; font-weight: 500;
      cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    `;

    btn.addEventListener('mouseenter', () => btn.style.background = '#0d9568');
    btn.addEventListener('mouseleave', () => btn.style.background = '#10a37f');
    btn.addEventListener('click', showExportDialog);
    return btn;
  }

  function addButton() {
    try {
      const existing = document.getElementById('copilot-export-btn');
      if (existing) existing.remove();
      const btn = createExportButton();
      document.body.appendChild(btn);
    } catch (e) {
      console.warn('[Copilot Exporter] Unable to add button yet. Will retry.');
    }
  }

  function ready(fn) {
    if (document.readyState !== 'loading') {
      fn();
    } else {
      document.addEventListener('DOMContentLoaded', fn);
    }
  }

  // ==========
  // Init
  // ==========
  ready(addButton);

  // Re-inject button on SPA navigations
  let lastHref = location.href;
  setInterval(() => {
    if (location.href !== lastHref) {
      lastHref = location.href;
      setTimeout(addButton, 800);
    }
  }, 800);

})();