M365 Copilot Chat Exporter

Export Microsoft 365 Copilot conversations as ChatGPT-compatible conversations.json

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         M365 Copilot Chat Exporter
// @namespace    https://github.com/ingo/m365-copilot-chat-exporter
// @version      4.4
// @description  Export Microsoft 365 Copilot conversations as ChatGPT-compatible conversations.json
// @license      MIT
// @author       ingo
// @match        https://m365.cloud.microsoft/
// @match        https://m365.cloud.microsoft/chat*
// @match        https://microsoft365.com/chat*
// @match        https://www.microsoft365.com/chat*
// @icon         data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%23a855f7'/%3E%3Cstop offset='100%25' stop-color='%236366f1'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='url(%23g)'/%3E%3Cpath d='M9 11h14M9 16h10M9 21h12' stroke='white' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M22 18l3 3-3 3' stroke='%2322d3ee' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {
  "use strict";

  // ── State ──────────────────────────────────────────────────────────

  const conversations = new Map();
  const rawCaptures = [];
  let isFetchingAll = false;

  const SKIP_MESSAGE_TYPES = new Set([
    "CrossPluginGroundingData",
    "Internal",
    "InternalSuggestions",
    "InternalLoaderMessage",
    "InternalSearchResult",
    "InternalSearchQuery",
    "Suggestion",
    "RenderCardRequest",
    "GenerateContentQuery",
    "AdsQuery",
  ]);

  // ── Text sanitization ──────────────────────────────────────────────

  /**
   * Sanitize text to ensure it doesn't contain problematic control characters
   * that could break JSON encoding. Removes ASCII control characters (0-31)
   * and replaces newlines/tabs with spaces to avoid JSON encoding issues.
   */
  function sanitizeText(text) {
    if (!text) return text;
    let result = "";
    for (let i = 0; i < text.length; i++) {
      const char = text[i];
      const code = text.charCodeAt(i);
      
      if (code === 0x2028 || code === 0x2029) {
        // Replace Unicode line/paragraph separators with space
        result += " ";
      } else if (code >= 32) {
        // Keep printable characters
        result += char;
      } else if (code === 10 || code === 13) {
        // Replace newlines and carriage returns with space
        result += " ";
      } else if (code === 9) {
        // Replace tabs with space
        result += " ";
      }
      // Skip other control characters (0-8, 11-12, 14-31)
    }
    return result;
  }

  // ── Date range helpers ──────────────────────────────────────────

  /**
   * Returns { from: Date, to: Date } for the selected date range preset,
   * or null if "all" is selected.  Timestamps are midnight-based in local tz.
   */
  function getDateRange() {
    const sel = document.getElementById("copilot-date-range");
    if (!sel) return null;
    const preset = sel.value;
    if (preset === "all") return null;

    const now = new Date();
    const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
    const tomorrow = new Date(today);
    tomorrow.setDate(tomorrow.getDate() + 1);

    if (preset === "custom") {
      const fromEl = document.getElementById("copilot-date-from");
      const toEl = document.getElementById("copilot-date-to");
      const from = fromEl?.value ? new Date(fromEl.value) : null;
      const to = toEl?.value ? new Date(toEl.value) : null;
      if (!from && !to) return null;
      // "to" is inclusive: advance to the next day
      const toEnd = to ? new Date(to.getTime() + 86400000) : tomorrow;
      return { from: from || new Date(0), to: toEnd };
    }

    let from;
    switch (preset) {
      case "today":
        from = today;
        break;
      case "week": {
        from = new Date(today);
        from.setDate(from.getDate() - 7);
        break;
      }
      case "month": {
        from = new Date(today);
        from.setMonth(from.getMonth() - 1);
        break;
      }
      case "year": {
        from = new Date(today);
        from.setFullYear(from.getFullYear() - 1);
        break;
      }
      default:
        return null;
    }
    return { from, to: tomorrow };
  }

  /**
   * Check whether a conversation falls within the active date range.
   * createTimeUtc from the Substrate API is in Unix milliseconds.
   */
  function isInDateRange(conv, range) {
    if (!range) return true;
    const ts = conv.createTimeUtc;
    if (!ts) return true; // keep conversations with unknown dates
    const d = new Date(typeof ts === "number" && ts > 1e12 ? ts : ts);
    return d >= range.from && d < range.to;
  }

  const SUBSTRATE_BASE = "https://substrate.office.com/m365Copilot";
  const DEFAULT_VARIANTS =
    "feature.EnableLastMessageForGetChats,feature.EnableMRUAgents,feature.EnableHasLoopPages,feature.EnableIsInputControlInGptItem";

  // ── MSAL token extraction (adapted from ganyuke/copilot-exporter) ─

  const getCookie = (key) =>
    document.cookie.match(`(^|;)\\s*${key}\\s*=\\s*([^;]+)`)?.pop() || "";

  function base64DecToArr(base64String) {
    let s = base64String.replace(/-/g, "+").replace(/_/g, "/");
    switch (s.length % 4) {
      case 2: s += "=="; break;
      case 3: s += "="; break;
    }
    const bin = atob(s);
    return Uint8Array.from(bin, (c) => c.codePointAt(0) || 0);
  }

  function toArrayBuffer(bufferLike) {
    return Uint8Array.from(bufferLike).buffer;
  }

  async function deriveKey(baseKey, nonce, context) {
    return crypto.subtle.deriveKey(
      { name: "HKDF", salt: toArrayBuffer(nonce), hash: "SHA-256", info: new TextEncoder().encode(context) },
      baseKey,
      { name: "AES-GCM", length: 256 },
      false,
      ["encrypt", "decrypt"]
    );
  }

  async function decryptPayload(baseKey, nonce, context, encryptedData) {
    const encoded = base64DecToArr(encryptedData);
    const derived = await deriveKey(baseKey, base64DecToArr(nonce), context);
    const decrypted = await crypto.subtle.decrypt(
      { name: "AES-GCM", iv: new Uint8Array(12) },
      derived,
      toArrayBuffer(encoded)
    );
    return new TextDecoder().decode(decrypted);
  }

  async function getEncryptionCookie() {
    const raw = decodeURIComponent(getCookie("msal.cache.encryption"));
    let parsed;
    try { parsed = JSON.parse(raw); } catch { throw new Error("Failed to parse msal.cache.encryption cookie"); }
    if (!parsed?.key || !parsed?.id) throw new Error("No encryption cookie found");
    return {
      id: parsed.id,
      key: await crypto.subtle.importKey("raw", toArrayBuffer(base64DecToArr(parsed.key)), "HKDF", false, ["deriveKey"]),
    };
  }

  function getMsalIds() {
    const el = document.getElementById("identity");
    if (!el?.textContent) throw new Error("Missing #identity element in page");
    const { objectId, tenantId } = JSON.parse(el.textContent);
    return {
      localAccountId: objectId,
      tenantId,
      homeAccountId: `${objectId}.${tenantId}`,
      clientId: "c0ab8ce9-e9a0-42e7-b064-33d422df41f1",
    };
  }

  async function getAccessToken(msalIds) {
    const cookie = await getEncryptionCookie();
    const { homeAccountId, tenantId, clientId } = msalIds;
    const scopes = ["https://substrate.office.com/sydney/.default"];
    const lsKey = `${homeAccountId}-login.windows.net-accesstoken-${clientId}-${tenantId}-${scopes.join(" ")}--`;
    const stored = localStorage.getItem(lsKey);
    if (!stored) throw new Error("Missing MSAL access token in localStorage");
    const payload = JSON.parse(stored);
    const decrypted = await decryptPayload(cookie.key, payload.nonce, clientId, payload.data);
    return JSON.parse(decrypted).secret;
  }

  async function getTokenAndIds() {
    const msalIds = getMsalIds();
    const token = await getAccessToken(msalIds);
    return { token, ...msalIds };
  }

  // ── Substrate API handlers ─────────────────────────────────────────

  function handleGetConversation(data) {
    const convId = data.conversationId;
    if (!convId) return;

    const visibleMessages = (data.messages || []).filter((m) => {
      if (SKIP_MESSAGE_TYPES.has(m.messageType)) return false;
      if (m.author === "system") return false;
      if (!m.text && !m.adaptiveCards?.length) return false;
      return true;
    });

    if (visibleMessages.length === 0) return;

    conversations.set(convId, {
      conversationId: convId,
      chatName: data.chatName || "",
      createTimeUtc: data.createTimeUtc,
      updateTimeUtc: data.updateTimeUtc,
      tone: data.tone || "",
      isLegacyWebChat: data.isLegacyWebChat || false,
      messages: visibleMessages,
    });

    updateBadge();
  }

  function handleGetChats(data) {
    const chats = data.chats || [];
    for (const chat of chats) {
      const convId = chat.conversationId;
      if (!convId) continue;
      if (!conversations.has(convId)) {
        conversations.set(convId, {
          conversationId: convId,
          chatName: chat.chatName || "",
          createTimeUtc: chat.createTimeUtc,
          updateTimeUtc: chat.updateTimeUtc,
          tone: chat.tone || "",
          isLegacyWebChat: chat.isLegacyWebChat || false,
          messages: [],
        });
      }
    }
    console.log(
      `[Copilot Export] Chat list: ${chats.length} conversations (${conversations.size} total known)`
    );
    updateBadge();
    return data;
  }

  // ── Passive response interceptors (for badge + raw export) ────────

  const originalFetch = window.fetch;

  window.fetch = async function (...args) {
    const url = typeof args[0] === "string" ? args[0] : args[0]?.url || "";
    const response = await originalFetch.apply(this, args);

    if (!url.includes("substrate.office.com") && !url.includes("m365.cloud.microsoft")) {
      return response;
    }

    const clone = response.clone();
    clone.text().then((text) => {
      if (!text || text.length < 20) return;
      try {
        const json = JSON.parse(text);
        rawCaptures.push({
          url: url.substring(0, 500),
          status: response.status,
          timestamp: new Date().toISOString(),
          byteLength: text.length,
          data: json,
        });
        if (url.includes("GetConversation")) handleGetConversation(json);
        else if (url.includes("GetChats")) handleGetChats(json);
      } catch { /* not JSON */ }
    }).catch(() => {});

    return response;
  };

  const origXHROpen = XMLHttpRequest.prototype.open;
  const origXHRSend = XMLHttpRequest.prototype.send;

  XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this._captureUrl = url;
    return origXHROpen.call(this, method, url, ...rest);
  };

  XMLHttpRequest.prototype.send = function (...args) {
    this.addEventListener("load", function () {
      const url = this._captureUrl || "";
      if (!url.includes("substrate.office.com") && !url.includes("m365.cloud.microsoft")) return;
      try {
        const json = JSON.parse(this.responseText);
        rawCaptures.push({
          url: url.substring(0, 500),
          status: this.status,
          timestamp: new Date().toISOString(),
          byteLength: this.responseText.length,
          data: json,
        });
        if (url.includes("GetConversation")) handleGetConversation(json);
        else if (url.includes("GetChats")) handleGetChats(json);
      } catch { /* not JSON */ }
    });
    return origXHRSend.apply(this, args);
  };

  // ── Fetch All automation ──────────────────────────────────────────

  function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  function setStatus(text) {
    const el = document.getElementById("copilot-export-status");
    if (el) el.textContent = text;
  }

  /**
   * Call a Substrate endpoint with proper auth headers.
   */
  async function substrateGet(auth, endpoint, params, includeVariants) {
    const requestJson = JSON.stringify(params);
    const variantsSuffix = includeVariants
      ? `&variants=${encodeURIComponent(DEFAULT_VARIANTS)}`
      : "";
    const url = `${SUBSTRATE_BASE}/${endpoint}?request=${encodeURIComponent(requestJson)}${variantsSuffix}`;

    const headers = {
      authorization: `Bearer ${auth.token}`,
      "content-type": "application/json",
      "x-anchormailbox": `Oid:${auth.localAccountId}@${auth.tenantId}`,
      "x-clientrequestid": crypto.randomUUID().replace(/-/g, ""),
      "x-routingparameter-sessionkey": auth.localAccountId,
      "x-scenario": "OfficeWebIncludedCopilot",
    };

    const resp = await fetch(url, { method: "GET", headers });

    if (!resp.ok) {
      throw new Error(`${endpoint} returned ${resp.status}`);
    }
    return resp.json();
  }

  /**
   * Fetch all chat IDs by paginating through GetChats.
   */
  async function fetchAllChatIds(auth) {
    const allChats = [];
    let syncState = null;
    let page = 0;

    while (true) {
      page++;
      setStatus(`Fetching chat list page ${page}...`);

      const params = {
        source: "officeweb",
        traceId: crypto.randomUUID(),
        threadType: "bizchat",
        MaxReturnedChatsCount: 50,
        mergeWorkWebChats: true,
        includeChatsWithHarmfulContentProtectionDisabled: true,
      };
      if (syncState) {
        params.syncState = syncState;
      }

      const data = await substrateGet(auth, "GetChats", params, true);
      const chats = data.chats || [];
      allChats.push(...chats);
      handleGetChats(data);

      console.log(
        `[Copilot Export] GetChats page ${page}: ${chats.length} chats (${allChats.length} total)`
      );

      syncState = data.syncState || null;
      if (chats.length === 0 || !syncState) break;

      await sleep(500);
    }

    return allChats;
  }

  /**
   * Fetch full conversation content for a single chat.
   */
  async function fetchConversation(auth, conversationId) {
    const data = await substrateGet(auth, "GetConversation", {
      conversationId,
      source: "officeweb",
      traceId: crypto.randomUUID().replace(/-/g, ""),
    }, false);
    handleGetConversation(data);
    return data;
  }

  /**
   * Main "Fetch All" workflow.
   */
  async function doFetchAll() {
    if (isFetchingAll) return;
    isFetchingAll = true;
    const btn = document.getElementById("copilot-btn-fetchall");
    if (btn) { btn.disabled = true; btn.textContent = "Fetching..."; }

    try {
      setStatus("Acquiring auth token...");
      const auth = await getTokenAndIds();
      console.log(`[Copilot Export] Auth acquired for ${auth.localAccountId}`);

      // Step 1: Get all chat IDs
      const allChats = await fetchAllChatIds(auth);
      console.log(`[Copilot Export] Found ${allChats.length} total conversations`);

      // Step 1b: Apply date range filter
      const range = getDateRange();
      const filteredChats = range
        ? allChats.filter((c) => isInDateRange(c, range))
        : allChats;

      if (range) {
        console.log(`[Copilot Export] Date filter: ${filteredChats.length}/${allChats.length} conversations in range`);
        setStatus(`${filteredChats.length} of ${allChats.length} conversations match date filter`);
        await sleep(800);
      }

      // Step 2: Fetch each conversation that we don't already have messages for
      const toFetch = filteredChats.filter((c) => {
        const existing = conversations.get(c.conversationId);
        return !existing || existing.messages.length === 0;
      });

      console.log(`[Copilot Export] Need to fetch ${toFetch.length} conversations (${filteredChats.length - toFetch.length} already loaded)`);

      let fetched = 0;
      let errors = 0;

      for (const chat of toFetch) {
        fetched++;
        setStatus(`Fetching ${fetched}/${toFetch.length}: ${(chat.chatName || "").substring(0, 40)}...`);

        try {
          await fetchConversation(auth, chat.conversationId);
        } catch (e) {
          errors++;
          console.warn(`[Copilot Export] Failed to fetch ${chat.conversationId}: ${e.message}`);
        }

        await sleep(500);
        updateBadge();
      }

      const withMessages = Array.from(conversations.values()).filter(
        (c) => c.messages && c.messages.length > 0
      );
      setStatus(
        `Done! ${withMessages.length} conversations loaded` +
          (errors > 0 ? ` (${errors} errors)` : "")
      );
    } catch (e) {
      setStatus(`Error: ${e.message}`);
      console.error("[Copilot Export] Fetch all failed:", e);
    } finally {
      isFetchingAll = false;
      if (btn) { btn.disabled = false; btn.textContent = "Fetch All Conversations"; }
    }
  }

  // ── ChatGPT format converter ──────────────────────────────────────

  function toUnixSeconds(ts) {
    if (!ts) return null;
    if (typeof ts === "number") return ts > 1e12 ? ts / 1000 : ts;
    try { return new Date(ts).getTime() / 1000; } catch { return null; }
  }

  function buildConversationsJson(range) {
    const output = [];

    for (const [convId, conv] of conversations) {
      if (!conv.messages || conv.messages.length === 0) continue;
      if (!isInDateRange(conv, range)) continue;

      const firstTs =
        toUnixSeconds(conv.createTimeUtc) ||
        toUnixSeconds(conv.messages[0]?.createdAt);
      const lastTs =
        toUnixSeconds(conv.updateTimeUtc) ||
        toUnixSeconds(conv.messages[conv.messages.length - 1]?.createdAt);

      const mapping = {};

      const rootId = "client-created-root";
      mapping[rootId] = { id: rootId, message: null, parent: null, children: [] };

      const systemId = crypto.randomUUID();
      mapping[systemId] = {
        id: systemId,
        message: {
          id: systemId,
          author: { role: "system", name: null, metadata: {} },
          create_time: firstTs,
          update_time: null,
          content: { content_type: "text", parts: [""] },
          status: "finished_successfully",
          end_turn: true,
          weight: 1.0,
          metadata: {},
          recipient: "all",
          channel: null,
        },
        parent: rootId,
        children: [],
      };
      mapping[rootId].children.push(systemId);

      let prevId = systemId;
      let title = sanitizeText(conv.chatName) || null;

      for (const msg of conv.messages) {
        const created = toUnixSeconds(msg.createdAt);
        const text = sanitizeText(msg.text || "");

        let role;
        if (msg.author === "user") {
          role = "user";
          if (!title && text) title = text.substring(0, 100).trim();
        } else if (msg.author === "bot") {
          role = "assistant";
        } else {
          continue;
        }

        const nodeId = msg.messageId || crypto.randomUUID();

        mapping[nodeId] = {
          id: nodeId,
          message: {
            id: nodeId,
            author: { role, name: null, metadata: {} },
            create_time: created,
            update_time: null,
            content: { content_type: "text", parts: [text] },
            status: "finished_successfully",
            end_turn: role === "assistant",
            weight: 1.0,
            metadata: {
              copilot_app_class: msg.contentOrigin || "",
              copilot_session_id: convId,
              copilot_message_id: msg.messageId || "",
              copilot_request_id: msg.requestId || "",
            },
            recipient: "all",
            channel: null,
          },
          parent: prevId,
          children: [],
        };

        mapping[prevId].children.push(nodeId);
        prevId = nodeId;
      }

      output.push({
        title: title || "Copilot Chat",
        create_time: firstTs,
        update_time: lastTs,
        mapping,
        moderation_results: [],
        current_node: prevId,
        plugin_ids: null,
        conversation_id: convId,
        conversation_template_id: null,
        gizmo_id: null,
        gizmo_type: null,
        is_archived: false,
        is_starred: null,
        safe_urls: [],
        blocked_urls: [],
        default_model_slug: "copilot",
        conversation_origin: null,
        is_read_only: null,
        voice: null,
        async_status: null,
        disabled_tool_ids: [],
        is_do_not_remember: false,
        memory_scope: null,
        context_scopes: null,
        sugar_item_id: null,
        sugar_item_visible: false,
        pinned_time: null,
        is_study_mode: false,
        owner: null,
        id: convId,
      });
    }

    output.sort((a, b) => (b.create_time || 0) - (a.create_time || 0));
    return output;
  }

  // ── Download helpers ───────────────────────────────────────────────

  function downloadJson(data, filename) {
    const jsonStr = JSON.stringify(data, null, 2);
    const blob = new Blob([jsonStr], { type: "application/json" });
    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 doExportConverted() {
    const range = getDateRange();
    const withMessages = Array.from(conversations.values()).filter(
      (c) => c.messages && c.messages.length > 0 && isInDateRange(c, range)
    );

    if (withMessages.length === 0) {
      alert(
        range
          ? "No conversations with content in the selected date range.\n\nTry a wider date range or use Fetch All first."
          : "No conversation content captured yet.\n\n" +
              'Use "Fetch All Conversations" to load everything first.'
      );
      return;
    }

    const result = buildConversationsJson(range);
    const totalMsgs = withMessages.reduce((s, c) => s + c.messages.length, 0);

    downloadJson(
      { conversations: result },
      `copilot_conversations_${new Date().toISOString().slice(0, 10)}.json`
    );

    console.log(
      `[Copilot Export] Exported ${result.length} conversations with ${totalMsgs} messages`
    );
  }

  function doExportRaw() {
    if (rawCaptures.length === 0) {
      alert("No API responses captured yet.");
      return;
    }
    downloadJson(
      rawCaptures,
      `copilot_raw_capture_${new Date().toISOString().slice(0, 10)}.json`
    );
  }

  // ── Floating UI ───────────────────────────────────────────────────

  function updateBadge() {
    const badge = document.getElementById("copilot-export-badge");
    if (badge) {
      const withMessages = Array.from(conversations.values()).filter(
        (c) => c.messages && c.messages.length > 0
      );
      const totalMsgs = withMessages.reduce((s, c) => s + c.messages.length, 0);
      const pending = conversations.size - withMessages.length;

      let text = `${withMessages.length} chats / ${totalMsgs} msgs captured`;
      if (pending > 0) text += ` (${pending} not yet loaded)`;
      badge.textContent = text;
    }
  }

  function createUI() {
    const container = document.createElement("div");
    container.id = "copilot-export-ui";
    container.innerHTML = `
      <style>
        #copilot-export-ui {
          position: fixed;
          bottom: 16px;
          right: 16px;
          z-index: 999999;
          font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
          font-size: 13px;
        }
        #copilot-export-title-row {
          display: flex;
          align-items: center;
          justify-content: space-between;
          margin-bottom: 8px;
        }
        #copilot-export-minimize-btn {
          background: transparent;
          border: none;
          color: #555;
          cursor: pointer;
          font-size: 16px;
          padding: 0 0 0 8px;
          line-height: 1;
          opacity: 0.5;
          transition: opacity 0.15s;
        }
        #copilot-export-minimize-btn:hover {
          opacity: 1;
          color: #9d8aff;
        }
        #copilot-export-icon {
          display: none;
          width: 48px;
          height: 48px;
          background: linear-gradient(135deg, #a855f7 0%, #6366f1 100%);
          border-radius: 50%;
          cursor: pointer;
          box-shadow: 0 4px 20px rgba(0,0,0,0.4);
          align-items: center;
          justify-content: center;
          font-size: 24px;
          color: white;
          transition: transform 0.2s;
        }
        #copilot-export-icon:hover {
          transform: scale(1.1);
        }
        #copilot-export-ui.minimized #copilot-export-panel {
          display: none;
        }
        #copilot-export-ui.minimized #copilot-export-icon {
          display: flex;
        }
        #copilot-export-panel {
          background: #1a1a2e;
          color: #e0e0e0;
          border-radius: 10px;
          padding: 12px 16px;
          box-shadow: 0 4px 20px rgba(0,0,0,0.4);
          min-width: 260px;
        }
        #copilot-export-panel .title {
          font-weight: 600;
          font-size: 13px;
          color: #9d8aff;
        }
        #copilot-export-badge {
          font-size: 12px;
          color: #8be9fd;
          margin-bottom: 6px;
          font-variant-numeric: tabular-nums;
          line-height: 1.4;
        }
        #copilot-export-status {
          font-size: 11px;
          color: #50fa7b;
          margin-bottom: 10px;
          line-height: 1.4;
          min-height: 15px;
        }
        #copilot-export-panel button {
          display: block;
          width: 100%;
          padding: 7px 10px;
          margin-bottom: 6px;
          border: 1px solid #333;
          border-radius: 6px;
          background: #16213e;
          color: #e0e0e0;
          cursor: pointer;
          font-size: 12px;
          text-align: left;
        }
        #copilot-export-panel button:hover:not(:disabled) {
          background: #1f3460;
          border-color: #9d8aff;
        }
        #copilot-export-panel button:disabled {
          opacity: 0.5;
          cursor: not-allowed;
        }
        #copilot-export-panel button.primary {
          background: #1f3460;
          border-color: #9d8aff;
          font-weight: 600;
        }
        #copilot-export-panel button.primary:hover:not(:disabled) {
          background: #2a4a80;
        }
        #copilot-export-panel .hint {
          font-size: 11px;
          color: #666;
          margin-top: 8px;
          line-height: 1.4;
        }
        #copilot-date-row {
          margin-bottom: 8px;
        }
        #copilot-date-row label {
          font-size: 11px;
          color: #aaa;
          display: block;
          margin-bottom: 3px;
        }
        #copilot-date-row select,
        #copilot-date-row input[type="date"] {
          background: #16213e;
          color: #e0e0e0;
          border: 1px solid #333;
          border-radius: 4px;
          padding: 4px 6px;
          font-size: 12px;
          font-family: inherit;
        }
        #copilot-date-row select {
          width: 100%;
        }
        #copilot-custom-dates {
          display: none;
          margin-top: 4px;
          gap: 6px;
        }
        #copilot-custom-dates.visible {
          display: flex;
        }
        #copilot-custom-dates input[type="date"] {
          flex: 1;
          min-width: 0;
        }
      </style>
      <div id="copilot-export-icon" title="Open Copilot Exporter">📥</div>
      <div id="copilot-export-panel">
        <div id="copilot-export-title-row">
          <div class="title">Copilot Chat Exporter v4.4</div>
          <button id="copilot-export-minimize-btn" title="Minimize" style="width:30px">−</button>
        </div>
        <div id="copilot-export-badge">Waiting for data...</div>
        <div id="copilot-export-status"></div>
        <div id="copilot-date-row">
          <label>Date range</label>
          <select id="copilot-date-range">
            <option value="all">All time</option>
            <option value="today">Today</option>
            <option value="week">Last 7 days</option>
            <option value="month">Last 30 days</option>
            <option value="year">Last year</option>
            <option value="custom">Custom...</option>
          </select>
          <div id="copilot-custom-dates">
            <input type="date" id="copilot-date-from" title="From date">
            <input type="date" id="copilot-date-to" title="To date">
          </div>
        </div>
        <button id="copilot-btn-fetchall" class="primary">Fetch All Conversations</button>
        <button id="copilot-btn-export">Export conversations.json</button>
        <button id="copilot-btn-raw">Export raw API captures</button>
        <div class="hint">Click Fetch All to load all conversations<br>directly from the API. <a href="https://github.com/ingo/m365_copilot_chat_exporter" target="_blank" style="color: #9d8aff; text-decoration: none;">About</a></div>
      </div>
    `;

    document.body.appendChild(container);
    document.getElementById("copilot-btn-fetchall").addEventListener("click", doFetchAll);
    document.getElementById("copilot-btn-export").addEventListener("click", doExportConverted);
    document.getElementById("copilot-btn-raw").addEventListener("click", doExportRaw);

    document.getElementById("copilot-date-range").addEventListener("change", (e) => {
      const customRow = document.getElementById("copilot-custom-dates");
      customRow.classList.toggle("visible", e.target.value === "custom");
    });

    // Minimize/maximize toggle
    const ui = document.getElementById("copilot-export-ui");
    const minimizeBtn = document.getElementById("copilot-export-minimize-btn");
    const icon = document.getElementById("copilot-export-icon");

    minimizeBtn.addEventListener("click", () => {
      ui.classList.add("minimized");
    });

    icon.addEventListener("click", () => {
      ui.classList.remove("minimized");
    });
  }

  // ── Init ──────────────────────────────────────────────────────────
  console.log("[Copilot Export v4.4] Loaded.");
  createUI();
})();