您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Export Claude conversations using API
// ==UserScript== // @name Claude API Exporter 1.4 // @namespace http://tampermonkey.net/ // @version 1.4 // @description Export Claude conversations using API // @author MRL // @match https://claude.ai/* // @grant GM_registerMenuCommand // @grant GM_download // @grant GM_setValue // @grant GM_getValue // @license MIT // ==/UserScript== (function() { 'use strict'; // ============================================= // CONSTANTS AND DEFAULT SETTINGS // ============================================= /** * Default settings configuration */ const defaultSettings = { conversationTemplate: '{timestamp}_{conversationId}__{title}.md', artifactTemplate: '{timestamp}_{conversationId}_{artifactId}_branch{branch}{main_suffix}_v{version}_{title}{extension}', dateFormat: 'YYYYMMDDHHMMSS', // YYYYMMDDHHMMSS, YYYY-MM-DD_HH-MM-SS, ISO artifactExportMode: 'files', // Flexible artifact export settings: 'embed', 'files', 'both' includeArtifactsInConversationOnly: false, // For "Export Conversation Only" button includeArtifactMetadata: true, // Include metadata comments in artifact files excludeCanceledArtifacts: true, // User canceled message // Content formatting settings excludeAttachments: false, // Exclude attachments from conversation export removeDoubleNewlinesFromConversation: false, // Remove \n\n from conversation content removeDoubleNewlinesFromMarkdown: false // Remove \n\n from markdown artifact content }; /** * Available variables for filename templates */ const availableVariables = { // Common variables '{timestamp}': 'Current timestamp (format depends on dateFormat setting)', '{conversationId}': 'Unique conversation identifier', '{title}': 'Sanitized conversation or artifact title', // Artifact-specific variables '{artifactId}': 'Unique artifact identifier', '{version}': 'Artifact version number', '{branch}': 'Branch number (e.g., 1, 2, 3)', '{main_suffix}': 'Suffix "_main" for main branch, empty for others', '{extension}': 'File extension based on artifact type', // Date/time variables '{date}': 'Current date in YYYY-MM-DD format', '{time}': 'Current time in HH-MM-SS format', '{year}': 'Current year (YYYY)', '{month}': 'Current month (MM)', '{day}': 'Current day (DD)', '{hour}': 'Current hour (HH)', '{minute}': 'Current minute (MM)', '{second}': 'Current second (SS)' }; // ============================================= // SETTINGS MANAGEMENT // ============================================= /** * Load settings from storage */ function loadSettings() { const settings = {}; for (const [key, defaultValue] of Object.entries(defaultSettings)) { settings[key] = GM_getValue(key, defaultValue); } return settings; } /** * Save settings to storage */ function saveSettings(settings) { for (const [key, value] of Object.entries(settings)) { GM_setValue(key, value); } } /** * Apply variables to template string */ function applyTemplate(template, variables) { let result = template; for (const [placeholder, value] of Object.entries(variables)) { // Replace all occurrences of the placeholder result = result.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), value || ''); } return result; } // ============================================= // SETTINGS UI // ============================================= /** * Creates and shows the settings interface */ function showSettingsUI() { // Remove existing settings UI if present document.getElementById('claude-exporter-settings')?.remove(); const currentSettings = loadSettings(); // Create modal overlay const overlay = document.createElement('div'); overlay.id = 'claude-exporter-settings'; overlay.innerHTML = ` <div class="claude-settings-overlay"> <div class="claude-settings-modal"> <div class="claude-settings-header"> <h2>🔧 Claude Exporter Settings</h2> <button class="claude-settings-close" type="button">×</button> </div> <!-- Tab Navigation --> <div class="claude-tabs-nav"> <button class="claude-tab-btn active" data-tab="general">⚙️ General Settings</button> <button class="claude-tab-btn" data-tab="filenames">📁 Filename Templates</button> </div> <div class="claude-settings-content"> <!-- Tab 1: General Settings --> <div class="claude-tab-content active" id="tab-general"> <div class="claude-settings-section"> <h3>📦 Artifact Export Settings</h3> <div class="claude-setting-group"> <label for="artifactExportMode">Default artifact export mode:</label> <select id="artifactExportMode"> ${['embed', 'files', 'both'].map(mode => `<option value="${mode}" ${currentSettings.artifactExportMode === mode ? 'selected' : ''}> ${mode === 'embed' ? '📄 Embed Only - Include artifacts in conversation file only' : mode === 'files' ? '📁 Files Only - Export artifacts as separate files only' : '📄📁 Both - Include in conversation and export as separate files'} </option>`).join('')} </select> <p class="claude-settings-help">This setting affects the "Export Conversation + Final Artifacts" and "Export Conversation + All Artifacts" buttons.</p> </div> <div class="claude-setting-group"> <div class="claude-checkbox-group"> <input type="checkbox" id="includeArtifactsInConversationOnly" ${currentSettings.includeArtifactsInConversationOnly ? 'checked' : ''}> <label for="includeArtifactsInConversationOnly">Include all artifacts in "Export Conversation Only"</label> </div> <p class="claude-settings-help">When enabled, the "Export Conversation Only" button will embed all artifact versions in the conversation file (but never export separate artifact files).</p> </div> <div class="claude-export-behavior-info"> <h4>📋 Export Behavior Summary</h4> <div class="claude-behavior-grid"> ${['conversationOnlyBehavior', 'finalArtifactsBehavior', 'allArtifactsBehavior'].map(id => `<div class="claude-behavior-item"><strong>${id.replace(/([A-Z])/g, ' $1').replace('Behavior', '').replace(/([a-z])([A-Z])/g, '$1 $2')}:</strong><span id="${id}"></span></div>`).join('')} </div> </div> </div> <div class="claude-setting-group"> <div class="claude-checkbox-group"> <input type="checkbox" id="excludeCanceledArtifacts" ${currentSettings.excludeCanceledArtifacts ? 'checked' : ''}> <label for="excludeCanceledArtifacts">Skip artifacts from canceled messages</label> </div> <p class="claude-settings-help">When enabled, artifacts from messages that were stopped by user (stop_reason: "user_canceled") will be excluded from export. This helps avoid incomplete or unfinished artifacts.</p> </div> <div class="claude-settings-section"> <h3>📝 Content Formatting</h3> <div class="claude-setting-group"> <div class="claude-checkbox-group"> <input type="checkbox" id="excludeAttachments" ${currentSettings.excludeAttachments ? 'checked' : ''}> <label for="excludeAttachments">Exclude attachments content from conversation export</label> </div> <p class="claude-settings-help">When enabled, the extracted content of attachments will not be included in the exported conversation markdown.</p> </div> ${['removeDoubleNewlinesFromConversation', 'removeDoubleNewlinesFromMarkdown'].map(setting => ` <div class="claude-setting-group"> <div class="claude-checkbox-group"> <input type="checkbox" id="${setting}" ${currentSettings[setting] ? 'checked' : ''}> <label for="${setting}">Remove double newlines (\\n\\n) from ${setting.includes('Conversation') ? 'conversation content' : 'markdown artifact content'}</label> </div> <p class="claude-settings-help">When enabled, replaces multiple consecutive newlines with single newlines in ${setting.includes('Conversation') ? 'conversation text content only. Does not affect markdown structure or metadata.' : 'markdown artifact content only. Does not affect artifact metadata or other file types.'}</p> </div> `).join('')} </div> <div class="claude-settings-section"> <h3>📝 Artifact File Settings</h3> <div class="claude-setting-group"> <div class="claude-checkbox-group"> <input type="checkbox" id="includeArtifactMetadata" ${currentSettings.includeArtifactMetadata ? 'checked' : ''}> <label for="includeArtifactMetadata">Include metadata comments in artifact files</label> </div> <p class="claude-settings-help">When enabled, artifact files will include metadata comments at the top (ID, branch, version, etc.). When disabled, files will contain only the pure artifact content.</p> </div> </div> </div> <!-- Tab 2: Filename Templates --> <div class="claude-tab-content" id="tab-filenames"> <div class="claude-settings-section"> <h3>📁 Filename Templates</h3> <p class="claude-settings-description">Configure how exported files are named using variables. <a href="#claude-variables-panel" class="claude-variables-toggle">Show available variables</a></p> <div class="claude-variables-panel" style="display: none;"> <h4>Available Variables:</h4> <div class="claude-variables-grid"> ${Object.entries(availableVariables).map(([variable, description]) => `<div class="claude-variable-item"><code class="claude-variable-name">${variable}</code><span class="claude-variable-desc">${description}</span></div>`).join('')} </div> </div> ${['conversation', 'artifact'].map(type => ` <div class="claude-setting-group"> <label for="${type}Template">${type.charAt(0).toUpperCase() + type.slice(1)} Files:</label> <input type="text" id="${type}Template" value="${currentSettings[type + 'Template']}" placeholder="${defaultSettings[type + 'Template']}"> <div class="claude-preview" id="${type}Preview"></div> </div> `).join('')} </div> <div class="claude-settings-section"> <h3>📅 Date Format</h3> <div class="claude-setting-group"> <label for="dateFormat">Timestamp Format:</label> <select id="dateFormat"> ${[ ['YYYYMMDDHHMMSS', 'Compact (YYYYMMDDHHMMSS) - e.g., 20250710143022'], ['YYYY-MM-DD_HH-MM-SS', 'Readable (YYYY-MM-DD_HH-MM-SS) - e.g., 2025-07-10_14-30-22'], ['ISO', 'ISO (YYYY-MM-DDTHH-MM-SS) - e.g., 2025-07-10T14-30-22'] ].map(([value, text]) => `<option value="${value}" ${currentSettings.dateFormat === value ? 'selected' : ''}>${text}</option>`).join('')} </select> </div> </div> </div> </div> <div class="claude-settings-footer"> <button class="claude-btn claude-btn-secondary" type="button" id="resetDefaults">🔄 Reset to Defaults</button> <div class="claude-btn-group"> <button class="claude-btn claude-btn-secondary" type="button" id="cancelSettings">Cancel</button> <button class="claude-btn claude-btn-primary" type="button" id="saveSettings">💾 Save Settings</button> </div> </div> </div> </div> `; // Add CSS styles (compressed) const styles = `<style id="claude-exporter-styles"> .claude-settings-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.75);display:flex;align-items:center;justify-content:center;z-index:99999;font-family:system-ui,-apple-system,sans-serif} .claude-settings-modal{background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,0.3);width:90%;max-width:800px;max-height:90vh;overflow:hidden;display:flex;flex-direction:column} .claude-settings-header{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white;padding:20px 24px;display:flex;align-items:center;justify-content:space-between} .claude-settings-header h2{margin:0;font-size:20px;font-weight:600} .claude-settings-close{background:none;border:none;color:white;font-size:24px;cursor:pointer;padding:0;width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:background-color 0.2s} .claude-settings-close:hover{background:rgba(255,255,255,0.2)} .claude-tabs-nav{display:flex;background:#f8fafc;border-bottom:1px solid #e2e8f0} .claude-tab-btn{background:none;border:none;padding:16px 20px;font-size:14px;font-weight:500;color:#64748b;cursor:pointer;border-bottom:3px solid transparent;transition:all 0.2s;flex:1;text-align:center} .claude-tab-btn:hover{background:rgba(102,126,234,0.1);color:#475569} .claude-tab-btn.active{color:#667eea;background:white;border-bottom-color:#667eea} .claude-tab-content{display:none}.claude-tab-content.active{display:block} .claude-settings-content{flex:1;overflow-y:auto;padding:24px} .claude-settings-section{margin-bottom:32px}.claude-settings-section:last-child{margin-bottom:0} .claude-settings-section h3{margin:0 0 12px 0;font-size:18px;font-weight:600;color:#2d3748} .claude-settings-description{margin:0 0 20px 0;color:#718096;font-size:14px;line-height:1.5} .claude-variables-toggle{color:#667eea;text-decoration:none;font-weight:500}.claude-variables-toggle:hover{text-decoration:underline} .claude-variables-panel{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:16px 0} .claude-variables-panel h4{margin:0 0 12px 0;font-size:14px;font-weight:600;color:#2d3748} .claude-variables-grid{display:grid;gap:8px} .claude-variable-item{display:grid;grid-template-columns:auto 1fr;gap:12px;align-items:start} .claude-variable-name{background:#e2e8f0;color:#2d3748;padding:2px 6px;border-radius:4px;font-family:'Monaco','Menlo',monospace;font-size:13px;white-space:nowrap} .claude-variable-desc{font-size:13px;color:#718096;line-height:1.4} .claude-setting-group{margin-bottom:20px} .claude-setting-group label{display:block;margin-bottom:6px;font-weight:500;color:#2d3748;font-size:14px} .claude-setting-group input,.claude-setting-group select{width:100%;padding:10px 12px;border:2px solid #e2e8f0;border-radius:6px;font-size:14px;transition:border-color 0.2s,box-shadow 0.2s;box-sizing:border-box} .claude-setting-group input[type="text"]{font-family:'Monaco','Menlo',monospace} .claude-checkbox-group{display:flex;align-items:center;gap:10px;margin-bottom:8px} .claude-checkbox-group input[type="checkbox"]{width:auto;margin:0;transform:scale(1.2)} .claude-checkbox-group label{margin:0;cursor:pointer;font-weight:500} .claude-settings-help{margin:8px 0 0 0;font-size:13px;color:#718096;line-height:1.4} .claude-setting-group input:focus,.claude-setting-group select:focus{outline:none;border-color:#667eea;box-shadow:0 0 0 3px rgba(102,126,234,0.1)} .claude-preview{margin-top:8px;padding:8px 12px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;font-family:'Monaco','Menlo',monospace;font-size:13px;color:#0369a1;word-break:break-all} .claude-export-behavior-info{background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px;padding:16px;margin-top:20px} .claude-export-behavior-info h4{margin:0 0 12px 0;font-size:14px;font-weight:600;color:#0369a1} .claude-behavior-grid{display:grid;gap:8px} .claude-behavior-item{display:grid;grid-template-columns:auto 1fr;gap:12px;align-items:start} .claude-behavior-item strong{font-size:13px;color:#0369a1;white-space:nowrap} .claude-behavior-item span{font-size:13px;color:#374151;line-height:1.4} .claude-settings-footer{background:#f8fafc;padding:20px 24px;border-top:1px solid #e2e8f0;display:flex;align-items:center;justify-content:space-between} .claude-btn{padding:10px 20px;border:none;border-radius:6px;font-size:14px;font-weight:500;cursor:pointer;transition:all 0.2s;text-decoration:none;display:inline-flex;align-items:center;gap:6px} .claude-btn-primary{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white} .claude-btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(102,126,234,0.4)} .claude-btn-secondary{background:#e2e8f0;color:#2d3748}.claude-btn-secondary:hover{background:#cbd5e0} .claude-btn-group{display:flex;gap:12px} @media (max-width:600px){.claude-settings-modal{width:95%;margin:20px}.claude-settings-footer{flex-direction:column;gap:12px}.claude-btn-group{width:100%}.claude-btn{flex:1;justify-content:center}.claude-tabs-nav{flex-direction:column}.claude-tab-btn{flex:none}} </style>`; // Add styles to head and modal to body document.head.insertAdjacentHTML('beforeend', styles); document.body.appendChild(overlay); // Initialize functionality initTabs(); initEventListeners(); updatePreviews(); } // Tab switching functionality function initTabs() { document.querySelectorAll('.claude-tab-btn').forEach(btn => { btn.addEventListener('click', () => { const targetTab = btn.dataset.tab; document.querySelectorAll('.claude-tab-btn, .claude-tab-content').forEach(el => el.classList.remove('active')); btn.classList.add('active'); document.getElementById(`tab-${targetTab}`).classList.add('active'); }); }); } // Initialize all event listeners function initEventListeners() { const formElements = ['conversationTemplate', 'artifactTemplate', 'dateFormat', 'artifactExportMode', 'excludeCanceledArtifacts', 'includeArtifactsInConversationOnly', 'includeArtifactMetadata', 'excludeAttachments', 'removeDoubleNewlinesFromConversation', 'removeDoubleNewlinesFromMarkdown']; formElements.forEach(id => { const element = document.getElementById(id); if (element) { element.addEventListener(element.type === 'checkbox' ? 'change' : 'input', updatePreviews); } }); // Variables panel toggle document.querySelector('.claude-variables-toggle').addEventListener('click', (e) => { e.preventDefault(); const panel = document.querySelector('.claude-variables-panel'); const isVisible = panel.style.display !== 'none'; panel.style.display = isVisible ? 'none' : 'block'; e.target.textContent = isVisible ? 'Show available variables' : 'Hide available variables'; }); // Reset defaults document.getElementById('resetDefaults').addEventListener('click', () => { if (confirm('Reset all settings to defaults?')) { Object.entries(defaultSettings).forEach(([key, value]) => { const element = document.getElementById(key); if (element) { element.type === 'checkbox' ? element.checked = value : element.value = value; } }); updatePreviews(); } }); // Close modal functionality const closeModal = () => { document.getElementById('claude-exporter-settings')?.remove(); document.getElementById('claude-exporter-styles')?.remove(); }; document.getElementById('cancelSettings').addEventListener('click', closeModal); document.querySelector('.claude-settings-close').addEventListener('click', closeModal); // Close on overlay click document.querySelector('.claude-settings-overlay').addEventListener('click', (e) => { if (e.target.classList.contains('claude-settings-overlay')) closeModal(); }); // Save settings document.getElementById('saveSettings').addEventListener('click', () => { const newSettings = {}; Object.keys(defaultSettings).forEach(key => { const element = document.getElementById(key); if (element) { newSettings[key] = element.type === 'checkbox' ? element.checked : element.value; } }); saveSettings(newSettings); showNotification('Settings saved successfully!', 'success'); closeModal(); }); } // Update previews function function updatePreviews() { const conversationTemplate = document.getElementById('conversationTemplate').value; const artifactTemplate = document.getElementById('artifactTemplate').value; const dateFormat = document.getElementById('dateFormat').value; const artifactExportMode = document.getElementById('artifactExportMode').value; const includeArtifactsInConversationOnly = document.getElementById('includeArtifactsInConversationOnly').checked; // Sample data for preview const sampleVariables = createTemplateVariables({ '{timestamp}': formatTimestamp(new Date(), dateFormat), '{conversationId}': '12dasdh1-fa1j-f213-da13-dfa3124123ff', '{title}': 'Title', '{artifactId}': 'artifact2', '{version}': '3', '{branch}': '2', '{main_suffix}': '', '{extension}': '.js' }); document.getElementById('conversationPreview').textContent = 'Preview: ' + applyTemplate(conversationTemplate, sampleVariables); document.getElementById('artifactPreview').textContent = 'Preview: ' + applyTemplate(artifactTemplate, sampleVariables); // Behavior descriptions const behaviors = { conversationOnlyBehavior: includeArtifactsInConversationOnly ? 'Embeds all artifacts in conversation file' : 'Pure conversation without artifacts', finalArtifactsBehavior: { 'embed': 'Embeds final artifacts in conversation file only', 'files': 'Pure conversation + final artifacts as separate files', 'both': 'Embeds final artifacts in conversation + exports as separate files' }[artifactExportMode], allArtifactsBehavior: { 'embed': 'Embeds all artifacts in conversation file only', 'files': 'Pure conversation + all artifacts as separate files', 'both': 'Embeds all artifacts in conversation + exports as separate files' }[artifactExportMode] }; Object.entries(behaviors).forEach(([id, text]) => { document.getElementById(id).textContent = text; }); } // ============================================= // SETTINGS UTILITY FUNCTIONS // ============================================= /** * Formats timestamp according to the selected format */ function formatTimestamp(dateInput, format) { const d = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; if (isNaN(d.getTime())) { return formatTimestamp(new Date(), format); } const components = { year: d.getFullYear(), month: String(d.getMonth() + 1).padStart(2, '0'), day: String(d.getDate()).padStart(2, '0'), hour: String(d.getHours()).padStart(2, '0'), minute: String(d.getMinutes()).padStart(2, '0'), second: String(d.getSeconds()).padStart(2, '0') }; switch (format) { case 'YYYY-MM-DD_HH-MM-SS': return `${components.year}-${components.month}-${components.day}_${components.hour}-${components.minute}-${components.second}`; case 'ISO': return `${components.year}-${components.month}-${components.day}T${components.hour}-${components.minute}-${components.second}`; case 'YYYYMMDDHHMMSS': default: return `${components.year}${components.month}${components.day}${components.hour}${components.minute}${components.second}`; } } /** * Generates timestamp using current settings */ function generateTimestamp(dateInput) { const settings = loadSettings(); const date = dateInput ? (typeof dateInput === 'string' ? new Date(dateInput) : dateInput) : new Date(); return formatTimestamp(date, settings.dateFormat); } /** * Creates template variables object for filenames */ function createTemplateVariables(baseData) { const now = new Date(); const base = { '{date}': now.toISOString().split('T')[0], '{time}': now.toTimeString().split(' ')[0].replace(/:/g, '-'), '{year}': now.getFullYear().toString(), '{month}': String(now.getMonth() + 1).padStart(2, '0'), '{day}': String(now.getDate()).padStart(2, '0'), '{hour}': String(now.getHours()).padStart(2, '0'), '{minute}': String(now.getMinutes()).padStart(2, '0'), '{second}': String(now.getSeconds()).padStart(2, '0') }; return { ...base, ...baseData }; } /** * Generates conversation filename using template */ function generateConversationFilename(conversationData) { const settings = loadSettings(); const variables = createTemplateVariables({ '{timestamp}': generateTimestamp(conversationData.updated_at), '{conversationId}': conversationData.uuid, '{title}': sanitizeFileName(conversationData.name) }); return applyTemplate(settings.conversationTemplate, variables); } /** * Generates artifact filename using template */ function generateArtifactFilename(version, conversationData, branchLabel, isMain, artifactId) { const settings = loadSettings(); const variables = createTemplateVariables({ '{timestamp}': generateTimestamp(version.content_stop_timestamp), '{conversationId}': conversationData.uuid, '{artifactId}': artifactId, '{version}': version.version.toString(), '{branch}': branchLabel, '{main_suffix}': isMain ? '_main' : '', '{title}': sanitizeFileName(version.title), '{extension}': getFileExtension(version.finalType, version.finalLanguage) }); return applyTemplate(settings.artifactTemplate, variables); } /** * Removes double newlines from text content while preserving markdown structure * @param {string} content - Text content to process * @param {boolean} removeDoubleNewlines - Whether to remove \n\n * @returns {string} Processed content */ function processTextContent(content, removeDoubleNewlines) { return processContent(content, removeDoubleNewlines); } /** * Processes artifact content based on type and settings * @param {string} content - Artifact content * @param {string} type - Artifact type * @param {boolean} removeDoubleNewlines - Whether to remove \n\n * @returns {string} Processed content */ function processArtifactContent(content, type, removeDoubleNewlines) { return processContent(content, removeDoubleNewlines, type); } /** * Unified content processing function */ function processContent(content, removeDoubleNewlines, type = null) { if (!removeDoubleNewlines || !content) { return content; } // Only apply to markdown artifacts or general content if (!type || type === 'text/markdown') { return content.replace(/\n\n+/g, '\n'); } return content; } // ============================================= // UTILITY FUNCTIONS // ============================================= /** * Sanitizes filename by removing invalid characters and limiting length */ function sanitizeFileName(name) { return name.replace(/[\\/:*?"<>|]/g, '_') .replace(/\s+/g, '_') .replace(/__+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 100); } /** * Formats date string to localized format */ function formatDate(dateInput) { if (!dateInput) return ''; const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput; return date.toLocaleString(); } /** * Downloads content as a file using browser's download functionality */ function downloadFile(filename, content) { const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); setTimeout(() => { URL.revokeObjectURL(url); }, 100); } /** * Shows temporary notification to the user * @param {string} message - Message to display * @param {string} type - Type of notification (info, success, error) */ function showNotification(message, type = "info") { const notification = document.createElement('div'); const colors = { error: '#f44336', success: '#4CAF50', info: '#2196F3' }; notification.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 15px 20px; border-radius: 5px; color: white; font-family: system-ui, -apple-system, sans-serif; font-size: 14px; z-index: 10000; max-width: 400px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); background-color: ${colors[type] || colors.info}; `; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 5000); } // ============================================= // API FUNCTIONS // ============================================= /** * Extracts conversation ID from current URL * @returns {string|null} Conversation ID or null if not found */ function getConversationId() { const match = window.location.pathname.match(/\/chat\/([^/?]+)/); return match ? match[1] : null; } /** * Gets organization ID from browser cookies * @returns {string} Organization ID * @throws {Error} If organization ID not found */ function getOrgId() { const cookies = document.cookie.split(';'); for (const cookie of cookies) { const [name, value] = cookie.trim().split('='); if (name === 'lastActiveOrg') { return value; } } throw new Error('Could not find organization ID'); } /** * Fetches conversation data from Claude API * @returns {Promise<Object>} Complete conversation data including messages and metadata */ async function getConversationData() { const conversationId = getConversationId(); if (!conversationId) { throw new Error('Not in a conversation'); } const orgId = getOrgId(); const response = await fetch(`/api/organizations/${orgId}/chat_conversations/${conversationId}?tree=true&rendering_mode=messages&render_all_tools=true`); if (!response.ok) { throw new Error(`API request failed: ${response.status}`); } return await response.json(); } // ============================================= // FILE EXTENSION FUNCTIONS // ============================================= /** * Gets appropriate file extension based on artifact type and language * @param {string} type - Artifact MIME type * @param {string} language - Programming language (for code artifacts) * @returns {string} File extension including the dot */ function getFileExtension(type, language) { switch (type) { case 'application/vnd.ant.code': return getCodeExtension(language); case 'text/html': return '.html'; case 'text/markdown': return '.md'; case 'image/svg+xml': return '.svg'; case 'application/vnd.ant.mermaid': return '.mmd'; case 'application/vnd.ant.react': return '.jsx'; case undefined: default: return '.txt'; } } /** * Maps programming language names to file extensions * @param {string} language - Programming language name * @returns {string} File extension including the dot */ function getCodeExtension(language) { const extensionMap = { // Web languages 'javascript': '.js', 'typescript': '.ts', 'html': '.html', 'css': '.css', 'scss': '.scss', 'sass': '.sass', 'less': '.less', 'jsx': '.jsx', 'tsx': '.tsx', 'vue': '.vue', // Languages 'python': '.py', 'java': '.java', 'csharp': '.cs', 'c#': '.cs', 'cpp': '.cpp', 'c++': '.cpp', 'c': '.c', 'go': '.go', 'rust': '.rs', 'swift': '.swift', 'kotlin': '.kt', 'dart': '.dart', 'php': '.php', 'ruby': '.rb', 'perl': '.pl', 'lua': '.lua', // Functional languages 'haskell': '.hs', 'clojure': '.clj', 'erlang': '.erl', 'elixir': '.ex', 'fsharp': '.fs', 'f#': '.fs', 'ocaml': '.ml', 'scala': '.scala', 'lisp': '.lisp', // Data and config 'json': '.json', 'yaml': '.yaml', 'yml': '.yml', 'xml': '.xml', 'toml': '.toml', 'ini': '.ini', 'csv': '.csv', // Query languages 'sql': '.sql', 'mysql': '.sql', 'postgresql': '.sql', 'sqlite': '.sql', 'plsql': '.sql', // Shell and scripting 'bash': '.sh', 'shell': '.sh', 'sh': '.sh', 'zsh': '.zsh', 'fish': '.fish', 'powershell': '.ps1', 'batch': '.bat', 'cmd': '.cmd', // Scientific and specialized 'r': '.r', 'matlab': '.m', 'julia': '.jl', 'fortran': '.f90', 'cobol': '.cob', 'assembly': '.asm', 'vhdl': '.vhd', 'verilog': '.v', // Build and config files 'dockerfile': '.dockerfile', 'makefile': '.mk', 'cmake': '.cmake', 'gradle': '.gradle', 'maven': '.xml', // Markup and documentation 'markdown': '.md', 'latex': '.tex', 'restructuredtext': '.rst', 'asciidoc': '.adoc', // Other 'regex': '.regex', 'text': '.txt', 'plain': '.txt' }; const normalizedLanguage = language ? language.toLowerCase().trim() : ''; return extensionMap[normalizedLanguage] || '.txt'; } /** * Gets comment style for a programming language * @param {string} type - Artifact MIME type * @param {string} language - Programming language * @returns {Object} Comment style object with start and end strings */ function getCommentStyle(type, language) { if (type === 'text/html' || type === 'image/svg+xml') { return { start: '<!-- ', end: ' -->' }; } if (type !== 'application/vnd.ant.code') { return { start: '# ', end: '' }; // Default to hash comments } const normalizedLanguage = language ? language.toLowerCase().trim() : ''; // Define comment patterns const commentPatterns = { // Languages with // comments slash: ['javascript', 'typescript', 'java', 'csharp', 'c#', 'cpp', 'c++', 'c', 'go', 'rust', 'swift', 'kotlin', 'dart', 'php', 'scala', 'jsx', 'tsx'], // Languages with # comments hash: ['python', 'ruby', 'perl', 'bash', 'shell', 'sh', 'zsh', 'fish', 'yaml', 'yml', 'r', 'julia', 'toml', 'ini', 'powershell'], // Languages with -- comments dash: ['sql', 'mysql', 'postgresql', 'sqlite', 'plsql', 'haskell', 'lua'] }; if (commentPatterns.slash.includes(normalizedLanguage)) { return { start: '// ', end: '' }; } else if (commentPatterns.hash.includes(normalizedLanguage)) { return { start: '# ', end: '' }; } else if (commentPatterns.dash.includes(normalizedLanguage)) { return { start: '-- ', end: '' }; } return { start: '# ', end: '' }; // Default to hash comments } /** * Gets language identifier for markdown syntax highlighting */ function getLanguageForHighlighting(type, language) { const typeMap = { 'text/html': 'html', 'text/markdown': 'markdown', 'image/svg+xml': 'xml', 'application/vnd.ant.mermaid': 'mermaid', 'application/vnd.ant.react': 'jsx' }; if (typeMap[type]) return typeMap[type]; if (type === 'application/vnd.ant.code' && language) { const normalizedLanguage = language.toLowerCase().trim(); // Map some languages to their markdown equivalents const languageMap = { 'c++': 'cpp', 'c#': 'csharp', 'f#': 'fsharp', 'objective-c': 'objc', 'shell': 'bash', 'sh': 'bash' }; return languageMap[normalizedLanguage] || normalizedLanguage; } return ''; } // ============================================= // BRANCH HANDLING FUNCTIONS // ============================================= /** * Builds conversation tree structure to understand message branches * @param {Array} messages - Array of chat messages * @returns {Object} Tree structure with branch information */ function buildConversationTree(messages) { const messageMap = new Map(); const rootMessages = []; // First pass: create message map messages.forEach(message => { messageMap.set(message.uuid, { ...message, children: [], branchId: null, branchIndex: null }); }); // Second pass: build parent-child relationships messages.forEach(message => { const messageNode = messageMap.get(message.uuid); if (message.parent_message_uuid && messageMap.has(message.parent_message_uuid)) { const parent = messageMap.get(message.parent_message_uuid); parent.children.push(messageNode); } else { rootMessages.push(messageNode); } }); return { messageMap, rootMessages }; } /** * Gets all branch information including branch points * @param {Object} tree - Tree structure from buildConversationTree * @returns {Array} Array of branch information */ function getAllBranchInfo(tree) { const branches = []; let branchCounter = 0; function traverseBranches(node, currentPath = [], branchStartIndex = 0) { const newPath = [...currentPath, node]; if (node.children.length === 0) { // Leaf node - this is a complete branch branchCounter++; branches.push({ branchId: node.uuid, branchIndex: branchCounter, fullPath: newPath, branchStartIndex: branchStartIndex, // Index in fullPath where this branch starts isMainBranch: branchStartIndex === 0 }); } else if (node.children.length === 1) { // Single child - continue same branch traverseBranches(node.children[0], newPath, branchStartIndex); } else { // Multiple children - branch point node.children.forEach((child, childIndex) => { // For first child, continue current branch // For other children, start new branches from this point const newBranchStart = childIndex === 0 ? branchStartIndex : newPath.length; traverseBranches(child, newPath, newBranchStart); }); } } tree.rootMessages.forEach(root => { traverseBranches(root, [], 0); }); return branches; } // ============================================= // TEXT PROCESSING FUNCTIONS // ============================================= /** * Recursively extracts text content from nested content structures * @param {Object} content - Content object to process * @returns {Promise<Array<string>>} Array of text pieces found */ async function getTextFromContent(content) { let textPieces = []; if (content.text) { textPieces.push(content.text); } if (content.input) { textPieces.push(JSON.stringify(content.input)); } if (content.content) { if (Array.isArray(content.content)) { for (const nestedContent of content.content) { textPieces = textPieces.concat(await getTextFromContent(nestedContent)); } } else if (typeof content.content === 'object') { textPieces = textPieces.concat(await getTextFromContent(content.content)); } } return textPieces; } // ============================================= // ARTIFACT PROCESSING FUNCTIONS // ============================================= /** * Extracts artifacts from messages, respecting branch boundaries * @param {Array} branchPath - Full path from root to leaf * @param {number} branchStartIndex - Index where this branch starts (for split branches) * @param {string} branchId - Unique identifier for this branch * @param {boolean} isMainBranch - Whether this is the main branch * @returns {Object} {ownArtifacts: Map, inheritedStates: Map} */ function extractArtifacts(branchPath, branchStartIndex, branchId, isMainBranch) { const settings = loadSettings(); const ownArtifacts = new Map(); // Artifacts created/modified in this branch const inheritedStates = new Map(); // Final states of artifacts from parent branch // For non-main branches, first collect inherited states from parent path if (!isMainBranch && branchStartIndex > 0) { const parentPath = branchPath.slice(0, branchStartIndex); const parentArtifacts = new Map(); // Extract artifacts from parent path parentPath.forEach((message, messageIndex) => { message.content.forEach(content => { if (content.type === 'tool_use' && content.name === 'artifacts' && content.input) { const input = content.input; const artifactId = input.id; if (!parentArtifacts.has(artifactId)) { parentArtifacts.set(artifactId, []); } const versions = parentArtifacts.get(artifactId); versions.push({ type: input.type, title: input.title || `Artifact ${artifactId}`, command: input.command, content: input.content || '', new_str: input.new_str || '', old_str: input.old_str || '', language: input.language || '', timestamp_created_at: message.created_at, timestamp_updated_at: message.updated_at }); } }); }); // Build final states from parent artifacts parentArtifacts.forEach((versions, artifactId) => { let currentContent = ''; let currentTitle = `Artifact ${artifactId}`; let currentType = undefined; let currentLanguage = ''; let versionCount = 0; versions.forEach(version => { versionCount++; switch (version.command) { case 'create': currentContent = version.content; currentTitle = version.title; currentType = version.type; currentLanguage = version.language; break; case 'rewrite': currentContent = version.content; currentTitle = version.title; // Keep type and language from create break; case 'update': const updateResult = applyUpdate(currentContent, version.old_str, version.new_str); currentContent = updateResult.content; break; } }); inheritedStates.set(artifactId, { content: currentContent, title: currentTitle, type: currentType, language: currentLanguage, versionCount: versionCount }); }); } // Now extract artifacts from this branch only (starting from branchStartIndex) const branchMessages = branchPath.slice(branchStartIndex); branchMessages.forEach((message, relativeIndex) => { // User canceled message - excludeCanceledArtifacts // if (settings.excludeCanceledArtifacts && message.stop_reason === 'user_canceled') { // return; // skip this message // } message.content.forEach(content => { if (content.type === 'tool_use' && content.name === 'artifacts' && content.input) { const input = content.input; const artifactId = input.id; if (!ownArtifacts.has(artifactId)) { ownArtifacts.set(artifactId, []); } const versions = ownArtifacts.get(artifactId); // Calculate version number based on inherited versions let versionNumber; if (isMainBranch) { // Main branch: start from 1 versionNumber = versions.length + 1; } else { // Split branch: continue from parent version count const inheritedCount = inheritedStates.has(artifactId) ? inheritedStates.get(artifactId).versionCount : 0; versionNumber = inheritedCount + versions.length + 1; } versions.push({ messageUuid: message.uuid, messageText: message.text, version: versionNumber, content_start_timestamp: content.start_timestamp, content_stop_timestamp: content.stop_timestamp, content_type: content.type, type: input.type, title: input.title || `Artifact ${artifactId}`, command: input.command, old_str: input.old_str || '', new_str: input.new_str || '', content: input.content || '', uuid: input.version_uuid, language: input.language || '', messageIndex: branchStartIndex + relativeIndex, stop_reason: message.stop_reason, timestamp_created_at: message.created_at, timestamp_updated_at: message.updated_at, branchId: branchId }); } }); }); return { ownArtifacts, inheritedStates }; } /** * Applies update command to previous content by replacing old_str with new_str * @param {string} previousContent - Content before update * @param {string} oldStr - String to be replaced * @param {string} newStr - String to replace with * @returns {Object} {success: boolean, content: string, info: string} */ function applyUpdate(previousContent, oldStr, newStr) { if (!previousContent || !oldStr) { // If no old_str or previousContent, prepend new_str to beginning if (newStr) { return { success: true, content: newStr + (previousContent ? '\n' + previousContent : ''), info: '[WARNING: Added content to beginning - missing old_str or previousContent]' }; } return { success: false, content: previousContent || '', info: 'Cannot apply update: missing previousContent, oldStr, and newStr' }; } // Apply the string replacement const updatedContent = previousContent.replace(oldStr, newStr); if (updatedContent === previousContent) { // old_str not found - prepend new_str to beginning as fallback if (newStr) { return { success: true, content: newStr + '\n' + previousContent, info: '[WARNING: Added content to beginning - old_str not found in content]' }; } // Try to find similar strings for debugging const lines = previousContent.split('\n'); const oldLines = oldStr.split('\n'); let debugInfo = 'Update did not change content - old string not found'; if (oldLines.length > 0) { const firstOldLine = oldLines[0].trim(); const foundLine = lines.find(line => line.includes(firstOldLine)); if (foundLine) { debugInfo += ` | Found similar line: "${foundLine.trim()}"`; } } return { success: false, content: previousContent, info: debugInfo }; } return { success: true, content: updatedContent, info: `Successfully applied update` }; } /** * Creates change description for artifact commands */ function createChangeDescription(version, oldContent, currentContent) { switch (version.command) { case 'create': return 'Created'; case 'rewrite': return 'Rewritten'; case 'update': const oldPreview = version.old_str ? version.old_str.substring(0, 50).replace(/\n/g, '\\n') + '...' : ''; const newPreview = version.new_str ? version.new_str.substring(0, 50).replace(/\n/g, '\\n') + '...' : ''; let changeDescription = `Updated: "${oldPreview}" → "${newPreview}"`; // Add information about character count changes const lengthDiff = currentContent.length - oldContent.length; if (lengthDiff > 0) { changeDescription += ` (+${lengthDiff} chars)`; } else if (lengthDiff < 0) { changeDescription += ` (${lengthDiff} chars)`; } return changeDescription; default: return `Unknown command: ${version.command}`; } } /** * Builds complete artifact versions for a specific branch * @param {Map} ownArtifacts - Artifacts created/modified in this branch * @param {Map} inheritedStates - Final states from parent branch * @param {string} branchId - Branch identifier * @param {boolean} isMainBranch - Whether this is the main branch * @returns {Map} Map of artifact ID to processed versions with full content */ function buildArtifactVersions(ownArtifacts, inheritedStates, branchId, isMainBranch) { const processedArtifacts = new Map(); ownArtifacts.forEach((versions, artifactId) => { const processedVersions = []; // Start with inherited content if this is a branch let currentContent = ''; let currentTitle = `Artifact ${artifactId}`; let currentType = undefined; let currentLanguage = ''; if (!isMainBranch && inheritedStates.has(artifactId)) { const inherited = inheritedStates.get(artifactId); currentContent = inherited.content; currentTitle = inherited.title; currentType = inherited.type; currentLanguage = inherited.language; } versions.forEach((version, index) => { let updateInfo = ''; let versionStartContent = currentContent; const oldContent = currentContent; switch (version.command) { case 'create': currentContent = version.content; currentTitle = version.title; currentType = version.type; currentLanguage = version.language; break; case 'rewrite': currentContent = version.content; currentTitle = version.title; // Keep type and language from create break; case 'update': const updateResult = applyUpdate(currentContent, version.old_str, version.new_str); currentContent = updateResult.content; updateInfo = updateResult.info; if (!updateResult.success) { updateInfo = `[WARNING: ${updateResult.info}]`; } break; default: console.warn(`Unknown command: ${version.command}`); break; } const changeDescription = createChangeDescription(version, oldContent, currentContent); processedVersions.push({ ...version, fullContent: currentContent, changeDescription: updateInfo ? changeDescription + ` ${updateInfo}` : changeDescription, updateInfo: updateInfo, branchId: branchId, isMainBranch: isMainBranch, inheritedContent: versionStartContent, finalType: currentType, finalLanguage: currentLanguage }); }); processedArtifacts.set(artifactId, processedVersions); }); return processedArtifacts; } /** * Extracts and processes all artifacts from all branches with proper inheritance * @param {Object} conversationData - Complete conversation data * @returns {Object} {branchArtifacts: Map, branchInfo: Array} */ function extractAllArtifacts(conversationData) { // Build conversation tree const tree = buildConversationTree(conversationData.chat_messages); const branches = getAllBranchInfo(tree); console.log(`Found ${branches.length} conversation branches`); const branchArtifacts = new Map(); // branchId -> Map<artifactId, versions> const branchInfo = []; branches.forEach((branch) => { const { ownArtifacts, inheritedStates } = extractArtifacts( branch.fullPath, branch.branchStartIndex, branch.branchId, branch.isMainBranch ); if (ownArtifacts.size > 0) { // Process artifacts for this branch const processedArtifacts = buildArtifactVersions( ownArtifacts, inheritedStates, branch.branchId, branch.isMainBranch ); branchArtifacts.set(branch.branchId, processedArtifacts); const leafMessage = branch.fullPath[branch.fullPath.length - 1]; branchInfo.push({ branchId: branch.branchId, branchIndex: branch.branchIndex, messageCount: branch.fullPath.length, branchMessageCount: branch.fullPath.length - branch.branchStartIndex, artifactCount: ownArtifacts.size, inheritedArtifactCount: inheritedStates.size, lastMessageTime: leafMessage.created_at, lastMessageUuid: leafMessage.uuid, isMainBranch: branch.isMainBranch, branchStartIndex: branch.branchStartIndex }); } }); return { branchArtifacts, branchInfo }; } // ============================================= // VERSION TRACKING FUNCTIONS // ============================================= /** * Builds version information for messages with alternatives (same parent) * @param {Array} messages - Array of chat messages * @returns {Map} Map of message UUID to version info {version, total} */ function buildVersionInfo(messages) { const versionInfo = new Map(); // Group messages by parent_message_uuid const parentGroups = new Map(); messages.forEach(message => { if (message.parent_message_uuid) { if (!parentGroups.has(message.parent_message_uuid)) { parentGroups.set(message.parent_message_uuid, []); } parentGroups.get(message.parent_message_uuid).push(message); } }); // Process groups with more than one message (alternatives) parentGroups.forEach((siblings, parentUuid) => { if (siblings.length > 1) { // Sort by created_at to determine version numbers siblings.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); siblings.forEach((message, index) => { versionInfo.set(message.uuid, { version: index + 1, total: siblings.length }); }); } }); return versionInfo; } // ============================================= // EXPORT FUNCTIONS // ============================================= /** * Finds the artifact content for a specific artifact ID and message */ function findArtifactContent(artifactId, messageUuid, branchArtifacts, includeMode = 'final', stopTimestamp = null) { let allVersionsOfArtifact = []; let messageVersion = null; // Collect all versions of this artifact from all branches for (const [branchId, artifactsMap] of branchArtifacts) { if (artifactsMap.has(artifactId)) { const versions = artifactsMap.get(artifactId); allVersionsOfArtifact = allVersionsOfArtifact.concat(versions); // Find the specific version by timestamp if provided if (stopTimestamp) { const specificVersion = versions.find(v => v.messageUuid === messageUuid && v.content_stop_timestamp === stopTimestamp ); if (specificVersion) { messageVersion = specificVersion; } } else { // Fallback: find any version in this message const msgVersion = versions.find(v => v.messageUuid === messageUuid); if (msgVersion) { messageVersion = msgVersion; } } } } if (allVersionsOfArtifact.length === 0) { return null; } if (includeMode === 'all') { // Show the specific version that was created in this tool_use return messageVersion; } else if (includeMode === 'final') { // Sort all versions by creation time to find the truly latest one allVersionsOfArtifact.sort((a, b) => { const timeA = new Date(a.content_stop_timestamp || a.timestamp_created_at); const timeB = new Date(b.content_stop_timestamp || b.timestamp_created_at); return timeA - timeB; }); const globalLatestVersion = allVersionsOfArtifact[allVersionsOfArtifact.length - 1]; // Show artifact ONLY if this message contains the globally final version if (globalLatestVersion.messageUuid === messageUuid) { return globalLatestVersion; } return null; } return null; } /** * Generates markdown content for the entire conversation * @param {Object} conversationData - Complete conversation data from API * @returns {string} Formatted markdown content */ function generateConversationMarkdown(conversationData, includeArtifacts = 'none', branchArtifacts = null, branchInfo = null) { const settings = loadSettings(); let markdown = ''; // Header with conversation metadata markdown += `# ${conversationData.name}\n`; // Project info (if available) if (conversationData.project) { markdown += `*Project:* [${conversationData.project.name}] (https://claude.ai/project/${conversationData.project.uuid})\n`; } markdown += `*URL:* https://claude.ai/chat/${conversationData.uuid}\n`; // Format dates properly markdown += `*Created:* ${formatDate(conversationData.created_at)}\n`; markdown += `*Updated:* ${formatDate(conversationData.updated_at)}\n`; markdown += `*Exported:* ${formatDate(new Date())}\n`; if (conversationData.model) { markdown += `*Model:* \`${conversationData.model}\`\n`; } markdown += `\n`; // Build version info for messages with alternatives const versionInfo = buildVersionInfo(conversationData.chat_messages); // Process each message conversationData.chat_messages.forEach(message => { // User canceled message const isCanceled = message.stop_reason === 'user_canceled'; const role = message.sender === 'human' ? 'Human' : 'Claude'; markdown += `__________\n\n`; markdown += `## ${role}`; // User canceled message if (isCanceled) { markdown += ` *(canceled)*`; } markdown += `\n`; markdown += `*UUID:* \`${message.uuid}\`\n`; markdown += `*Created:* ${formatDate(message.created_at)}\n`; // Add version info if this message has alternatives if (versionInfo.has(message.uuid)) { const info = versionInfo.get(message.uuid); markdown += `*Version:* ${info.version} of ${info.total}\n`; } markdown += `\n`; // For latest_per_message mode, collect latest artifact entry for each artifact ID let latestArtifactEntries = new Map(); // artifactId -> latest content entry if (includeArtifacts === 'latest_per_message') { // Go through content in order and keep overwriting - last one wins message.content.forEach(content => { if (content.type === 'tool_use' && content.name === 'artifacts' && content.input) { const artifactId = content.input.id; latestArtifactEntries.set(artifactId, content); } }); } // Process message content message.content.forEach(content => { if (content.type === 'text') { const processedText = processTextContent(content.text, settings.removeDoubleNewlinesFromConversation); markdown += processedText + '\n\n'; } else if (content.type === 'tool_use' && content.name === 'artifacts') { const input = content.input; // For latest_per_message mode, only show artifact details for the latest version in this message let shouldShowArtifactDetails = true; if (includeArtifacts === 'latest_per_message') { shouldShowArtifactDetails = latestArtifactEntries.get(input.id) === content; } if (shouldShowArtifactDetails) { if (input.title) { markdown += `**Artifact Created:** ${input.title}`; // User canceled message if (isCanceled) { markdown += ` *(incomplete - generation was canceled)*`; } markdown += `\n`; } markdown += `*ID:* \`${input.id}\`\n`; markdown += `*Command:* \`${input.command}\`\n`; // Add version, branch and timestamp info if available if (branchArtifacts) { // Find the specific version for this operation (by timestamp if available) let artifactVersion = null; for (const [branchId, artifactsMap] of branchArtifacts) { if (artifactsMap.has(input.id)) { const versions = artifactsMap.get(input.id); if (content.stop_timestamp) { // Try to find by exact timestamp artifactVersion = versions.find(v => v.messageUuid === message.uuid && v.content_stop_timestamp === content.stop_timestamp ); } // Fallback: find any version in this message if (!artifactVersion) { artifactVersion = versions.find(v => v.messageUuid === message.uuid); } if (artifactVersion) break; } } if (artifactVersion) { // Find branch info for proper branch label const branchData = branchInfo ? branchInfo.find(b => b.branchId === artifactVersion.branchId) : null; let branchLabel; if (branchData) { if (branchData.isMainBranch) { branchLabel = `branch${branchData.branchIndex} (main) (${artifactVersion.branchId.substring(0, 8)}...)`; } else { branchLabel = `branch${branchData.branchIndex} (${artifactVersion.branchId.substring(0, 8)}...)`; } } else { branchLabel = `unknown (${artifactVersion.branchId.substring(0, 8)}...)`; } markdown += `*Version:* ${artifactVersion.version}\n`; markdown += `*Branch:* ${branchLabel}\n`; markdown += `*Created:* ${formatDate(artifactVersion.content_stop_timestamp || artifactVersion.timestamp_created_at)}\n`; // Show change description if available if (artifactVersion.changeDescription) { markdown += `*Change:* ${artifactVersion.changeDescription}\n`; } } } // Include artifact content based on mode if (includeArtifacts !== 'none' && branchArtifacts) { // User canceled message if (settings.excludeCanceledArtifacts && isCanceled) { markdown += `\n*Artifact content excluded (generation was canceled)*\n`; } else { let artifactContent = null; if (includeArtifacts === 'latest_per_message') { // Find the latest artifact version in this specific message using the same logic const latestInMessage = new Map(); message.content.forEach(c => { if (c.type === 'tool_use' && c.name === 'artifacts' && c.input && c.input.id === input.id) { latestInMessage.set(c.input.id, c); } }); const latestEntry = latestInMessage.get(input.id); if (latestEntry && latestEntry === content) { // Find the corresponding artifact version for (const [branchId, artifactsMap] of branchArtifacts) { if (artifactsMap.has(input.id)) { const versions = artifactsMap.get(input.id); artifactContent = versions.find(v => v.content_stop_timestamp === latestEntry.stop_timestamp ); if (artifactContent) break; } } } } else { // Use existing logic for 'all' and 'final' modes artifactContent = findArtifactContent(input.id, message.uuid, branchArtifacts, includeArtifacts, content.stop_timestamp); } if (artifactContent) { markdown += `\n### Artifact Content\n\n`; // Determine the language for syntax highlighting const language = getLanguageForHighlighting(artifactContent.finalType, artifactContent.finalLanguage); // Process artifact content based on settings let processedArtifactContent = artifactContent.fullContent; if (artifactContent.finalType === 'text/markdown' && settings.removeDoubleNewlinesFromMarkdown) { processedArtifactContent = processArtifactContent(artifactContent.fullContent, artifactContent.finalType, true); } markdown += '```' + language + '\n'; markdown += processedArtifactContent + '\n'; markdown += '```\n'; } } } markdown += `\n`; } } else if (content.type === 'thinking') { if (content.thinking) { markdown += `*[Claude thinking...]*\n\n`; markdown += `<details>\n<summary>Thinking process</summary>\n\n`; const processedThinking = processTextContent(content.thinking, settings.removeDoubleNewlinesFromConversation); markdown += processedThinking + '\n\n'; markdown += `</details>\n\n`; } else { markdown += `*[Claude thinking...]*\n\n`; } } }); // Process files if present (files or files_v2) const attachedFiles = message.files_v2 || message.files || []; if (attachedFiles.length > 0) { attachedFiles.forEach(file => { markdown += `**File:** ${file.file_name}\n`; markdown += `*ID:* \`${file.file_uuid}\`\n`; if (file.file_kind === 'image') { markdown += `*Preview:* https://claude.ai${file.preview_url}\n`; } markdown += `\n`; }); } // Process attachments if present if (message.attachments && message.attachments.length > 0) { message.attachments.forEach(attachment => { markdown += `**Attachment:** ${attachment.file_name}\n`; markdown += `*ID:* \`${attachment.id}\`\n\n`; if (!settings.excludeAttachments && attachment.extracted_content) { markdown += `<details>\n\n`; markdown += '```\n'; const processedAttachment = processTextContent(attachment.extracted_content, settings.removeDoubleNewlinesFromConversation); markdown += processedAttachment + '\n'; markdown += '```\n\n'; markdown += `</details>\n\n`; } }); } }); return markdown; } /** * Formats artifact metadata as comments in the appropriate style * @param {Object} version - Version object with metadata * @param {string} artifactId - Artifact ID * @param {string} branchLabel - Branch label * @param {boolean} isMain - Whether this is the main branch * @returns {string} Formatted metadata comments */ function formatArtifactMetadata(version, artifactId, branchLabel, isMain) { const settings = loadSettings(); // Return empty string if metadata is disabled if (!settings.includeArtifactMetadata) { return ''; } const metadataInfo = [ `Artifact ID: ${artifactId}`, `Branch: ${branchLabel}${isMain ? ' (main)' : ''} (${version.branchId.substring(0, 8)}...)`, `Version: ${version.version}`, `Command: ${version.command}`, `UUID: ${version.uuid}`, `Created: ${formatDate(version.content_stop_timestamp)}` ]; if (version.changeDescription) { metadataInfo.push(`Change: ${version.changeDescription}`); } if (version.updateInfo) { metadataInfo.push(`Update Info: ${version.updateInfo}`); } if (!isMain && version.inheritedContent && version.command === 'update') { metadataInfo.push(`Started from inherited content: ${version.inheritedContent.length} chars`); } // Special formatting for markdown files if (version.finalType === 'text/markdown') { let metadata = metadataInfo.map(info => `*${info}*`).join('\n') + '\n\n---\n'; return metadata; } // For all other file types, use comments const commentStyle = getCommentStyle(version.finalType, version.finalLanguage); const { start, end } = commentStyle; let metadata = metadataInfo.map(info => `${start}${info}${end}`).join('\n') + '\n'; // Add separator based on language const separators = { '// ': '\n// ---\n', '-- ': '\n-- ---\n', '<!-- ': '\n<!-- --- -->\n' }; metadata += separators[start] || '\n# ---\n'; return metadata; } /** * Helper to count artifacts across branches */ function countArtifacts(branchArtifacts) { return Array.from(branchArtifacts.values()) .reduce((total, artifactsMap) => total + artifactsMap.size, 0); } /** * Unified export function that handles all export modes * @param {boolean} finalVersionsOnly - If true, exports only final artifact versions * @param {boolean} latestPerMessage - If true, exports only latest artifact per message */ async function exportConversation(finalVersionsOnly = false, latestPerMessage = false) { try { showNotification('Fetching conversation data...', 'info'); const conversationData = await getConversationData(); const settings = loadSettings(); // Extract and process artifacts from all branches const { branchArtifacts, branchInfo } = extractAllArtifacts(conversationData); let conversationMarkdown; let shouldExportSeparateFiles = false; let includeMode; // Determine include mode for conversation markdown if (latestPerMessage) { includeMode = 'latest_per_message'; } else if (finalVersionsOnly) { includeMode = 'final'; } else { includeMode = 'all'; } // Determine behavior based on setting switch (settings.artifactExportMode) { case 'embed': // Only embed in conversation file conversationMarkdown = generateConversationMarkdown(conversationData, includeMode, branchArtifacts, branchInfo); shouldExportSeparateFiles = false; break; case 'files': // Only separate files, no embedding (except for latestPerMessage which always shows in conversation) const conversationIncludeMode = latestPerMessage ? includeMode : 'none'; conversationMarkdown = generateConversationMarkdown(conversationData, conversationIncludeMode, branchArtifacts, branchInfo); shouldExportSeparateFiles = true; break; case 'both': // Both embed and separate files conversationMarkdown = generateConversationMarkdown(conversationData, includeMode, branchArtifacts, branchInfo); shouldExportSeparateFiles = true; break; } // Always download conversation file const conversationFilename = generateConversationFilename(conversationData); downloadFile(conversationFilename, conversationMarkdown); // Export separate artifact files if needed if (shouldExportSeparateFiles && branchArtifacts.size > 0) { let totalExported = 0; // For latest per message mode, build set of latest artifact timestamps let latestArtifactTimestamps = new Set(); if (latestPerMessage) { conversationData.chat_messages.forEach(message => { const latestInMessage = new Map(); // artifactId -> latest content entry // Go through content in order - last artifact with each ID wins message.content.forEach(content => { if (content.type === 'tool_use' && content.name === 'artifacts' && content.input) { const artifactId = content.input.id; latestInMessage.set(artifactId, content); } }); // Add timestamps of latest artifacts to our set latestInMessage.forEach((content, artifactId) => { if (content.stop_timestamp) { latestArtifactTimestamps.add(content.stop_timestamp); } }); }); } // Export artifacts for each branch branchArtifacts.forEach((artifactsMap, branchId) => { const branchData = branchInfo.find(b => b.branchId === branchId); const branchLabel = branchData ? branchData.branchIndex.toString() : 'unknown'; const isMain = branchData ? branchData.isMainBranch : false; artifactsMap.forEach((versions, artifactId) => { let versionsToExport = versions; // Default to all versions if (latestPerMessage) { // Only export versions that are latest per message versionsToExport = versions.filter(version => latestArtifactTimestamps.has(version.content_stop_timestamp) ); } else if (finalVersionsOnly) { // Only last version versionsToExport = [versions[versions.length - 1]]; } versionsToExport.forEach(version => { // User canceled message - excludeCanceledArtifacts if (settings.excludeCanceledArtifacts && version.stop_reason === 'user_canceled') { return; // skip this message } const filename = generateArtifactFilename(version, conversationData, branchLabel, isMain, artifactId); // Format metadata as comments const metadata = formatArtifactMetadata(version, artifactId, branchLabel, isMain); // Process artifact content based on settings let processedContent = version.fullContent; if (version.finalType === 'text/markdown' && settings.removeDoubleNewlinesFromMarkdown) { processedContent = processArtifactContent(version.fullContent, version.finalType, true); } // Combine metadata and content const content = metadata ? metadata + '\n' + processedContent : processedContent; downloadFile(filename, content); totalExported++; }); }); }); // Generate notification message let mode; let modeText; if (latestPerMessage) { mode = 'latest per message'; modeText = settings.artifactExportMode === 'both' ? 'latest per message embedded + latest per message as separate files' : 'latest per message as separate files'; } else if (finalVersionsOnly) { mode = 'final versions'; modeText = settings.artifactExportMode === 'both' ? 'embedded + separate files' : 'separate files'; } else { mode = 'all versions'; modeText = settings.artifactExportMode === 'both' ? 'embedded + separate files' : 'separate files'; } showNotification(`Export completed! Downloaded conversation + ${totalExported} artifacts as ${modeText} (${mode})`, 'success'); } else { // No separate files exported const artifactCount = countArtifacts(branchArtifacts); if (artifactCount > 0) { let mode; if (latestPerMessage) { mode = 'latest per message'; } else if (finalVersionsOnly) { mode = 'final versions'; } else { mode = 'all versions'; } showNotification(`Export completed! Downloaded conversation with ${artifactCount} embedded artifacts (${mode})`, 'success'); } else { showNotification('Export completed! No artifacts found in conversation', 'info'); } } } catch (error) { console.error('Export failed:', error); showNotification(`Export failed: ${error.message}`, 'error'); } } /** * Exports only the conversation, with optional artifact inclusion based on settings */ async function exportConversationOnly() { try { showNotification('Fetching conversation data...', 'info'); const conversationData = await getConversationData(); const settings = loadSettings(); // Extract artifacts to get metadata for conversation display const { branchArtifacts, branchInfo } = extractAllArtifacts(conversationData); const includeArtifacts = settings.includeArtifactsInConversationOnly ? 'all' : 'none'; const conversationMarkdown = generateConversationMarkdown(conversationData, includeArtifacts, branchArtifacts, branchInfo); const conversationFilename = generateConversationFilename(conversationData); downloadFile(conversationFilename, conversationMarkdown); if (settings.includeArtifactsInConversationOnly && branchArtifacts.size > 0) { const artifactCount = countArtifacts(branchArtifacts); showNotification(`Conversation exported with ${artifactCount} embedded artifacts!`, 'success'); } else { showNotification('Conversation exported successfully!', 'success'); } } catch (error) { console.error('Export failed:', error); showNotification(`Export failed: ${error.message}`, 'error'); } } // ============================================= // INITIALIZATION // ============================================= /** * Initializes the script and registers menu commands */ function init() { console.log('[Claude API Exporter] Initializing...'); // Register menu commands GM_registerMenuCommand('⚙️ Settings', showSettingsUI); GM_registerMenuCommand('📄 Export Conversation Only', exportConversationOnly); GM_registerMenuCommand('📁 Export Conversation + Final Artifacts', () => exportConversation(true, false)); GM_registerMenuCommand('📁 Export Conversation + All Artifacts', () => exportConversation(false, false)); GM_registerMenuCommand('📁 Export Conversation + Latest Artifacts Per Message', () => exportConversation(false, true)); } // Start when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();