ChatGPT / Claude / Copilot / Gemini AI Chat Exporter by RevivalStack

Export your ChatGPT, Claude, Copilot or Gemini chat into a properly and elegantly formatted Markdown or JSON.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         ChatGPT / Claude / Copilot / Gemini AI Chat Exporter by RevivalStack
// @namespace    https://github.com/revivalstack/chatgpt-exporter
// @version      2.7.1
// @description  Export your ChatGPT, Claude, Copilot or Gemini chat into a properly and elegantly formatted Markdown or JSON.
// @author       Mic Mejia (Refactored by Google Gemini)
// @homepage     https://github.com/micmejia
// @license      MIT License
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @match        https://claude.ai/*
// @match        https://copilot.microsoft.com/*
// @match        https://gemini.google.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function () {
  "use strict";

  // --- Global Constants ---
  const EXPORTER_VERSION = "2.7.1";
  const EXPORT_CONTAINER_ID = "export-controls-container";
  const OUTLINE_CONTAINER_ID = "export-outline-container"; // ID for the outline div
  const DOM_READY_TIMEOUT = 1000;
  const EXPORT_BUTTON_TITLE_PREFIX = `AI Chat Exporter v${EXPORTER_VERSION}`;
  const ALERT_CONTAINER_ID = "exporter-alert-container";
  const HIDE_ALERT_FLAG = "exporter_hide_scroll_alert"; // Local Storage flag
  const ALERT_AUTO_CLOSE_DURATION = 30000; // 30 seconds
  const OUTLINE_COLLAPSED_STATE_KEY = "outline_is_collapsed"; // Local Storage key for collapsed state
  const AUTOSCROLL_INITIAL_DELAY = 2000; // Initial delay before starting auto-scroll (X seconds)
  const OUTLINE_TITLE_ID = "ai-chat-exporter-outline-title";
  const OUTPUT_FILE_FORMAT_DEFAULT = "{platform}_{title}_{timestampLocal}";
  const GM_OUTPUT_FILE_FORMAT = "aiChatExporter_fileFormat";

  // --- Font Stack for UI Elements ---
  const FONT_STACK = `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"`;

  // Common styles for the container and buttons
  // These will be applied property by property.
  const COMMON_CONTROL_PROPS = {
    position: "fixed",
    bottom: "20px",
    right: "20px",
    zIndex: "9999",
    boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
    fontSize: "14px",
    cursor: "pointer",
    borderRadius: "8px",
    display: "flex",
    alignItems: "center",
    fontFamily: FONT_STACK,
  };

  // New styles for the outline container (property by property)
  const OUTLINE_CONTAINER_PROPS = {
    position: "fixed",
    bottom: "70px", // Position above the export buttons
    right: "20px",
    zIndex: "9998", // Below buttons, above general content
    boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
    fontSize: "12px", // Smaller font for outline
    borderRadius: "8px",
    backgroundColor: "#fff", // White background
    color: "#333", // Dark text
    maxHeight: "350px", // Max height for scrollable content
    width: "300px", // Fixed width
    padding: "10px",
    border: "1px solid #ddd",
    fontFamily: FONT_STACK,
    display: "flex",
    flexDirection: "column",
    transition:
      "max-height 0.3s ease-in-out, padding 0.3s ease-in-out, opacity 0.3s ease-in-out",
    opacity: "1",
    transformOrigin: "bottom right", // For scaling/transform animations if desired
  };

  const OUTLINE_CONTAINER_COLLAPSED_PROPS = {
    maxHeight: "30px", // Height when collapsed
    padding: "5px 10px",
    overflow: "hidden",
    opacity: "0.9",
  };

  const OUTLINE_HEADER_PROPS = {
    display: "flex",
    justifyContent: "space-between",
    alignItems: "center",
    marginBottom: "5px",
    paddingBottom: "5px",
    borderBottom: "1px solid #eee",
    fontWeight: "bold",
    cursor: "pointer", // Indicates it's clickable to collapse/expand
  };

  const OUTLINE_TITLE_PROPS = {
    display: "flex",
    justifyContent: "space-between",
    alignItems: "center",
    marginBottom: "5px",
    paddingBottom: "5px",
    borderBottom: "1px solid #eee",
    wordWrap: "break-word" /* Ensures long titles wrap */,
  };

  // Styles for the "Select all" section
  const SELECT_ALL_CONTAINER_PROPS = {
    display: "flex",
    alignItems: "center",
    padding: "5px 0",
    marginBottom: "5px",
    borderBottom: "1px solid #eee",
  };

  // Styles for the search bar
  const SEARCH_INPUT_PROPS = {
    width: "calc(100% - 20px)", // Full width minus padding
    padding: "6px 10px",
    margin: "5px 0 10px 0",
    border: "1px solid #ddd",
    borderRadius: "4px",
    fontSize: "12px",
    fontFamily: FONT_STACK,
  };

  const NO_MATCH_MESSAGE_PROPS = {
    textAlign: "center",
    fontStyle: "italic",
    fontWeight: "bold",
    color: "#666",
    padding: "10px 0",
  };

  const OUTLINE_ITEM_PROPS = {
    display: "flex",
    alignItems: "center",
    marginBottom: "3px",
    lineHeight: "1.3",
  };

  const OUTLINE_CHECKBOX_PROPS = {
    marginRight: "5px",
    cursor: "pointer",
  };

  const OUTLINE_TOGGLE_BUTTON_PROPS = {
    background: "none",
    border: "none",
    fontSize: "16px",
    cursor: "pointer",
    padding: "0 5px",
    color: "#5b3f87",
  };

  const BUTTON_BASE_PROPS = {
    padding: "10px 14px",
    backgroundColor: "#5b3f87", // Primary brand color
    color: "white",
    border: "none",
    cursor: "pointer",
    borderRadius: "8px",
  };

  const BUTTON_SPACING_PROPS = {
    marginLeft: "8px",
  };

  // --- Alert Styles ---
  // Note: max-width for ALERT_PROPS will be dynamically set
  const ALERT_PROPS = {
    position: "fixed",
    top: "20px",
    left: "50%",
    transform: "translateX(-50%)",
    zIndex: "10000",
    backgroundColor: "rgba(91, 63, 135, 0.9)", // Shade of #5b3f87 with transparency
    color: "white",
    padding: "15px 20px",
    borderRadius: "8px",
    boxShadow: "0 2px 10px rgba(0,0,0,0.2)",
    display: "flex",
    flexDirection: "column", // Changed to column for title, message and checkbox
    justifyContent: "space-between",
    alignItems: "flex-start", // Align items to the start for better layout
    fontSize: "14px",
    opacity: "1",
    transition: "opacity 0.5s ease-in-out",
    fontFamily: FONT_STACK,
  };

  const ALERT_MESSAGE_ROW_PROPS = {
    display: "flex",
    justifyContent: "space-between",
    alignItems: "center",
    width: "100%",
    marginBottom: "10px", // Space between message and checkbox
  };

  const ALERT_CLOSE_BUTTON_PROPS = {
    background: "none",
    border: "none",
    color: "white",
    fontSize: "20px",
    cursor: "pointer",
    marginLeft: "15px", // Add margin to push it right
    lineHeight: "1", // Align 'x' vertically
  };

  const ALERT_CHECKBOX_CONTAINER_PROPS = {
    display: "flex",
    alignItems: "center",
    width: "100%",
  };

  const ALERT_CHECKBOX_PROPS = {
    marginRight: "5px",
  };

  // --- Hostname-Specific Selectors & Identifiers ---
  const CHATGPT = "chatgpt";
  const CHATGPT_HOSTNAMES = ["chat.openai.com", "chatgpt.com"];
  const CHATGPT_TITLE_REPLACE_TEXT = " - ChatGPT";
  const CHATGPT_ARTICLE_SELECTOR = "article";
  const CHATGPT_HEADER_SELECTOR = "h5";
  const CHATGPT_TEXT_DIV_SELECTOR = "div.text-base";
  const CHATGPT_USER_MESSAGE_INDICATOR = "you said";
  const CHATGPT_POPUP_DIV_CLASS = "popover";
  const CHATGPT_BUTTON_SPECIFIC_CLASS = "text-sm";

  const GEMINI = "gemini";
  const GEMINI_HOSTNAMES = ["gemini.google.com"];
  const GEMINI_TITLE_REPLACE_TEXT = "Gemini - ";
  const GEMINI_MESSAGE_ITEM_SELECTOR = "user-query, model-response";
  const GEMINI_SIDEBAR_ACTIVE_CHAT_SELECTOR =
    'div[data-test-id="conversation"].selected .conversation-title';

  const CLAUDE = "claude";
  const CLAUDE_HOSTNAMES = ["claude.ai"];
  const CLAUDE_MESSAGE_SELECTOR =
    ".font-claude-message:not(#markdown-artifact), .font-user-message";
  const CLAUDE_USER_MESSAGE_CLASS = "font-user-message";
  const CLAUDE_THINKING_BLOCK_CLASS = "transition-all";
  const CLAUDE_ARTIFACT_BLOCK_CELL = ".artifact-block-cell";

  const COPILOT = "copilot";
  const COPILOT_HOSTNAMES = ["copilot.microsoft.com"];
  const COPILOT_MESSAGE_SELECTOR =
    '[data-content="user-message"], [data-content="ai-message"]';
  const COPILOT_USER_MESSAGE_SELECTOR = '[data-content="user-message"]';
  const COPILOT_BOT_MESSAGE_SELECTOR = '[data-content="ai-message"]';

  const HOSTNAME = window.location.hostname;
  const CURRENT_PLATFORM = (() => {
    if (CHATGPT_HOSTNAMES.some((host) => HOSTNAME.includes(host))) {
      return CHATGPT;
    }
    if (CLAUDE_HOSTNAMES.some((host) => HOSTNAME.includes(host))) {
      return CLAUDE;
    }
    if (COPILOT_HOSTNAMES.some((host) => HOSTNAME.includes(host))) {
      return COPILOT;
    }
    if (GEMINI_HOSTNAMES.some((host) => HOSTNAME.includes(host))) {
      return GEMINI;
    }
    return "unknown";
  })();

  // --- Markdown Formatting Constants ---
  const DEFAULT_CHAT_TITLE = "chat";
  const MARKDOWN_TOC_PLACEHOLDER_LINK = "#table-of-contents";
  const MARKDOWN_BACK_TO_TOP_LINK = `___\n###### [top](${MARKDOWN_TOC_PLACEHOLDER_LINK})\n`;

  // Parents of <p> tags where newlines should be suppressed or handled differently
  // LI is handled separately in the paragraph rule for single newlines.
  const PARAGRAPH_FILTER_PARENT_NODES = ["TH", "TR"];

  // Styles for the scrollable message list div
  const MESSAGE_LIST_PROPS = {
    overflowY: "auto", // Enable vertical scrolling for this specific div
    flexGrow: "1", // Allow it to grow and take available space
    paddingRight: "5px", // Add some padding for scrollbar visibility
  };

  // --- Inlined Turndown.js (v7.1.2) - BEGIN ---
  // Customized TurndownService to handle specific chat DOM structures
  class TurndownService {
    constructor(options = {}) {
      this.rules = [];
      this.options = {
        headingStyle: "atx",
        hr: "___",
        bulletListMarker: "-",
        codeBlockStyle: "fenced",
        ...options,
      };
    }

    addRule(key, rule) {
      this.rules.push({ key, ...rule });
    }

    turndown(rootNode) {
      let output = "";

      const process = (node) => {
        if (node.nodeType === Node.TEXT_NODE) return node.nodeValue;
        if (node.nodeType !== Node.ELEMENT_NODE) return "";

        const rule = this.rules.find(
          (r) =>
            (typeof r.filter === "string" &&
              r.filter === node.nodeName.toLowerCase()) ||
            (Array.isArray(r.filter) &&
              r.filter.includes(node.nodeName.toLowerCase())) ||
            (typeof r.filter === "function" && r.filter(node))
        );

        const content = Array.from(node.childNodes)
          .map((n) => process(n))
          .join("");

        if (rule) return rule.replacement(content, node, this.options);
        return content;
      };

      let parsedRootNode = rootNode;
      if (typeof rootNode === "string") {
        const parser = new DOMParser();
        const doc = parser.parseFromString(rootNode, "text/html");
        parsedRootNode = doc.body || doc.documentElement;
      }

      output = Array.from(parsedRootNode.childNodes)
        .map((n) => process(n))
        .join("");
      // Clean up excessive newlines (more than two)
      return output.trim().replace(/\n{3,}/g, "\n\n");
    }
  }
  // --- Inlined Turndown.js - END ---

  // --- Utility Functions ---
  const Utils = {
    /**
     * Converts a string into a URL-friendly slug.
     * @param {string} str The input text.
     * @returns {string} The slugified string.
     */
    slugify(str, toLowerCase = true, maxLength = 120) {
      if (typeof str !== "string") {
        return "invalid-filename"; // Handle non-string input gracefully
      }
      if (toLowerCase) {
        str = str.toLocaleLowerCase();
      }
      return str
        .replace(/[^a-zA-Z0-9\-_.+]+/g, "-")
        .replace(/-+/g, "-")
        .replace(/^-|-$/g, "")
        .replace(/^$/, "invalid-filename")
        .slice(0, maxLength);
    },

    /**
     * Formats a Date object into a local time string with UTC offset.
     * @param {Date} d The Date object.
     * @returns {string} The formatted local time string.
     */
    formatLocalTime(d) {
      const pad = (n) => String(n).padStart(2, "0");
      const tzOffsetMin = -d.getTimezoneOffset();
      const sign = tzOffsetMin >= 0 ? "+" : "-";
      const absOffset = Math.abs(tzOffsetMin);
      const offsetHours = pad(Math.floor(absOffset / 60));
      const offsetMinutes = pad(absOffset % 60);
      return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
        d.getDate()
      )}T${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(
        d.getSeconds()
      )}${sign}${offsetHours}${offsetMinutes}`;
    },

    /**
     * Truncates a string to a given maximum length, adding "…" if truncated.
     * @param {string} str The input string.
     * @param {number} [len=70] The maximum length.
     * @returns {string} The truncated string.
     */
    truncate(str, len = 70) {
      return str.length <= len ? str : str.slice(0, len).trim() + "…";
    },

    /**
     * Escapes Markdown special characters in a string.
     * @param {string} text The input string.
     * @returns {string} The string with Markdown characters escaped.
     */
    escapeMd(text) {
      return text.replace(/[|\\`*_{}\[\]()#+\-!>]/g, "\\$&");
    },

    /**
     * Downloads text content as a file.
     * @param {string} filename The name of the file to download.
     * @param {string} text The content to save.
     * @param {string} [mimeType='text/plain;charset=utf-8'] The MIME type.
     */
    downloadFile(filename, text, mimeType = "text/plain;charset=utf-8") {
      const blob = new Blob([text], { type: mimeType });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = filename;
      a.click();
      URL.revokeObjectURL(url);
    },

    /**
     * Applies a set of CSS properties to an element.
     * @param {HTMLElement} element The HTML element to style.
     * @param {object} styles An object where keys are CSS property names (camelCase) and values are their values.
     */
    applyStyles(element, styles) {
      for (const prop in styles) {
        element.style[prop] = styles[prop];
      }
    },

    /**
     * Formats a filename string based on provided format and chat data.
     *
     * @param {string} format - The format string with placeholders (e.g., "{platform}_{tag1}_{title}_{timestamp}.md").
     * @param {string} title - The cleaned title of the chat.
     * @param {string[]} tags - An array of tags for the chat.
     * @param {string} ext - The file extenstion without leading dot.
     * @returns {string} The formatted filename.
     */
    formatFileName(format, title, tags, ext) {
      // Ensure tags is an array
      const tagsArray = Array.isArray(tags) ? tags : [];

      const replacements = {
        "{exporter}": EXPORTER_VERSION,
        "{platform}": CURRENT_PLATFORM,
        "{title}": title.slice(0, 70).toLocaleLowerCase(),
        "{timestamp}": new Date().toISOString(),
        "{timestampLocal}": Utils.formatLocalTime(new Date()),
        "{tags}": tagsArray.join("-").toLocaleLowerCase(), // Comma separated string of all tags
      };

      // Add individual tags (tag1 to tag9)
      for (let i = 0; i < 9; i++) {
        const tagName = `{tag${i + 1}}`;
        replacements[tagName] = tagsArray[i]
          ? tagsArray[i].toLocaleLowerCase()
          : ""; // Use tag if it exists, otherwise empty string
      }

      let formattedFilename = format;
      for (const placeholder in replacements) {
        if (replacements.hasOwnProperty(placeholder)) {
          // Replace all occurrences of the placeholder with its value
          formattedFilename = formattedFilename
            .split(placeholder)
            .join(replacements[placeholder]);
        }
      }

      return Utils.slugify(`${formattedFilename}.${ext}`, false);
    },

    /**
     * Parses a raw chat title to extract tags and the cleaned main title.
     * Tags starting with '#' followed by one or more digits are ignored.
     *
     * @param {string} rawTitle - The raw chat title string, e.g., "#aice #plan #tech #50731 Browser Storage Options Comparison".
     * @returns {{title: string, tags: string[]}} An object containing the cleaned title and an array of extracted tags.
     */
    parseChatTitleAndTags(rawTitle) {
      const tags = [];
      let cleanedTitle = rawTitle.trim();

      // Regular expression to find tags at the beginning of the string:
      // # (hash)
      // \S+ (one or more non-whitespace characters)
      // ^ (start of string)
      // (\s*#\S+)* (zero or more occurrences of space and then a tag)
      const tagRegex = /(^|\s+)#(\S+)/g;
      let match;

      // Iterate over all matches to extract tags
      while ((match = tagRegex.exec(cleanedTitle)) !== null) {
        const fullTag = match[0].trim(); // e.g., "#aice", " #plan"
        const tagName = match[2]; // e.g., "aice", "plan"

        // Check if the tag is numeric (e.g., #50731)
        if (!/^\d+$/.test(tagName)) {
          tags.push(tagName);
        }
      }

      // Remove all tags from the title string, including the numeric ones,
      // to get the final cleaned title.
      // This regex matches a hash, followed by one or more non-whitespace characters,
      // optionally followed by a space, only if it appears at the beginning or after a space.
      cleanedTitle = cleanedTitle.replace(/(^|\s+)#\S+/g, " ").trim();

      // Remove any extra spaces that might result from tag removal
      cleanedTitle = cleanedTitle.replace(/\s+/g, " ").trim();

      return {
        title: cleanedTitle,
        tags: tags,
      };
    },
  };

  // --- Core Export Logic ---
  const ChatExporter = {
    _currentChatData: null, // Store the last extracted chat data
    _selectedMessageIds: new Set(), // Store IDs of selected messages for export

    /**
     * Extracts chat data from ChatGPT's DOM structure.
     * @param {Document} doc - The Document object.
     * @returns {object|null} The standardized chat data, or null.
     */
    extractChatGPTChatData(doc) {
      const articles = [...doc.querySelectorAll(CHATGPT_ARTICLE_SELECTOR)];
      if (articles.length === 0) return null;

      let title =
        doc.title.replace(CHATGPT_TITLE_REPLACE_TEXT, "").trim() ||
        DEFAULT_CHAT_TITLE;
      const messages = [];
      let chatIndex = 1;

      for (const article of articles) {
        const seenDivs = new Set();
        const header =
          article.querySelector(CHATGPT_HEADER_SELECTOR)?.textContent?.trim() ||
          "";
        const textDivs = article.querySelectorAll(CHATGPT_TEXT_DIV_SELECTOR);
        let fullText = "";

        textDivs.forEach((div) => {
          const key = div.innerText.trim();
          if (!key || seenDivs.has(key)) return;
          seenDivs.add(key);
          fullText += key + "\n";
        });

        if (!fullText.trim()) continue;

        const isUser = header
          .toLowerCase()
          .includes(CHATGPT_USER_MESSAGE_INDICATOR);
        const author = isUser ? "user" : "ai";

        // Assign a unique ID to each message. This is crucial for selection.
        const messageId = `${author}-${chatIndex}-${Date.now()}-${Math.random()
          .toString(36)
          .substring(2, 9)}`;

        messages.push({
          id: messageId, // Unique ID
          author: author,
          contentHtml: article, // Store the direct DOM Element
          contentText: fullText.trim(),
          timestamp: new Date(),
          originalIndex: chatIndex, // Keep original index for outline
        });

        if (!isUser) chatIndex++;
      }

      const _parsedTitle = Utils.parseChatTitleAndTags(title);

      return {
        _raw_title: title,
        title: _parsedTitle.title,
        tags: _parsedTitle.tags,
        author: CURRENT_PLATFORM,
        messages: messages,
        messageCount: messages.filter((m) => m.author === "user").length, // Count user messages as questions
        exportedAt: new Date(),
        exporterVersion: EXPORTER_VERSION,
        threadUrl: window.location.href,
      };
    },

    /**
     * Extracts chat data from Claude's DOM structure.
     * @param {Document} doc - The Document object.
     * @returns {object|null} The standardized chat data, or null.
     */
    extractClaudeChatData(doc) {
      const messageItems = [...doc.querySelectorAll(CLAUDE_MESSAGE_SELECTOR)];
      if (messageItems.length === 0) return null;

      const messages = [];
      let chatIndex = 1;
      const chatTitle = doc.title || DEFAULT_CHAT_TITLE;

      messageItems.forEach((item) => {
        const isUser = item.classList.contains(CLAUDE_USER_MESSAGE_CLASS);
        const author = isUser ? "user" : "ai";

        let messageContentHtml = null;
        let messageContentText = "";

        if (isUser) {
          // For user messages, the entire div is the content
          messageContentHtml = item;
          messageContentText = item.innerText.trim();
        } else {
          // For Claude messages, we need to filter out "thinking" blocks
          const claudeResponseContent = document.createElement("div");
          Array.from(item.children).forEach((child) => {
            const isThinkingBlock = child.className.includes(
              CLAUDE_THINKING_BLOCK_CLASS
            );
            const isArtifactBlock =
              (child.className.includes("pt-3") &&
                child.className.includes("pb-3")) ||
              child.querySelector(CLAUDE_ARTIFACT_BLOCK_CELL);

            // Only consider non-thinking, non-artifact blocks
            if (!isThinkingBlock && !isArtifactBlock) {
              const contentGrid = child.querySelector(".grid-cols-1");
              if (contentGrid) {
                // We will use the existing TurndownService to process this content
                claudeResponseContent.appendChild(contentGrid.cloneNode(true));
              }
            }
          });
          messageContentHtml = claudeResponseContent;
          messageContentText = claudeResponseContent.innerText.trim();
        }

        if (messageContentText) {
          const messageId = `${author}-${chatIndex}-${Date.now()}-${Math.random()
            .toString(36)
            .substring(2, 9)}`;

          messages.push({
            id: messageId,
            author: author,
            contentHtml: messageContentHtml,
            contentText: messageContentText,
            timestamp: new Date(),
            originalIndex: chatIndex,
          });

          if (!isUser) chatIndex++;
        }
      });

      const _parsedTitle = Utils.parseChatTitleAndTags(chatTitle);

      return {
        _raw_title: chatTitle,
        title: _parsedTitle.title,
        tags: _parsedTitle.tags,
        author: CURRENT_PLATFORM,
        messages: messages,
        messageCount: messages.filter((m) => m.author === "user").length,
        exportedAt: new Date(),
        exporterVersion: EXPORTER_VERSION,
        threadUrl: window.location.href,
      };
    },

    /**
     * Extracts chat data from Copilot's DOM structure.
     * @param {Document} doc - The Document object.
     * @returns {object|null} The standardized chat data, or null.
     */
    extractCopilotChatData(doc) {
      const messageItems = [...doc.querySelectorAll(COPILOT_MESSAGE_SELECTOR)];
      if (messageItems.length === 0) return null;

      const messages = [];
      let chatIndex = 1;

      let rawTitle = "";
      const selected = doc.querySelector(
        '[role="option"][aria-selected="true"]'
      );
      if (selected) {
        rawTitle =
          selected.querySelector("p")?.textContent.trim() ||
          (selected.getAttribute("aria-label") || "")
            .split(",")
            .slice(1)
            .join(",")
            .trim();
      }
      if (!rawTitle) {
        rawTitle = (doc.title || "")
          .replace(/^\s*Microsoft[_\s-]*Copilot.*$/i, "")
          .replace(/\s*[-–|]\s*Copilot.*$/i, "")
          .trim();
      }
      if (!rawTitle) rawTitle = "Copilot Conversation";

      for (const item of messageItems) {
        const isUser = item.matches(COPILOT_USER_MESSAGE_SELECTOR);
        const author = isUser ? "user" : "ai";
        // The actual content is nested differently for user and AI messages
        const messageContentElem = isUser
          ? item.querySelector("div")
          : item.querySelector(":scope > div:nth-child(2)");

        if (!messageContentElem) continue;

        const messageId = `${author}-${chatIndex}-${Date.now()}-${Math.random()
          .toString(36)
          .substring(2, 9)}`;

        messages.push({
          id: messageId,
          author: author,
          contentHtml: messageContentElem.cloneNode(true),
          contentText: messageContentElem.innerText.trim(),
          timestamp: new Date(),
          originalIndex: chatIndex,
        });

        if (author === "ai") chatIndex++;
      }

      const _parsedTitle = Utils.parseChatTitleAndTags(rawTitle);

      return {
        _raw_title: rawTitle,
        title: _parsedTitle.title,
        tags: _parsedTitle.tags,
        author: COPILOT,
        messages: messages,
        messageCount: messages.filter((m) => m.author === "user").length,
        exportedAt: new Date(),
        exporterVersion: EXPORTER_VERSION,
        threadUrl: window.location.href,
      };
    },

    /**
     * Extracts chat data from Gemini's DOM structure.
     * @param {Document} doc - The Document object.
     * @returns {object|null} The standardized chat data, or null.
     */
    extractGeminiChatData(doc) {
      const messageItems = [
        ...doc.querySelectorAll(GEMINI_MESSAGE_ITEM_SELECTOR),
      ];
      if (messageItems.length === 0) return null;

      let title = DEFAULT_CHAT_TITLE;

      // Prioritize title from sidebar if available and not generic
      const sidebarActiveChatItem = doc.querySelector(
        GEMINI_SIDEBAR_ACTIVE_CHAT_SELECTOR
      );
      if (sidebarActiveChatItem && sidebarActiveChatItem.textContent.trim()) {
        title = sidebarActiveChatItem.textContent.trim();
      } else {
        title = doc.title;
      }
      if (title.startsWith(GEMINI_TITLE_REPLACE_TEXT)) {
        title = title.replace(GEMINI_TITLE_REPLACE_TEXT, "").trim();
      }

      const messages = [];
      let chatIndex = 1;

      for (const item /* @type {HTMLElement} */ of messageItems) {
        let author = "";
        let messageContentElem = null;

        const tagName = item.tagName.toLowerCase();

        if (tagName === "user-query") {
          author = "user";
          messageContentElem = item.querySelector("div.query-content");
        } else if (tagName === "model-response") {
          author = "ai";
          messageContentElem = item.querySelector("message-content");
        }

        if (!messageContentElem) continue;

        // Assign a unique ID to each message. This is crucial for selection.
        const messageId = `${author}-${chatIndex}-${Date.now()}-${Math.random()
          .toString(36)
          .substring(2, 9)}`;

        messages.push({
          id: messageId, // Unique ID
          author: author,
          contentHtml: messageContentElem, // Store the direct DOM Element
          contentText: messageContentElem.innerText.trim(),
          timestamp: new Date(),
          originalIndex: chatIndex, // Keep original index for outline
        });

        if (author === "ai") chatIndex++;
      }

      // Final fallback to the first user message if title is still default
      if (
        title === DEFAULT_CHAT_TITLE &&
        messages.length > 0 &&
        messages[0].author === "user"
      ) {
        const firstUserMessage = messages[0].contentText;
        const words = firstUserMessage
          .split(/\s+/)
          .filter((word) => word.length > 0);
        if (words.length > 0) {
          let generatedTitle = words.slice(0, 7).join(" ");
          generatedTitle = generatedTitle.replace(/[,.;:!?\-+]$/, "").trim();
          if (generatedTitle.length < 5 && words.length > 1) {
            generatedTitle = words
              .slice(0, Math.min(words.length, 10))
              .join(" ");
            generatedTitle = generatedTitle.replace(/[,.;:!?\-+]$/, "").trim();
          }
          title = generatedTitle || DEFAULT_CHAT_TITLE;
        }
      }

      const _parsedTitle = Utils.parseChatTitleAndTags(title);

      return {
        _raw_title: title,
        title: _parsedTitle.title,
        tags: _parsedTitle.tags,
        author: CURRENT_PLATFORM,
        messages: messages,
        messageCount: messages.filter((m) => m.author === "user").length, // Count user messages as questions
        exportedAt: new Date(),
        exporterVersion: EXPORTER_VERSION,
        threadUrl: window.location.href,
      };
    },

    /**
     * Converts standardized chat data to Markdown format.
     * This function now expects a pre-filtered `chatData`.
     * @param {object} chatData - The standardized chat data (already filtered).
     * @param {TurndownService} turndownServiceInstance - Configured TurndownService.
     * @returns {{output: string, fileName: string}} Markdown string and filename.
     */
    formatToMarkdown(chatData, turndownServiceInstance) {
      let toc = "";
      let content = "";
      let exportChatIndex = 0; // Initialize to 0 for sequential user message numbering

      chatData.messages.forEach((msg) => {
        if (msg.author === "user") {
          exportChatIndex++; // Increment only for user messages
          const preview = Utils.truncate(
            msg.contentText.replace(/\s+/g, " "),
            70
          );
          toc += `- [${exportChatIndex}: ${Utils.escapeMd(
            preview
          )}](#chat-${exportChatIndex})\n`;
          content +=
            `### chat-${exportChatIndex}\n\n> ` +
            msg.contentText.replace(/\n/g, "\n> ") +
            "\n\n";
        } else {
          let markdownContent;
          try {
            markdownContent = turndownServiceInstance.turndown(msg.contentHtml);
          } catch (e) {
            console.error(
              `Error converting AI message ${msg.id} to Markdown:`,
              e
            );
            markdownContent = `[CONVERSION ERROR: Failed to render this section. Original content below]\n\n\`\`\`\n${msg.contentText}\n\`\`\`\n`;
          }
          content += markdownContent + "\n\n" + MARKDOWN_BACK_TO_TOP_LINK;
        }
        // Removed the incorrect increment logic from here
      });

      const localTime = Utils.formatLocalTime(chatData.exportedAt);

      const yaml = `---\ntitle: ${chatData.title}\ntags: [${chatData.tags.join(
        ", "
      )}]\nauthor: ${chatData.author}\ncount: ${
        chatData.messageCount
      }\nexporter: ${EXPORTER_VERSION}\ndate: ${localTime}\nurl: ${
        chatData.threadUrl
      }\n---\n`;
      const tocBlock = `## Table of Contents\n\n${toc.trim()}\n\n`;

      const finalOutput =
        yaml + `\n# ${chatData.title}\n\n` + tocBlock + content.trim() + "\n\n";

      const fileName = Utils.formatFileName(
        GM_getValue(GM_OUTPUT_FILE_FORMAT, OUTPUT_FILE_FORMAT_DEFAULT),
        chatData.title,
        chatData.tags,
        "md"
      );
      return { output: finalOutput, fileName: fileName };
    },

    /**
     * Converts standardized chat data to JSON format.
     * This function now expects a pre-filtered `chatData`.
     * @param {object} chatData - The standardized chat data (already filtered).
     * @param {TurndownService} turndownServiceInstance - Configured TurndownService.
     * @returns {{output: string, fileName: string}} JSON string and filename.
     */
    formatToJSON(chatData, turndownServiceInstance) {
      const processMessageContent = function (msg) {
        if (msg.author === "user") {
          return msg.contentText;
        } else {
          let markdownContent;
          try {
            markdownContent = turndownServiceInstance.turndown(msg.contentHtml);
          } catch (e) {
            console.error(
              `Error converting AI message ${msg.id} to Markdown:`,
              e
            );
            markdownContent = `[CONVERSION ERROR: Failed to render this section.]: ${msg.contentText}`;
          }
          return markdownContent;
        }
      };
      const jsonOutput = {
        title: chatData.title,
        tags: chatData.tags,
        author: chatData.author,
        count: chatData.messageCount,
        exporter: EXPORTER_VERSION,
        date: chatData.exportedAt.toISOString(),
        url: chatData.threadUrl,
        messages: chatData.messages.map((msg) => ({
          id: msg.id.split("-").slice(0, 2).join("-"), // Keep the ID for reference in JSON
          author: msg.author,
          content: processMessageContent(msg),
        })),
      };

      const fileName = Utils.formatFileName(
        GM_getValue(GM_OUTPUT_FILE_FORMAT, OUTPUT_FILE_FORMAT_DEFAULT),
        chatData.title,
        chatData.tags,
        "json"
      );

      return {
        output: JSON.stringify(jsonOutput, null, 2),
        fileName: fileName,
      };
    },

    /**
     * This function setups the rules for turndownServiceInstance
     * @param {TurndownService} turndownServiceInstance - Configured TurndownService.
     */
    setupTurndownRules(turndownServiceInstance) {
      if (CURRENT_PLATFORM === CHATGPT) {
        turndownServiceInstance.addRule("chatgptRemoveReactions", {
          filter: (node) =>
            node.nodeName === "DIV" &&
            // Check for the language div (2nd child).
            node.querySelector(
              ':scope > div:nth-child(1) > button[data-testid="copy-turn-action-button"]'
            ),
          replacement: () => "",
        });
        turndownServiceInstance.addRule("chatgptRemoveH6ChatGPTSaid", {
          filter: (node) =>
            node.nodeName === "H6" &&
            node.classList.contains("sr-only") &&
            node.textContent.trim().toLowerCase().startsWith("chatgpt said"),
          replacement: () => "",
        });
      }

      if (CURRENT_PLATFORM === COPILOT) {
        turndownServiceInstance.addRule("copilotRemoveReactions", {
          filter: (node) =>
            node.matches('[data-testid="message-item-reactions"]'),
          replacement: () => "",
        });

        // This single rule handles the entire Copilot code block structure.
        turndownServiceInstance.addRule("copilotCodeBlock", {
          filter: function (node, options) {
            // Filter for the grandparent div of the pre element using more concise CSS selectors.
            return (
              node.nodeName === "DIV" &&
              // Check for the language div (2nd child).
              node.querySelector(":scope > div:nth-child(1) > span") &&
              // Check for the code block div (3rd child) with a direct <pre> child.
              node.querySelector(":scope > div:nth-child(2) > div > pre")
            );
          },
          replacement: function (content, node) {
            // Get the language from the second child div.
            const languageNode = node.querySelector(
              ":scope > div:nth-child(1) > span"
            );
            const language = languageNode
              ? languageNode.textContent.trim().toLowerCase()
              : "";

            // Get the code content from the pre > code element within the third child div.
            const codeNode = node.querySelector(
              ":scope > div:nth-child(2) > div > pre > code"
            );
            if (!codeNode) return "";

            const codeText = codeNode.textContent || "";

            return "\n\n```" + language + "\n" + codeText + "\n```\n\n";
          },
        });

        turndownServiceInstance.addRule("copilotFooterLinks", {
          filter: function (node, options) {
            // Footer links for each message is an <a> with children: span, img, and span
            // Use the last span content as text
            return (
              node.nodeName === "A" &&
              node.querySelector(":scope > span:nth-child(1)") &&
              node.querySelector(":scope > img:nth-child(2)") &&
              node.querySelector(":scope > span:nth-child(3)")
            );
          },
          replacement: function (content, node) {
            // Get the link text from last span.
            const lastSpan = node.querySelector(":scope > span:nth-child(3)");
            const linkText = lastSpan
              ? lastSpan.textContent.trim()
              : node.getAttribute("href");

            return `[${linkText}](${node.getAttribute("href")}) `;
          },
        });
      }

      turndownServiceInstance.addRule("lineBreak", {
        filter: "br",
        replacement: () => "  \n",
      });

      turndownServiceInstance.addRule("heading", {
        filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
        replacement: (content, node) => {
          const hLevel = Number(node.nodeName.charAt(1));
          return `\n\n${"#".repeat(hLevel)} ${content}\n\n`;
        },
      });

      // Custom rule for list items to ensure proper nesting and markers
      turndownServiceInstance.addRule("customLi", {
        filter: "li",
        replacement: function (content, node) {
          let processedContent = content.trim();

          // Heuristic: If content contains multiple lines and the second line
          // looks like a list item, ensure a double newline for nested lists.
          if (processedContent.length > 0) {
            const lines = processedContent.split("\n");
            if (lines.length > 1 && /^\s*[-*+]|^[0-9]+\./.test(lines[1])) {
              processedContent = lines.join("\n\n").trim();
            }
          }

          let listItemMarkdown;
          if (node.parentNode.nodeName === "UL") {
            let indent = "";
            let liAncestorCount = 0;
            let parent = node.parentNode;

            // Calculate indentation for nested unordered lists
            while (parent) {
              if (parent.nodeName === "LI") {
                liAncestorCount++;
              }
              parent = parent.parentNode;
            }
            for (let i = 0; i < liAncestorCount; i++) {
              indent += "    "; // 4 spaces per nesting level
            }
            listItemMarkdown = `${indent}${turndownServiceInstance.options.bulletListMarker} ${processedContent}`;
          } else if (node.parentNode.nodeName === "OL") {
            // Get the correct index for ordered list items
            const siblings = Array.from(node.parentNode.children).filter(
              (child) => child.nodeName === "LI"
            );
            const index = siblings.indexOf(node);
            listItemMarkdown = `${index + 1}. ${processedContent}`;
          } else {
            listItemMarkdown = processedContent; // Fallback
          }
          // Always add a newline after each list item for separation
          return listItemMarkdown + "\n";
        }.bind(turndownServiceInstance),
      });

      if (CURRENT_PLATFORM === CLAUDE) {
        // This single rule handles the entire Claude code block structure.
        turndownServiceInstance.addRule("claudeCodeBlock", {
          filter: function (node, options) {
            // Filter for the grandparent div of the pre element using more concise CSS selectors.
            return (
              node.nodeName === "DIV" &&
              // Check for the language div (2nd child).
              node.querySelector(":scope > div:nth-child(2)") &&
              // Check for the code block div (3rd child) with a direct <pre> child.
              node.querySelector(":scope > div:nth-child(3) > pre > code")
            );
          },
          replacement: function (content, node) {
            // Get the language from the second child div.
            const languageNode = node.querySelector(
              ":scope > div:nth-child(2)"
            );
            const language = languageNode
              ? languageNode.textContent.trim().toLowerCase()
              : "";

            // Get the code content from the pre > code element within the third child div.
            const codeNode = node.querySelector(
              ":scope > div:nth-child(3) > pre > code"
            );
            if (!codeNode) return "";

            const codeText = codeNode.textContent || "";

            return "\n\n```" + language + "\n" + codeText + "\n```\n\n";
          },
        });
      }

      turndownServiceInstance.addRule("code", {
        filter: "code",
        replacement: (content, node) => {
          if (node.parentNode.nodeName === "PRE") return content;
          return `\`${content}\``;
        },
      });

      // Rule for preformatted code blocks
      turndownServiceInstance.addRule("pre", {
        filter: "pre",
        replacement: (content, node) => {
          let lang = "";

          // Attempt to find language for Gemini's code blocks
          const geminiCodeBlockParent = node.closest(".code-block");
          if (geminiCodeBlockParent) {
            const geminiLanguageSpan = geminiCodeBlockParent.querySelector(
              ".code-block-decoration span"
            );
            if (geminiLanguageSpan && geminiLanguageSpan.textContent.trim()) {
              lang = geminiLanguageSpan.textContent.trim();
            }
          }

          // Fallback to ChatGPT's language selector if Gemini's wasn't found
          if (!lang) {
            const chatgptLanguageDiv = node.querySelector(
              ".flex.items-center.text-token-text-secondary"
            );
            if (chatgptLanguageDiv) {
              lang = chatgptLanguageDiv.textContent.trim();
            }
          }

          const codeElement = node.querySelector("code");
          if (!codeElement) return content;
          const codeText = codeElement ? codeElement.textContent.trim() : "";

          // Ensure a blank line before the code section's language text if its parent is a list item
          let prefix = "\n"; // Default prefix for code blocks
          let prevSibling = node.previousElementSibling;

          // Check for a specific pattern: <p> immediately followed by <pre> inside an <li>
          if (prevSibling && prevSibling.nodeName === "P") {
            let parentLi = prevSibling.closest("li");
            if (parentLi && parentLi.contains(node)) {
              // Ensure the <pre> is also a descendant of the same <li>
              prefix = "\n\n"; // Add an extra newline for better separation
            }
          }

          return `${prefix}\`\`\`${lang}\n${codeText}\n\`\`\`\n`;
        },
      });

      turndownServiceInstance.addRule("strong", {
        filter: ["strong", "b"],
        replacement: (content) => `**${content}**`,
      });

      turndownServiceInstance.addRule("em", {
        filter: ["em", "i"],
        replacement: (content) => `_${content}_`,
      });

      turndownServiceInstance.addRule("blockQuote", {
        filter: "blockquote",
        replacement: (content) =>
          content
            .trim()
            .split("\n")
            .map((l) => `> ${l}`)
            .join("\n"),
      });

      turndownServiceInstance.addRule("link", {
        filter: "a",
        replacement: (content, node) =>
          `[${content}](${node.getAttribute("href")})`,
      });

      turndownServiceInstance.addRule("strikethrough", {
        filter: (node) => node.nodeName === "DEL",
        replacement: (content) => `~~${content}~~`,
      });

      // Rule for HTML tables to Markdown table format
      turndownServiceInstance.addRule("table", {
        filter: "table",
        replacement: function (content, node) {
          const headerRows = Array.from(node.querySelectorAll("thead tr"));
          const bodyRows = Array.from(node.querySelectorAll("tbody tr"));
          const footerRows = Array.from(node.querySelectorAll("tfoot tr"));

          let allRowsContent = [];

          const getRowCellsContent = (rowElement) => {
            const cells = Array.from(rowElement.querySelectorAll("th, td"));
            return cells.map((cell) =>
              cell.textContent.replace(/\s+/g, " ").trim()
            );
          };

          if (headerRows.length > 0) {
            allRowsContent.push(getRowCellsContent(headerRows[0]));
          }

          bodyRows.forEach((row) => {
            allRowsContent.push(getRowCellsContent(row));
          });

          footerRows.forEach((row) => {
            allRowsContent.push(getRowCellsContent(row));
          });

          if (allRowsContent.length === 0) {
            return "";
          }

          const isFirstRowAHeader = headerRows.length > 0;
          const maxCols = Math.max(...allRowsContent.map((row) => row.length));

          const paddedRows = allRowsContent.map((row) => {
            const paddedRow = [...row];
            while (paddedRow.length < maxCols) {
              paddedRow.push("");
            }
            return paddedRow;
          });

          let markdownTable = "";

          if (isFirstRowAHeader) {
            markdownTable += "| " + paddedRows[0].join(" | ") + " |\n";
            markdownTable += "|" + Array(maxCols).fill("---").join("|") + "|\n";
            for (let i = 1; i < paddedRows.length; i++) {
              markdownTable += "| " + paddedRows[i].join(" | ") + " |\n";
            }
          } else {
            for (let i = 0; i < paddedRows.length; i++) {
              markdownTable += "| " + paddedRows[i].join(" | ") + " |\n";
              if (i === 0) {
                markdownTable +=
                  "|" + Array(maxCols).fill("---").join("|") + "|\n";
              }
            }
          }

          return markdownTable.trim();
        },
      });

      // Universal rule for paragraph tags with a fix for list item newlines
      turndownServiceInstance.addRule("paragraph", {
        filter: "p",
        replacement: (content, node) => {
          if (!content.trim()) return ""; // Ignore empty paragraphs

          let currentNode = node.parentNode;
          while (currentNode) {
            // If inside TH or TR (table headers/rows), suppress newlines.
            if (PARAGRAPH_FILTER_PARENT_NODES.includes(currentNode.nodeName)) {
              return content;
            }
            // If inside an LI (list item), add a single newline for proper separation.
            if (currentNode.nodeName === "LI") {
              return content + "\n";
            }
            currentNode = currentNode.parentNode;
          }
          // For all other cases, add double newlines for standard paragraph separation.
          return `\n\n${content}\n\n`;
        },
      });

      // ChatGPT-specific rules for handling unique elements/classes
      if (CURRENT_PLATFORM === CHATGPT) {
        turndownServiceInstance.addRule("popup-div", {
          filter: (node) =>
            node.nodeName === "DIV" &&
            node.classList.contains(CHATGPT_POPUP_DIV_CLASS),
          replacement: (content) => {
            // Convert HTML content of popups to a code block
            const textWithLineBreaks = content
              .replace(/<br\s*\/?>/gi, "\n")
              .replace(/<\/(p|div|h[1-6]|ul|ol|li)>/gi, "\n")
              .replace(/<(?:p|div|h[1-6]|ul|ol|li)[^>]*>/gi, "\n")
              .replace(/<\/?[^>]+(>|$)/g, "")
              .replace(/\n+/g, "\n");
            return "\n```\n" + textWithLineBreaks + "\n```\n";
          },
        });
        turndownServiceInstance.addRule("buttonWithSpecificClass", {
          filter: (node) =>
            node.nodeName === "BUTTON" &&
            node.classList.contains(CHATGPT_BUTTON_SPECIFIC_CLASS),
          replacement: (content) =>
            content.trim() ? `__${content}__\n\n` : "",
        });
        // turndownServiceInstance.addRule("remove-img", {
        //   filter: "img",
        //   replacement: () => "", // Remove image tags
        // });
      }

      // Gemini specific rule to remove language labels from being processed as content
      if (CURRENT_PLATFORM === GEMINI) {
        turndownServiceInstance.addRule("geminiCodeLanguageLabel", {
          filter: (node) =>
            node.nodeName === "SPAN" &&
            node.closest(".code-block-decoration") &&
            node.textContent.trim().length > 0, // Ensure it's not an an empty span
          replacement: () => "", // Replace with empty string
        });
      }

      turndownServiceInstance.addRule("images", {
        filter: (node) => node.nodeName === "IMG",
        replacement: (content, node) => {
          const src = node.getAttribute("src") || "";
          const alt = node.alt || "";
          return src ? `![${alt}](${src})` : "";
        },
      });
    },

    /**
     * Main export orchestrator. Extracts data, configures Turndown, and formats.
     * This function now filters messages based on _selectedMessageIds and visibility.
     * @param {string} format - The desired output format ('markdown' or 'json').
     */
    initiateExport(format) {
      // Use the _currentChatData that matches the outline's IDs
      const rawChatData = ChatExporter._currentChatData;
      let turndownServiceInstance = null;

      if (!rawChatData || rawChatData.messages.length === 0) {
        alert("No messages found to export.");
        return;
      }

      // --- Refresh ChatExporter._selectedMessageIds from current UI state and visibility ---
      ChatExporter._selectedMessageIds.clear(); // Clear previous state
      const outlineContainer = document.querySelector(
        `#${OUTLINE_CONTAINER_ID}`
      );
      if (outlineContainer) {
        // Only consider checkboxes that are checked AND visible
        const checkedVisibleCheckboxes = outlineContainer.querySelectorAll(
          ".outline-item-checkbox:checked"
        ); // This will only return visible ones if their parent `itemDiv` is hidden with `display:none` as `querySelectorAll` won't find them

        checkedVisibleCheckboxes.forEach((cb) => {
          // Ensure the parent element is actually visible before adding to selected
          const parentItemDiv = cb.closest("div");
          if (
            parentItemDiv &&
            window.getComputedStyle(parentItemDiv).display !== "none" &&
            cb.dataset.messageId
          ) {
            ChatExporter._selectedMessageIds.add(cb.dataset.messageId);
          }
        });

        // Also, manually add AI responses that follow selected *and visible* user messages.
        const visibleUserMessageIds = new Set();
        checkedVisibleCheckboxes.forEach((cb) => {
          const parentItemDiv = cb.closest("div");
          if (
            parentItemDiv &&
            window.getComputedStyle(parentItemDiv).display !== "none" &&
            cb.dataset.messageId
          ) {
            visibleUserMessageIds.add(cb.dataset.messageId);
          }
        });

        rawChatData.messages.forEach((msg, index) => {
          if (msg.author === "ai") {
            let prevUserMessageId = null;
            for (let i = index - 1; i >= 0; i--) {
              if (rawChatData.messages[i].author === "user") {
                prevUserMessageId = rawChatData.messages[i].id;
                break;
              }
            }
            if (
              prevUserMessageId &&
              visibleUserMessageIds.has(prevUserMessageId)
            ) {
              ChatExporter._selectedMessageIds.add(msg.id);
            }
          }
        });
      }
      // --- End Refresh ---

      // --- Filter messages based on selection ---
      const filteredMessages = rawChatData.messages.filter((msg) =>
        ChatExporter._selectedMessageIds.has(msg.id)
      );

      if (filteredMessages.length === 0) {
        alert(
          "No messages selected or visible for export. Please check at least one question in the outline or clear your search filter."
        );
        return;
      }

      // Create a new chatData object for the filtered export
      // Also, re-calculate messageCount for the filtered set
      const chatDataForExport = {
        ...rawChatData,
        messages: filteredMessages,
        messageCount: filteredMessages.filter((m) => m.author === "user")
          .length,
        exportedAt: new Date(), // Set current timestamp just before export
      };

      let fileOutput = null;
      let fileName = null;
      let mimeType = "";

      turndownServiceInstance = new TurndownService();
      ChatExporter.setupTurndownRules(turndownServiceInstance);

      if (format === "markdown") {
        // Pass the filtered chat data to formatToMarkdown
        const markdownResult = ChatExporter.formatToMarkdown(
          chatDataForExport,
          turndownServiceInstance
        );
        fileOutput = markdownResult.output;
        fileName = markdownResult.fileName;
        mimeType = "text/markdown;charset=utf-8";
      } else if (format === "json") {
        // Pass the filtered chat data to formatToJSON
        const jsonResult = ChatExporter.formatToJSON(
          chatDataForExport,
          turndownServiceInstance
        );
        fileOutput = jsonResult.output;
        fileName = jsonResult.fileName;
        mimeType = "application/json;charset=utf-8";
      } else {
        alert("Invalid export format selected.");
        return;
      }

      if (fileOutput && fileName) {
        Utils.downloadFile(fileName, fileOutput, mimeType);
      }
    },
  };

  // --- Injected CSS for Theme Overrides ---
  function injectThemeOverrideStyles() {
    const styleElement = document.createElement("style");
    styleElement.id = "ai-chat-exporter-theme-overrides";
    styleElement.textContent = `
      /* Always ensure the outline container and its children have a light theme */
      #${OUTLINE_CONTAINER_ID} {
        background-color: #fff !important;
        color: #333 !important;
      }

      /* Force the search input to have a light background and text color */
      #${OUTLINE_CONTAINER_ID} #outline-search-input {
        background-color: #fff !important;
        color: #333 !important;
        border: 1px solid #ddd !important;
      }

      /* --- Special rule for Gemini's search box on dark theme --- */
      /* Gemini's dark theme selector is very specific, so we need to match or exceed it. */
      .dark-theme #${OUTLINE_CONTAINER_ID} #outline-search-input {
        background-color: #fff !important;
        color: #333 !important;
      }

      /* Force scrollbar to be light for all browsers */
      /* For WebKit (Chrome, Safari, Gemini, ChatGPT) */
      #${OUTLINE_CONTAINER_ID} ::-webkit-scrollbar {
        width: 8px;
        background-color: #f1f1f1; /* Light track color */
      }
      
      #${OUTLINE_CONTAINER_ID} ::-webkit-scrollbar-thumb {
        background-color: #c1c1c1; /* Light thumb color */
        border-radius: 4px;
      }

      /* For Firefox */
      #${OUTLINE_CONTAINER_ID} {
        scrollbar-color: #c1c1c1 #f1f1f1 !important; /* Light thumb and track */
        scrollbar-width: thin !important;
      }
    `;
    document.head.appendChild(styleElement);
  }

  // --- UI Management ---
  const UIManager = {
    /**
     * Stores the timeout ID for the alert's auto-hide.
     * @type {number|null}
     */
    alertTimeoutId: null,
    _outlineIsCollapsed: false, // State for the outline collapse
    _lastProcessedChatUrl: null, // Track the last processed chat URL for Gemini
    _initialListenersAttached: false, // Track if the URL change handlers are initialized

    /**
     * Determines the appropriate width for the alert based on the chat's content area.
     * @returns {string} The width in pixels (e.g., '600px').
     */
    getTargetContentWidth() {
      let targetElement = null;
      let width = 0;

      if (CURRENT_PLATFORM === CHATGPT) {
        // Try to find the specific input container for ChatGPT
        targetElement = document.querySelector(
          "form > div.relative.flex.h-full.max-w-full.flex-1.flex-col"
        );
        if (!targetElement) {
          // Fallback to a broader chat content container if the specific input container is not found
          targetElement = document.querySelector(
            "div.w-full.md\\:max-w-2xl.lg\\:max-w-3xl.xl\\:max-w-4xl.flex-shrink-0.px-4"
          );
        }
      } else if (CURRENT_PLATFORM === GEMINI) {
        // Try to find the specific input container for Gemini
        targetElement = document.querySelector(
          "gb-chat-input-textarea-container"
        );
        if (!targetElement) {
          // Fallback to the main input section container
          targetElement = document.querySelector(
            "div.flex.flex-col.w-full.relative.max-w-3xl.m-auto"
          );
        }
      }

      if (targetElement) {
        width = targetElement.offsetWidth;
      }

      // Apply a reasonable min/max to prevent extreme sizes
      if (width < 350) width = 350; // Minimum width
      if (width > 900) width = 900; // Maximum width for very wide monitors

      return `${width}px`;
    },

    /**
     * Adds the export buttons to the current page.
     */
    addExportControls() {
      if (document.querySelector(`#${EXPORT_CONTAINER_ID}`)) {
        return; // Controls already exist
      }

      const container = document.createElement("div");
      container.id = EXPORT_CONTAINER_ID;
      Utils.applyStyles(container, COMMON_CONTROL_PROPS);

      const markdownButton = document.createElement("button");
      markdownButton.id = "export-markdown-btn";
      markdownButton.textContent = "⬇ Export MD";
      markdownButton.title = `${EXPORT_BUTTON_TITLE_PREFIX}: Export to Markdown`;
      Utils.applyStyles(markdownButton, BUTTON_BASE_PROPS);
      markdownButton.onclick = () => ChatExporter.initiateExport("markdown");
      container.appendChild(markdownButton);

      const jsonButton = document.createElement("button");
      jsonButton.id = "export-json-btn";
      jsonButton.textContent = "⬇ JSON";
      jsonButton.title = `${EXPORT_BUTTON_TITLE_PREFIX}: Export to JSON`;
      Utils.applyStyles(jsonButton, {
        ...BUTTON_BASE_PROPS,
        ...BUTTON_SPACING_PROPS,
      });
      jsonButton.onclick = () => ChatExporter.initiateExport("json");
      container.appendChild(jsonButton);

      // --- Settings Button (NEW) ---
      const settingsButton = document.createElement("button");
      settingsButton.className = "export-button-settings";
      settingsButton.textContent = "⚙️";
      settingsButton.title = `${EXPORT_BUTTON_TITLE_PREFIX}: ⚙️ Settings: Configure Filename Format`;
      Utils.applyStyles(settingsButton, {
        ...BUTTON_BASE_PROPS,
        ...BUTTON_SPACING_PROPS,
      });
      settingsButton.addEventListener("click", () => {
        const currentFormat = GM_getValue(
          GM_OUTPUT_FILE_FORMAT,
          OUTPUT_FILE_FORMAT_DEFAULT
        );
        const newFormat = window.prompt(
          `+++++++  ${EXPORT_BUTTON_TITLE_PREFIX}  +++++++\n\n ` +
            `ENTER NEW FILENAME FORMAT:\n` +
            ` • sample1: {platform}__{tag1}__{title}__{timestampLocal}\n` +
            ` • sample2: {tag1}__{title}-v{exporter}-{timestamp}\n` +
            ` • current: ${currentFormat}\n\n` +
            `valid placeholders: \n  ` +
            `- {platform}              : e.g. chatgpt, gemini\n  ` +
            `- {title}                      : title, with tags removed\n  ` +
            `- {timestamp}          : YYYY-MM-DDTHH-mm-ss.sssZ\n  ` +
            `- {timestampLocal}: YYYY-MM-DDTHH-mm-ss[+/-]HHMM\n  ` +
            `- {tags}                     : all tags, hyphen-separated\n  ` +
            `- {tag1}                     : 1st tag\n  ` +
            `- {tag2}                     : 2nd tag\n  ` +
            `  ...\n  ` +
            `- {tag9}                     : 9th tag\n  ` +
            `- {exporter}             : AI Chat Exporter version\n`,
          currentFormat
        );

        if (newFormat !== null && newFormat !== currentFormat) {
          GM_setValue(GM_OUTPUT_FILE_FORMAT, newFormat);
          alert("Filename format updated successfully!");
          console.log("New filename format saved:", newFormat);
        } else if (newFormat === currentFormat) {
          // User clicked OK but didn't change the value, or entered same value
          console.log("Filename format not changed.");
        } else {
          // User clicked Cancel
          console.log("Filename format update cancelled.");
        }
      });
      container.appendChild(settingsButton);
      // --- End Settings Button ---

      document.body.appendChild(container);
    },

    /**
     * Adds and manages the collapsible outline div.
     */
    addOutlineControls() {
      let outlineContainer = document.querySelector(`#${OUTLINE_CONTAINER_ID}`);
      if (!outlineContainer) {
        outlineContainer = document.createElement("div");
        outlineContainer.id = OUTLINE_CONTAINER_ID;
        document.body.appendChild(outlineContainer);
      }

      // Apply base styles
      Utils.applyStyles(outlineContainer, OUTLINE_CONTAINER_PROPS);

      // Apply collapsed styles if state is collapsed
      if (UIManager._outlineIsCollapsed) {
        Utils.applyStyles(outlineContainer, OUTLINE_CONTAINER_COLLAPSED_PROPS);
      }

      UIManager.generateOutlineContent();
    },

    /**
     * Generates and updates the content of the outline div.
     * This function should be called whenever the chat data changes.
     */
    generateOutlineContent() {
      const outlineContainer = document.querySelector(
        `#${OUTLINE_CONTAINER_ID}`
      );
      if (!outlineContainer) return;

      // Extract fresh chat data
      let freshChatData = null;
      switch (CURRENT_PLATFORM) {
        case CHATGPT:
          freshChatData = ChatExporter.extractChatGPTChatData(document);
          break;
        case CLAUDE:
          freshChatData = ChatExporter.extractClaudeChatData(document);
          break;
        case COPILOT:
          freshChatData = ChatExporter.extractCopilotChatData(document);
          break;
        case GEMINI:
          freshChatData = ChatExporter.extractGeminiChatData(document);
          break;
        default:
          outlineContainer.style.display = "none"; // Hide if not supported
          return;
      }

      // Check if chat data has changed significantly to warrant a re-render
      // Compare message count and content of the last few messages as a heuristic
      // This is to avoid regenerating the outline on every minor DOM change.
      const hasDataChanged =
        !ChatExporter._currentChatData || // No previous data
        !freshChatData || // No new data
        freshChatData._raw_title !== ChatExporter._currentChatData._raw_title ||
        freshChatData.messages.length !==
          ChatExporter._currentChatData.messages.length ||
        (freshChatData.messages.length > 0 &&
          ChatExporter._currentChatData.messages.length > 0 &&
          freshChatData.messages[freshChatData.messages.length - 1]
            .contentText !==
            ChatExporter._currentChatData.messages[
              ChatExporter._currentChatData.messages.length - 1
            ].contentText);

      if (!hasDataChanged) {
        // If data hasn't changed, just ensure visibility based on message presence
        outlineContainer.style.display =
          freshChatData && freshChatData.messages.length > 0 ? "flex" : "none";
        return; // No need to regenerate content
      }

      // Update stored chat data
      ChatExporter._currentChatData = freshChatData;

      // Hide if no messages after update
      if (
        !ChatExporter._currentChatData ||
        ChatExporter._currentChatData.messages.length === 0
      ) {
        outlineContainer.style.display = "none";
        return;
      } else {
        outlineContainer.style.display = "flex";
      }

      // Clear existing content safely to avoid TrustedHTML error
      while (outlineContainer.firstChild) {
        outlineContainer.removeChild(outlineContainer.firstChild);
      }

      // Reset selections and check all by default (only on fresh rebuild)
      ChatExporter._selectedMessageIds.clear();

      // Header for Chat Outline (always visible)
      const headerDiv = document.createElement("div");
      Utils.applyStyles(headerDiv, OUTLINE_HEADER_PROPS);
      headerDiv.title = `AI Chat Exporter v${EXPORTER_VERSION}`;
      headerDiv.onclick = UIManager.toggleOutlineCollapse; // Only this div handles collapse

      const headerSpan = document.createElement("span");
      headerSpan.textContent = "AI Chat Exporter: Chat Outline";
      headerDiv.appendChild(headerSpan);

      const toggleButton = document.createElement("button");
      toggleButton.id = "outline-toggle-btn";
      toggleButton.textContent = UIManager._outlineIsCollapsed ? "▲" : "▼"; // Up/Down arrow
      Utils.applyStyles(toggleButton, OUTLINE_TOGGLE_BUTTON_PROPS);
      headerDiv.appendChild(toggleButton);

      outlineContainer.appendChild(headerDiv);

      const titleDiv = document.createElement("div");
      Utils.applyStyles(titleDiv, OUTLINE_TITLE_PROPS);
      titleDiv.textContent = freshChatData.title || DEFAULT_CHAT_TITLE;
      titleDiv.title = "tags: " + freshChatData.tags.join(", ");
      titleDiv.id = OUTLINE_TITLE_ID;
      outlineContainer.appendChild(titleDiv);

      // New: Select All checkbox and label section (below header)
      const selectAllContainer = document.createElement("div");
      Utils.applyStyles(selectAllContainer, SELECT_ALL_CONTAINER_PROPS);
      selectAllContainer.id = "outline-select-all-container"; // For easier hiding/showing

      const masterCheckbox = document.createElement("input");
      masterCheckbox.type = "checkbox";
      masterCheckbox.id = "outline-select-all";
      masterCheckbox.checked = true; // Default to checked
      Utils.applyStyles(masterCheckbox, OUTLINE_CHECKBOX_PROPS);
      // masterCheckbox.onchange will be set later after updateSelectedCountDisplay is defined and elements exist
      selectAllContainer.appendChild(masterCheckbox);

      const selectAllLabel = document.createElement("span");
      selectAllContainer.appendChild(selectAllLabel); // Append label here, content set later
      outlineContainer.appendChild(selectAllContainer);

      // Search Bar
      const searchInput = document.createElement("input");
      searchInput.type = "text";
      searchInput.id = "outline-search-input";
      searchInput.placeholder =
        "Search text or regex in user queries & AI responses.";
      Utils.applyStyles(searchInput, SEARCH_INPUT_PROPS);
      outlineContainer.appendChild(searchInput);

      const noMatchMessage = document.createElement("div");
      noMatchMessage.id = "outline-no-match-message";
      noMatchMessage.textContent = "Your search text didn't match any items";
      Utils.applyStyles(noMatchMessage, NO_MATCH_MESSAGE_PROPS);
      noMatchMessage.style.display = "none"; // Hidden by default
      outlineContainer.appendChild(noMatchMessage);

      const hr = document.createElement("hr"); // Horizontal rule
      hr.style.cssText =
        "border: none; border-top: 1px solid #eee; margin: 5px 0;";
      outlineContainer.appendChild(hr);

      // List of messages
      const messageListDiv = document.createElement("div");
      messageListDiv.id = "outline-message-list";
      Utils.applyStyles(messageListDiv, MESSAGE_LIST_PROPS);

      let userQuestionCount = 0; // This will be 'y' (total items)

      const updateSelectedCountDisplay = () => {
        const totalUserMessages = userQuestionCount; // 'y'
        let selectedAndVisibleMessages = 0;

        // Only count if the outline is not collapsed
        if (!UIManager._outlineIsCollapsed) {
          const allCheckboxes = outlineContainer.querySelectorAll(
            ".outline-item-checkbox"
          );
          allCheckboxes.forEach((checkbox) => {
            // Check if the checkbox is checked AND its parent div is visible due to search filter
            const parentItemDiv = checkbox.closest("div");
            if (
              checkbox.checked &&
              parentItemDiv &&
              window.getComputedStyle(parentItemDiv).display !== "none"
            ) {
              selectedAndVisibleMessages++;
            }
          });
        }

        // Clear existing content safely
        while (selectAllLabel.firstChild) {
          selectAllLabel.removeChild(selectAllLabel.firstChild);
        }

        // Create a strong element for bold text
        const strongElement = document.createElement("strong");
        strongElement.appendChild(
          document.createTextNode("Items to export:  ")
        );
        strongElement.appendChild(
          document.createTextNode(selectedAndVisibleMessages.toString())
        );
        strongElement.appendChild(document.createTextNode(" out of "));
        strongElement.appendChild(
          document.createTextNode(totalUserMessages.toString())
        );

        selectAllLabel.appendChild(strongElement);
      };

      // Store references to the actual itemDiv elements for easy access during search
      const outlineItemElements = new Map(); // Map<messageId, itemDiv>

      ChatExporter._currentChatData.messages.forEach((msg, index) => {
        if (msg.author === "user") {
          userQuestionCount++; // Increment 'y'
          const itemDiv = document.createElement("div");
          Utils.applyStyles(itemDiv, OUTLINE_ITEM_PROPS);
          itemDiv.dataset.userMessageId = msg.id; // Store user message ID for search lookup

          const checkbox = document.createElement("input");
          checkbox.type = "checkbox";
          checkbox.checked = true; // Default to checked
          checkbox.className = "outline-item-checkbox"; // Add class for easy selection
          checkbox.dataset.messageId = msg.id; // Store message ID on checkbox
          Utils.applyStyles(checkbox, OUTLINE_CHECKBOX_PROPS);
          checkbox.onchange = (e) => {
            // Update master checkbox state based on individual checkboxes
            const allVisibleCheckboxes = Array.from(
              outlineContainer.querySelectorAll(
                ".outline-item-checkbox:not([style*='display: none'])"
              )
            );
            const allVisibleChecked = allVisibleCheckboxes.every(
              (cb) => cb.checked
            );
            masterCheckbox.checked = allVisibleChecked;
            updateSelectedCountDisplay(); // Update count on individual checkbox change
          };
          itemDiv.appendChild(checkbox);

          const itemText = document.createElement("span");
          itemText.textContent = `${userQuestionCount}: ${Utils.truncate(
            msg.contentText,
            40
          )}`; // Truncate to 40
          itemText.style.cursor = "pointer"; // Set cursor to hand
          itemText.style.textDecoration = "none"; // Remove underline
          itemText.title = `${userQuestionCount}: ${Utils.truncate(
            msg.contentText.replace(/\n+/g, "\n"),
            140
          )}`; // Truncate to 140 // Add tooltip

          // Add hover effect
          itemText.onmouseover = () => {
            itemText.style.backgroundColor = "#f0f0f0"; // Light gray background on hover
            itemText.style.color = "#5b3f87"; // Change text color on hover
          };
          itemText.onmouseout = () => {
            itemText.style.backgroundColor = "transparent"; // Revert background on mouse out
            itemText.style.color = "#333"; // Revert text color on mouse out (assuming default is #333, adjust if needed)
          };

          itemText.onclick = () => {
            // Find the original message element using the stored contentHtml reference
            const messageElement = ChatExporter._currentChatData.messages.find(
              (m) => m.id === msg.id
            )?.contentHtml;
            // console.log("clicked on message", msg.id, messageElement);
            if (messageElement) {
              messageElement.scrollIntoView({
                behavior: "smooth",
                block: "start",
              });
            }
          };
          itemDiv.appendChild(itemText);

          messageListDiv.appendChild(itemDiv);
          outlineItemElements.set(msg.id, itemDiv);

          // Add to selected IDs by default (will be refreshed on export anyway)
          ChatExporter._selectedMessageIds.add(msg.id);
        } else {
          // For AI responses, if they follow a selected user message, also add them to selected IDs
          // This is a pre-population, actual selection is determined on export.
          const prevUserMessage = ChatExporter._currentChatData.messages.find(
            (m, i) =>
              i < ChatExporter._currentChatData.messages.indexOf(msg) &&
              m.author === "user"
          );
          if (
            prevUserMessage &&
            ChatExporter._selectedMessageIds.has(prevUserMessage.id)
          ) {
            ChatExporter._selectedMessageIds.add(msg.id);
          }
        }
      });

      // Now set the master checkbox onchange after userQuestionCount is final
      masterCheckbox.onchange = (e) => {
        const isChecked = e.target.checked;
        // Only toggle visible checkboxes
        const visibleCheckboxes = outlineContainer.querySelectorAll(
          ".outline-item-checkbox:not([style*='display: none'])"
        );
        visibleCheckboxes.forEach((cb) => {
          cb.checked = isChecked;
        });
        updateSelectedCountDisplay(); // Update count on master checkbox change
      };

      outlineContainer.appendChild(messageListDiv);

      // Initial call to set the display text once all checkboxes are rendered and userQuestionCount is final
      // This call is now placed AFTER messageListDiv (containing all checkboxes) is appended to outlineContainer.
      updateSelectedCountDisplay();

      // --- Search Bar Logic ---
      searchInput.oninput = () => {
        const searchText = searchInput.value.trim(); // Get the raw input text
        let anyMatchFound = false;
        let searchRegex;
        let regexError = false;

        // Reset previous error message and style
        noMatchMessage.textContent = "Your search text didn't match any items";
        noMatchMessage.style.color = "#7e7e7e"; // Default color

        if (searchText === "") {
          // If search text is empty, no regex is needed, all items will be shown
        } else {
          try {
            // Create a RegExp object from the search input.
            // The 'i' flag is added by default for case-insensitive search.
            // Users can still specify other flags (e.g., /pattern/gi) directly in the input.
            searchRegex = new RegExp(searchText, "i");
          } catch (e) {
            regexError = true;
            // Display an error message for invalid regex
            noMatchMessage.textContent = `Invalid regex: ${e.message}`;
            noMatchMessage.style.color = "red"; // Make error message red
            noMatchMessage.style.display = "block";
            messageListDiv.style.display = "none";

            // Hide all outline items if there's a regex error
            outlineItemElements.forEach((itemDiv) => {
              itemDiv.style.display = "none";
            });
            masterCheckbox.checked = false; // No valid visible items
            updateSelectedCountDisplay(); // Update the count display
            return; // Exit the function early if regex is invalid
          }
        }

        const messages = ChatExporter._currentChatData.messages;
        const userMessageMap = new Map();

        // Group user messages with their immediate AI responses
        for (let i = 0; i < messages.length; i++) {
          const msg = messages[i];
          if (msg.author === "user") {
            const userMsg = msg;
            let aiMsg = null;
            if (i + 1 < messages.length && messages[i + 1].author === "ai") {
              aiMsg = messages[i + 1];
            }
            userMessageMap.set(userMsg.id, { user: userMsg, ai: aiMsg });
          }
        }

        outlineItemElements.forEach((itemDiv, userMsgId) => {
          const userAiPair = userMessageMap.get(userMsgId);
          let match = false;

          if (userAiPair) {
            const userContent = userAiPair.user.contentText;
            const aiContent = userAiPair.ai ? userAiPair.ai.contentText : "";

            if (searchText === "") {
              match = true; // If search box is empty, consider it a match (show all)
            } else if (searchRegex) {
              // Use regex.test() for matching against content
              if (
                searchRegex.test(userContent) ||
                searchRegex.test(aiContent)
              ) {
                match = true;
              }
            }
          }

          if (match) {
            itemDiv.style.display = "flex";
            anyMatchFound = true;
          } else {
            itemDiv.style.display = "none";
          }
        });

        // Show/hide no match message and adjust message list visibility
        if (searchText !== "" && !anyMatchFound && !regexError) {
          noMatchMessage.style.display = "block";
          messageListDiv.style.display = "none";
        } else if (searchText === "" || anyMatchFound) {
          noMatchMessage.style.display = "none";
          if (!UIManager._outlineIsCollapsed) {
            // Only show message list if outline is expanded
            // Keep this as a fallback if messageListDiv display is not primarily controlled by flexGrow
            messageListDiv.style.display = "block";
          }
        }

        // After filtering, update master checkbox and count display based on visible items
        const visibleCheckboxes = outlineContainer.querySelectorAll(
          ".outline-item-checkbox:not([style*='display: none'])"
        );
        const allVisibleChecked =
          visibleCheckboxes.length > 0 &&
          Array.from(visibleCheckboxes).every((cb) => cb.checked);
        masterCheckbox.checked = allVisibleChecked;
        updateSelectedCountDisplay();
      };
      // --- End Search Bar Logic ---

      // Ensure visibility based on collapse state
      if (UIManager._outlineIsCollapsed) {
        titleDiv.style.display = "none";
        selectAllContainer.style.display = "none";
        searchInput.style.display = "none";
        noMatchMessage.style.display = "none";
        hr.style.display = "none";
        messageListDiv.style.display = "none";
      } else {
        titleDiv.style.display = "flex";
        selectAllContainer.style.display = "flex";
        searchInput.style.display = "block";
        // noMatchMessage and messageListDiv display will be handled by searchInput.oninput
        hr.style.display = "block";
      }
    },

    /**
     * Toggles the collapse state of the outline div.
     */
    toggleOutlineCollapse() {
      UIManager._outlineIsCollapsed = !UIManager._outlineIsCollapsed;
      // New: Save the new state to localStorage
      localStorage.setItem(
        OUTLINE_COLLAPSED_STATE_KEY,
        UIManager._outlineIsCollapsed.toString()
      );

      const outlineContainer = document.querySelector(
        `#${OUTLINE_CONTAINER_ID}`
      );
      const titleDiv = document.querySelector(`#${OUTLINE_TITLE_ID}`);
      const selectAllContainer = document.querySelector(
        "#outline-select-all-container"
      );
      const searchInput = document.querySelector("#outline-search-input");
      const noMatchMessage = document.querySelector(
        "#outline-no-match-message"
      );
      const hr = outlineContainer.querySelector("hr");
      const messageListDiv = document.querySelector("#outline-message-list");
      const toggleButton = document.querySelector("#outline-toggle-btn");

      if (UIManager._outlineIsCollapsed) {
        Utils.applyStyles(outlineContainer, {
          ...OUTLINE_CONTAINER_PROPS,
          ...OUTLINE_CONTAINER_COLLAPSED_PROPS,
        });
        if (titleDiv) titleDiv.style.display = "none";
        if (selectAllContainer) selectAllContainer.style.display = "none";
        if (searchInput) searchInput.style.display = "none";
        if (noMatchMessage) noMatchMessage.style.display = "none";
        if (hr) hr.style.display = "none";
        if (messageListDiv) messageListDiv.style.display = "none";
        if (toggleButton) toggleButton.textContent = "▲";
      } else {
        Utils.applyStyles(outlineContainer, OUTLINE_CONTAINER_PROPS);
        if (titleDiv) titleDiv.style.display = "flex";
        if (selectAllContainer) selectAllContainer.style.display = "flex";
        if (searchInput) searchInput.style.display = "block";
        // noMatchMessage and messageListDiv display depend on search state, not just collapse
        if (hr) hr.style.display = "block";
        // Trigger a re-evaluation of search filter if it was active
        const currentSearchText = searchInput
          ? searchInput.value.toLowerCase().trim()
          : "";
        if (currentSearchText !== "") {
          searchInput.dispatchEvent(new Event("input")); // Re-run search filter
        } else {
          // If no search text, ensure all messages are visible
          if (messageListDiv) messageListDiv.style.display = "block";
          const allItems = outlineContainer.querySelectorAll(
            ".outline-item-checkbox"
          );
          allItems.forEach((cb) => {
            const parentDiv = cb.closest("div");
            if (parentDiv) parentDiv.style.display = "flex";
          });
          if (noMatchMessage) noMatchMessage.style.display = "none";
        }
        if (toggleButton) toggleButton.textContent = "▼";
      }
    },

    /**
     * Displays a non-obstructive alert message.
     * @param {string} message The message to display.
     */
    showAlert(message) {
      // Clear any existing auto-hide timeout before showing a new alert
      if (UIManager.alertTimeoutId) {
        clearTimeout(UIManager.alertTimeoutId);
        UIManager.alertTimeoutId = null;
      }

      // Only show alert if the flag is not set in local storage
      if (localStorage.getItem(HIDE_ALERT_FLAG) === "true") {
        return;
      }

      // Check if alert is already present to avoid multiple instances.
      // If it is, and we're trying to show a new one, remove the old one first.
      let alertContainer = document.querySelector(`#${ALERT_CONTAINER_ID}`);
      if (alertContainer) {
        alertContainer.remove();
      }

      alertContainer = document.createElement("div");
      alertContainer.id = ALERT_CONTAINER_ID;
      Utils.applyStyles(alertContainer, ALERT_PROPS);
      // Set dynamic max-width
      alertContainer.style.maxWidth = UIManager.getTargetContentWidth();

      // New: Title for the alert
      const titleElement = document.createElement("strong");
      titleElement.textContent = EXPORT_BUTTON_TITLE_PREFIX; // Use the global variable for title
      titleElement.style.display = "block"; // Ensure it takes full width and breaks line
      titleElement.style.marginBottom = "8px"; // Spacing before the message
      titleElement.style.fontSize = "16px"; // Slightly larger font for title
      titleElement.style.width = "100%"; // Take full available width of the alert box
      titleElement.style.textAlign = "center"; // Center the title
      alertContainer.appendChild(titleElement);

      // Message row with close button
      const messageRow = document.createElement("div");
      Utils.applyStyles(messageRow, ALERT_MESSAGE_ROW_PROPS);

      const messageSpan = document.createElement("span");
      messageSpan.textContent = message;
      messageRow.appendChild(messageSpan);

      const closeButton = document.createElement("button");
      closeButton.textContent = "×";
      Utils.applyStyles(closeButton, ALERT_CLOSE_BUTTON_PROPS);
      messageRow.appendChild(closeButton);
      alertContainer.appendChild(messageRow);

      // Checkbox for "never show again"
      const checkboxContainer = document.createElement("div");
      Utils.applyStyles(checkboxContainer, ALERT_CHECKBOX_CONTAINER_PROPS);

      const hideCheckbox = document.createElement("input");
      hideCheckbox.type = "checkbox";
      hideCheckbox.id = "hide-exporter-alert";
      Utils.applyStyles(hideCheckbox, ALERT_CHECKBOX_PROPS);
      checkboxContainer.appendChild(hideCheckbox);

      const label = document.createElement("label");
      label.htmlFor = "hide-exporter-alert";
      label.textContent = "Don't show this again";
      checkboxContainer.appendChild(label);
      alertContainer.appendChild(checkboxContainer);

      document.body.appendChild(alertContainer);

      // Function to hide and remove the alert
      const hideAndRemoveAlert = () => {
        alertContainer.style.opacity = "0";
        setTimeout(() => {
          if (alertContainer) {
            // Check if element still exists before removing
            alertContainer.remove();
          }
          UIManager.alertTimeoutId = null; // Reset timeout ID
        }, 500); // Remove after fade out
      };

      // Event listener for close button
      closeButton.onclick = () => {
        if (hideCheckbox.checked) {
          localStorage.setItem(HIDE_ALERT_FLAG, "true");
        }
        hideAndRemoveAlert();
      };

      // Set auto-hide timeout
      UIManager.alertTimeoutId = setTimeout(() => {
        // Only auto-hide if the checkbox is NOT checked
        if (
          alertContainer &&
          alertContainer.parentNode &&
          !hideCheckbox.checked
        ) {
          hideAndRemoveAlert();
        } else {
          UIManager.alertTimeoutId = null; // Clear if not auto-hiding
        }
      }, ALERT_AUTO_CLOSE_DURATION); // Use the defined duration
    },

    /**
     * Attempts to auto-scroll the Gemini chat to the top to load all messages.
     * This function uses an iterative approach to handle dynamic loading.
     */
    autoScrollToTop: async function () {
      if (CURRENT_PLATFORM !== GEMINI) {
        // console.log("autoScrollToTop: Not on a Gemini hostname. Returning early.");
        return;
      }

      // Track the current URL to avoid re-scrolling the same chat repeatedly
      const currentUrl = window.location.href;

      // New: Check if we have already effectively started auto-scrolling for this URL.
      // UIManager._lastProcessedChatUrl will be null initially, or explicitly reset by handleUrlChange for new URLs.
      // It will be set to currentUrl *after* the initial message element is found.
      if (UIManager._lastProcessedChatUrl === currentUrl) {
        console.log(
          "Auto-scroll already initiated or completed for this URL. Skipping."
        );
        return;
      }

      // console.log(`Auto-scroll triggered for new URL: ${currentUrl}`);

      let scrollableElement =
        document.querySelector('[data-test-id="chat-history-container"]') || // **PRIMARY TARGET (CONFIRMED BY LOGS)**
        document.querySelector("#chat-history") || // Fallback to chat history div by ID
        document.querySelector("main") || // Fallback to main element
        document.documentElement; // Final fallback to the document's root element

      if (!scrollableElement) {
        // UIManager.showAlert(
        //   "Error: Could not find chat scroll area. Auto-scroll failed."
        // );
        return;
      }

      // UIManager.showAlert(
      //   "Auto-scrolling to load entire chat... Please wait."
      // );

      const AUTOSCROLL_MAT_PROGRESS_BAR_POLL_INTERVAL = 50;
      const AUTOSCROLL_MAT_PROGRESS_BAR_APPEAR_TIMEOUT = 3000;
      const AUTOSCROLL_MAT_PROGRESS_BAR_DISAPPEAR_TIMEOUT = 5000;
      const AUTOSCROLL_REPEAT_DELAY = 500;
      const AUTOSCROLL_MAX_RETRY = 3;
      const MESSAGE_ELEMENT_APPEAR_TIMEOUT = 5000;

      let previousMessageCount = -1;
      let retriesForProgressBar = 0;

      const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

      const waitForElementToAppear = async (
        selector,
        timeoutMs,
        checkInterval = AUTOSCROLL_MAT_PROGRESS_BAR_POLL_INTERVAL
      ) => {
        const startTime = Date.now();
        return new Promise((resolve) => {
          const interval = setInterval(() => {
            const element = document.querySelector(selector);
            if (element) {
              clearInterval(interval);
              resolve(element);
            } else if (Date.now() - startTime > timeoutMs) {
              clearInterval(interval);
              resolve(null);
            }
          }, checkInterval);
        });
      };

      const waitForElementToDisappear = async (
        selector,
        timeoutMs,
        checkInterval = AUTOSCROLL_MAT_PROGRESS_BAR_POLL_INTERVAL
      ) => {
        const startTime = Date.now();
        return new Promise((resolve) => {
          const interval = setInterval(() => {
            const element = document.querySelector(selector);
            if (
              !element ||
              (element.offsetWidth === 0 && element.offsetHeight === 0)
            ) {
              clearInterval(interval);
              resolve(true);
            } else if (Date.now() - startTime > timeoutMs) {
              clearInterval(interval);
              console.warn(
                `waitForElementToDisappear: Timeout waiting for '${selector}' to disappear.`
              );
              resolve(false);
            }
          }, checkInterval);
        });
      };

      // --- Wait for initial chat messages to appear ---
      // This is crucial for new chat loads from sidebar clicks.
      // console.log("Waiting for initial chat message elements...");
      const initialMessageElement = await waitForElementToAppear(
        GEMINI_MESSAGE_ITEM_SELECTOR,
        MESSAGE_ELEMENT_APPEAR_TIMEOUT
      );

      if (!initialMessageElement) {
        // UIManager.showAlert(
        //   "Timeout waiting for chat messages to appear. Auto-scroll cannot proceed."
        // );
        console.error(
          "Initial chat message elements did not appear within timeout."
        );
        // If initial messages don't appear, this URL was not successfully processed for auto-scroll.
        // So, reset _lastProcessedChatUrl to null to allow a retry or a different trigger for this URL.
        UIManager._lastProcessedChatUrl = null; // Add this line
        return;
      }
      // console.log("Initial chat message elements found. Starting scroll loop.");

      // Mark this URL as processed *only after* initial messages are found.
      // This ensures that autoScrollToTop will proceed if called for a new URL,
      // and will block subsequent calls for the *same* URL until _lastProcessedChatUrl is reset by handleUrlChange.
      UIManager._lastProcessedChatUrl = currentUrl; // Move this line from the beginning to here.

      // --- IMPORTANT: Attach URL change listeners here after initial chat message elements appears ---
      if (!UIManager._initialListenersAttached) {
        // Only attach them once
        UIManager.initUrlChangeObserver();
        UIManager._initialListenersAttached = true; // Mark that they are attached
      }

      while (true) {
        scrollableElement.scrollTop = 0;
        await delay(50); // Small delay after scroll

        // console.log("Scrolling to top, checking for progress bar...");
        const progressBarElement = await waitForElementToAppear(
          "mat-progress-bar.mdc-linear-progress--indeterminate",
          AUTOSCROLL_MAT_PROGRESS_BAR_APPEAR_TIMEOUT
        );

        if (progressBarElement) {
          retriesForProgressBar = 0; // Reset retries if progress bar appeared
          // console.log("Progress bar appeared. Waiting for it to disappear...");
          const disappeared = await waitForElementToDisappear(
            "mat-progress-bar.mdc-linear-progress--indeterminate",
            AUTOSCROLL_MAT_PROGRESS_BAR_DISAPPEAR_TIMEOUT
          );
          if (!disappeared) {
            console.warn(
              "autoScrollToTop: mat-progress-bar did not disappear within expected time."
            );
          }
        } else {
          // If progress bar doesn't appear, increment retry count
          retriesForProgressBar++;

          if (retriesForProgressBar > AUTOSCROLL_MAX_RETRY) {
            break;
          }
          await delay(AUTOSCROLL_REPEAT_DELAY);
          continue; // Continue loop to try scrolling again
        }

        const currentChatData = ChatExporter.extractGeminiChatData(document);
        const currentMessageCount = currentChatData
          ? currentChatData.messages.length
          : 0;

        if (currentMessageCount > previousMessageCount) {
          previousMessageCount = currentMessageCount;
          retriesForProgressBar = 0; // Reset retries if new messages found
        } else {
          // No new messages detected after a scroll attempt (and progress bar check)
          // If we had messages before, and now no new ones, it means we reached the top.
          // console.log("autoScrollToTop: No NEW messages detected after this load cycle. Checking for termination conditions.");
          if (previousMessageCount !== -1) {
            // console.log("autoScrollToTop: Assuming end of chat due to no new messages after loading.");
            break;
          }
        }

        await delay(AUTOSCROLL_REPEAT_DELAY);
      }

      // console.log("autoScrollToTop: Auto-scroll process complete. Final message count:", previousMessageCount);
      // UIManager.showAlert(
      //   "Auto-scroll complete. You can now export your chat."
      // );
      UIManager.addOutlineControls();
    },

    /**
     * Handles URL changes to trigger auto-scroll for new Gemini chats.
     * This will only be attached AFTER the initial page load auto-scroll finishes.
     */
    handleUrlChange: function () {
      const newUrl = window.location.href;
      // console.log(
      //   "URL Change Detected (popstate or customHistoryChange):",
      //   newUrl
      // );

      const isGeminiChatUrl =
        GEMINI_HOSTNAMES.some((host) => newUrl.includes(host)) &&
        newUrl.includes("/app");

      if (isGeminiChatUrl) {
        // Trigger auto-scroll for valid Gemini chat URLs.
        setTimeout(() => {
          UIManager.autoScrollToTop();
        }, 100); // Small delay to allow DOM to update before triggering
      } else {
        console.log(
          "URL is not a Gemini chat URL. Skipping auto-scroll for:",
          newUrl
        );
      }
    },

    /**
     * Initializes a MutationObserver to ensure the controls are always present
     * and to regenerate the outline on DOM changes.
     */
    initObserver() {
      const observer = new MutationObserver((mutations) => {
        // Only re-add export controls if they are missing
        if (!document.querySelector(`#${EXPORT_CONTAINER_ID}`)) {
          UIManager.addExportControls();
        }
        // Always ensure outline controls are present and regenerate content on changes
        // This covers new messages, and for Gemini, scrolling up to load more content.
        UIManager.addOutlineControls();
      });

      // Selector that includes chat messages and where new messages are added
      let targetNode = null;
      switch (CURRENT_PLATFORM) {
        case COPILOT:
          targetNode =
            document.querySelector('[data-content="conversation"]') ||
            document.body;
          break;
        case GEMINI:
          targetNode = document.querySelector("#__next") || document.body;
          break;
        default:
          targetNode = document.querySelector("main") || document.body;
      }

      observer.observe(targetNode, {
        childList: true,
        subtree: true,
        attributes: false,
      });

      // Additionally, for Gemini, listen for scroll events on the window or a specific scrollable div
      // if MutationObserver isn't sufficient for detecting all content loads.
      if (CURRENT_PLATFORM === GEMINI) {
        let scrollTimeout;
        window.addEventListener(
          "scroll",
          () => {
            clearTimeout(scrollTimeout);
            scrollTimeout = setTimeout(() => {
              // Only regenerate if title or tags are different or current data count is less than actual count (implies more loaded)
              const newChatData = ChatExporter.extractGeminiChatData(document);
              if (
                newChatData &&
                ChatExporter._currentChatData &&
                (newChatData._raw_title !==
                  ChatExporter._currentChatData._raw_title ||
                  newChatData.messages.length >
                    ChatExporter._currentChatData.messages.length)
              ) {
                UIManager.addOutlineControls(); // Regenerate outline
              }
            }, 500); // Debounce scroll events
          },
          true
        ); // Use capture phase to ensure it works
      }
    },

    /**
     * Sets up the event listeners for URL changes (popstate and customHistoryChange).
     * This function will be called *after* the initial page load auto-scroll.
     */
    initUrlChangeObserver: function () {
      // console.log("Attaching URL change listeners.");
      window.addEventListener("popstate", UIManager.handleUrlChange);

      // Overwrite history.pushState and history.replaceState to dispatch custom event
      (function (history) {
        const pushState = history.pushState;
        history.pushState = function (state) {
          if (typeof history.onpushstate == "function") {
            history.onpushstate({ state: state });
          }
          const customEvent = new Event("customHistoryChange");
          window.dispatchEvent(customEvent);
          return pushState.apply(history, arguments);
        };

        const replaceState = history.replaceState;
        history.replaceState = function (state) {
          if (typeof history.onreplacestate == "function") {
            history.onreplacestate({ state: state });
          }
          const customEvent = new Event("customHistoryChange");
          window.dispatchEvent(customEvent);
          return replaceState.apply(history, arguments);
        };
      })(window.history);

      window.addEventListener("customHistoryChange", UIManager.handleUrlChange);
    },

    /**
     * Initializes the UI components by adding controls and setting up the observer.
     */
    init() {
      // New: Read collapsed state from localStorage on init
      const storedCollapsedState = localStorage.getItem(
        OUTLINE_COLLAPSED_STATE_KEY
      );
      UIManager._outlineIsCollapsed = storedCollapsedState === "true";

      // Add controls after DOM is ready
      if (
        document.readyState === "complete" ||
        document.readyState === "interactive"
      ) {
        // console.log("DOM is ready (complete or interactive). Setting timeout for UI controls.");
        setTimeout(() => {
          // console.log("Timeout elapsed. Adding export and outline controls.");
          UIManager.addExportControls();
          UIManager.addOutlineControls(); // Add outline after buttons
          // New: Initiate auto-scroll for Gemini after controls are set up
          // console.log("Checking if current host is a Gemini hostname...");
          if (CURRENT_PLATFORM === GEMINI) {
            setTimeout(() => {
              // console.log("Delayed auto-scroll initiated."); // Debug log
              UIManager.autoScrollToTop(); // This call will now use the async logic below
            }, AUTOSCROLL_INITIAL_DELAY);
          }
        }, DOM_READY_TIMEOUT); // DOM_READY_TIMEOUT is assumed to be defined elsewhere, e.g., 1000ms
      } else {
        // console.log("DOM not yet ready. Adding DOMContentLoaded listener.");
        window.addEventListener("DOMContentLoaded", () =>
          setTimeout(() => {
            // console.log("DOMContentLoaded event fired. Adding export and outline controls after timeout.");
            UIManager.addExportControls();
            UIManager.addOutlineControls(); // Add outline after buttons
            // New: Initiate auto-scroll for Gemini after controls are set up
            // console.log("Checking if current host is a Gemini hostname (from DOMContentLoaded).");
            if (CURRENT_PLATFORM === GEMINI) {
              setTimeout(() => {
                // console.log("Delayed auto-scroll initiated (from DOMContentLoaded)."); // Debug log
                UIManager.autoScrollToTop(); // This call will now use the async logic below
              }, AUTOSCROLL_INITIAL_DELAY);
            }
          }, DOM_READY_TIMEOUT)
        );
      }

      UIManager.initObserver();

      // To have a uniform look regardless if light or dark theme is used
      injectThemeOverrideStyles();
    },
  };

  // --- Script Initialization ---
  UIManager.init();
})();