您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Export your ChatGPT, Claude, Copilot or Gemini chat into a properly and elegantly formatted Markdown or JSON.
// ==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 ? `` : ""; }, }); }, /** * 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(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址