// ==UserScript== // @name Claude API Exporter // @namespace http://tampermonkey.net/ // @version 4.9.0 // @description Export Claude conversations, artifacts and projects // @author MRL // @match https://claude.ai/* // @grant GM_registerMenuCommand // @grant GM_download // @grant GM_setValue // @grant GM_getValue // @icon https://www.google.com/s2/favicons?sz=64&domain=claude.ai // @license MIT // @require https://update.greasyfork.icu/scripts/543706/Claude%20Mass%20Exporter%20Library.js // @require https://update.greasyfork.icu/scripts/546704/Claude%20Project%20Documents%20Exporter%20Library.js // @downloadURL https://update.greasyfork.icu/scripts/542117/Claude%20API%20Exporter.user.js // @updateURL https://update.greasyfork.icu/scripts/542117/Claude%20API%20Exporter.meta.js // ==/UserScript== (function() { 'use strict'; // ============================================= // CORE & UTILITIES // ============================================= // JSZIP DYNAMIC LOADER // Code from https://greasyfork.org/ru/scripts/541467 function loadJSZip() { return new Promise((resolve, reject) => { if (typeof JSZip !== 'undefined') { // console.log('[Claude API Exporter] JSZip already available'); resolve(); return; } // console.log('[Claude API Exporter] Loading JSZip from CDN...'); const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js'; script.onload = () => { // console.log('[Claude API Exporter] JSZip script loaded'); setTimeout(() => { if (typeof JSZip !== 'undefined') { // console.log('[Claude API Exporter] JSZip is now available'); resolve(); } else { reject(new Error('JSZip loaded but not available')); } }, 500); }; script.onerror = () => { console.error('[Claude API Exporter] Failed to load JSZip'); reject(new Error('Failed to load JSZip')); }; document.head.appendChild(script); }); } // CONSTANTS AND DEFAULT SETTINGS /** * Default settings configuration */ const defaultSettings = { conversationTemplate: '[{created_date}_{created_time}] {conversation_title}.md', artifactTemplate: '[{timestamp}] {conversation_title} - Branch{branch}{main_suffix}_v{version}_{artifact_title}_{artifactId}_{command}{canceled}{extension}', dateFormat: 'YYYY-MM-DD_HH-MM-SS', // YYYYMMDDHHMMSS, YYYY-MM-DD_HH-MM-SS, ISO artifactExportMode: 'files', // Flexible artifact export settings: 'embed', 'files', 'both' conversationOnlyArtifactMode: 'none', // 'none', 'final', 'all', 'latest_per_message' 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 includeParentMessageUuid: false, // Include parent message UUID in conversation export conversationSortMode: 'chronological', // 'chronological', 'logical', or 'pairs' // Archive settings exportToArchive: true, // Export files to ZIP archive createChatFolders: false, // Create folders for each chat in archive archiveName: 'Claude_Export_{timestamp}_{conversation_title}', chatFolderName: '[{created_date}_{created_time}] {conversation_title}', // Mass export archive settings (only when mass exporter detected) forceArchiveForMassExport: true, // Force archive for mass exports forceChatFoldersForMassExport: true, // Force chat folders for mass exports massExportArchiveName: 'Claude_{export_type}_Export_{timestamp}', massExportProjectsChatFolderName: '{project_name}/[{created_date}_{created_time}] {conversation_title}', // Export all projects massExportSingleChatFolderName: '{project_name}/[{created_date}_{created_time}] {conversation_title}', // Export a single project and recent conversations // Main branch export settings useCurrentLeafForMainBranch: false, // Use current_leaf_message_uuid instead of max index exportOnlyCurrentBranch: false, // Use tree=false to get only user-selected branch mainBranchOnlyIncludeAllMessages: false, // Include all messages in main branch export // Export menu button settings enableConversationOnly: true, enableFinalArtifacts: true, enableAllArtifacts: true, enableLatestPerMessage: true, enableMainBranchOnly: true, enableRawApiExport: false, rawApiExportFormat: 'pretty', // 'pretty', 'compact' }; /** * Available variables for filename templates */ const availableVariables = { // Common variables '{timestamp}': 'Main timestamp (conversation: updated_at, artifact: content_stop_timestamp) in dateFormat setting', '{conversationId}': 'Unique conversation identifier', '{conversation_title}': 'Conversation title', '{artifact_title}': 'Artifact title', '{export_type}': 'Type of export (Conversation, Projects, Recent, etc.)', '{project_name}': 'Project name (for mass exports)', // 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', '{command}': 'Artifact command (create, update, rewrite)', '{extension}': 'File extension based on artifact type', '{canceled}': 'Adds "_Ρanceled" if artifact was canceled, empty otherwise', // Conversation created_at timestamps '{created_date}': 'Conversation created_at date in YYYY-MM-DD format', '{created_time}': 'Conversation created_at time in HH-MM-SS format', '{created_year}': 'Conversation created_at year (YYYY)', '{created_month}': 'Conversation created_at month (MM)', '{created_day}': 'Conversation created_at day (DD)', '{created_hour}': 'Conversation created_at hour (HH)', '{created_minute}': 'Conversation created_at minute (MM)', '{created_second}': 'Conversation created_at second (SS)', // Conversation updated_at timestamps '{updated_date}': 'Conversation updated_at date in YYYY-MM-DD format', '{updated_time}': 'Conversation updated_at time in HH-MM-SS format', '{updated_year}': 'Conversation updated_at year (YYYY)', '{updated_month}': 'Conversation updated_at month (MM)', '{updated_day}': 'Conversation updated_at day (DD)', '{updated_hour}': 'Conversation updated_at hour (HH)', '{updated_minute}': 'Conversation updated_at minute (MM)', '{updated_second}': 'Conversation updated_at second (SS)', // Artifact content_start_timestamp '{content_start_date}': 'Artifact content_start_timestamp date in YYYY-MM-DD format', '{content_start_time}': 'Artifact content_start_timestamp time in HH-MM-SS format', '{content_start_year}': 'Artifact content_start_timestamp year (YYYY)', '{content_start_month}': 'Artifact content_start_timestamp month (MM)', '{content_start_day}': 'Artifact content_start_timestamp day (DD)', '{content_start_hour}': 'Artifact content_start_timestamp hour (HH)', '{content_start_minute}': 'Artifact content_start_timestamp minute (MM)', '{content_start_second}': 'Artifact content_start_timestamp second (SS)', // Artifact content_stop_timestamp '{content_stop_date}': 'Artifact content_stop_timestamp date in YYYY-MM-DD format', '{content_stop_time}': 'Artifact content_stop_timestamp time in HH-MM-SS format', '{content_stop_year}': 'Artifact content_stop_timestamp year (YYYY)', '{content_stop_month}': 'Artifact content_stop_timestamp month (MM)', '{content_stop_day}': 'Artifact content_stop_timestamp day (DD)', '{content_stop_hour}': 'Artifact content_stop_timestamp hour (HH)', '{content_stop_minute}': 'Artifact content_stop_timestamp minute (MM)', '{content_stop_second}': 'Artifact content_stop_timestamp second (SS)', // Current export time variables '{current_date}': 'Current export date in YYYY-MM-DD format', '{current_time}': 'Current export time in HH-MM-SS format', '{current_year}': 'Current export year (YYYY)', '{current_month}': 'Current export month (MM)', '{current_day}': 'Current export day (DD)', '{current_hour}': 'Current export hour (HH)', '{current_minute}': 'Current export minute (MM)', '{current_second}': 'Current export 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 SYSTEM // ============================================= /** * Creates standard template variables from conversation data */ function createStandardTemplateVariables(conversationData) { const createdDate = new Date(conversationData.created_at); const updatedDate = new Date(conversationData.updated_at); const currentDate = new Date(); return { '{conversationId}': conversationData.uuid, '{conversation_title}': sanitizeFileName(conversationData.name), // Conversation created_at variables '{created_date}': createdDate.toISOString().split('T')[0], '{created_time}': createdDate.toTimeString().split(' ')[0].replace(/:/g, '-'), '{created_year}': createdDate.getFullYear().toString(), '{created_month}': String(createdDate.getMonth() + 1).padStart(2, '0'), '{created_day}': String(createdDate.getDate()).padStart(2, '0'), '{created_hour}': String(createdDate.getHours()).padStart(2, '0'), '{created_minute}': String(createdDate.getMinutes()).padStart(2, '0'), '{created_second}': String(createdDate.getSeconds()).padStart(2, '0'), // Conversation updated_at variables '{updated_date}': updatedDate.toISOString().split('T')[0], '{updated_time}': updatedDate.toTimeString().split(' ')[0].replace(/:/g, '-'), '{updated_year}': updatedDate.getFullYear().toString(), '{updated_month}': String(updatedDate.getMonth() + 1).padStart(2, '0'), '{updated_day}': String(updatedDate.getDate()).padStart(2, '0'), '{updated_hour}': String(updatedDate.getHours()).padStart(2, '0'), '{updated_minute}': String(updatedDate.getMinutes()).padStart(2, '0'), '{updated_second}': String(updatedDate.getSeconds()).padStart(2, '0'), // Current time variables '{current_date}': currentDate.toISOString().split('T')[0], '{current_time}': currentDate.toTimeString().split(' ')[0].replace(/:/g, '-'), '{current_year}': currentDate.getFullYear().toString(), '{current_month}': String(currentDate.getMonth() + 1).padStart(2, '0'), '{current_day}': String(currentDate.getDate()).padStart(2, '0'), '{current_hour}': String(currentDate.getHours()).padStart(2, '0'), '{current_minute}': String(currentDate.getMinutes()).padStart(2, '0'), '{current_second}': String(currentDate.getSeconds()).padStart(2, '0') }; } /** * Creates artifact-specific template variables */ function createArtifactTemplateVariables(version, conversationData, branchLabel, isMain, artifactId) { const baseVariables = createStandardTemplateVariables(conversationData); const contentStartDate = new Date(version.content_start_timestamp); const contentStopDate = new Date(version.content_stop_timestamp); return { ...baseVariables, '{timestamp}': generateTimestamp(version.content_stop_timestamp), '{artifactId}': artifactId, '{version}': version.version.toString(), '{branch}': branchLabel, '{main_suffix}': isMain ? '_main' : '', '{artifact_title}': sanitizeFileName(version.title), '{command}': version.command, '{extension}': getFileExtension(version.finalType, version.finalLanguage), '{canceled}': version.stop_reason === 'user_canceled' ? '_Ρanceled' : '', // Artifact content_start_timestamp variables '{content_start_date}': contentStartDate.toISOString().split('T')[0], '{content_start_time}': contentStartDate.toTimeString().split(' ')[0].replace(/:/g, '-'), '{content_start_year}': contentStartDate.getFullYear().toString(), '{content_start_month}': String(contentStartDate.getMonth() + 1).padStart(2, '0'), '{content_start_day}': String(contentStartDate.getDate()).padStart(2, '0'), '{content_start_hour}': String(contentStartDate.getHours()).padStart(2, '0'), '{content_start_minute}': String(contentStartDate.getMinutes()).padStart(2, '0'), '{content_start_second}': String(contentStartDate.getSeconds()).padStart(2, '0'), // Artifact content_stop_timestamp variables '{content_stop_date}': contentStopDate.toISOString().split('T')[0], '{content_stop_time}': contentStopDate.toTimeString().split(' ')[0].replace(/:/g, '-'), '{content_stop_year}': contentStopDate.getFullYear().toString(), '{content_stop_month}': String(contentStopDate.getMonth() + 1).padStart(2, '0'), '{content_stop_day}': String(contentStopDate.getDate()).padStart(2, '0'), '{content_stop_hour}': String(contentStopDate.getHours()).padStart(2, '0'), '{content_stop_minute}': String(contentStopDate.getMinutes()).padStart(2, '0'), '{content_stop_second}': String(contentStopDate.getSeconds()).padStart(2, '0') }; } /** * 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) { return { ...baseData }; } /** * Generates conversation filename using template */ function generateConversationFilename(conversationData) { const settings = loadSettings(); const variables = { ...createStandardTemplateVariables(conversationData), '{timestamp}': generateTimestamp(conversationData.updated_at) }; return applyTemplate(settings.conversationTemplate, variables); } /** * Generates artifact filename using template */ function generateArtifactFilename(version, conversationData, branchLabel, isMain, artifactId) { const settings = loadSettings(); const variables = createArtifactTemplateVariables(version, conversationData, branchLabel, isMain, artifactId); return applyTemplate(settings.artifactTemplate, variables); } /** * Generates archive filename using template */ function generateArchiveName(conversationData, template, isMassExport = false, exportType = 'Conversation') { const variables = { ...createStandardTemplateVariables(conversationData), '{timestamp}': generateTimestamp(new Date()), '{export_type}': exportType }; return applyTemplate(template, variables) + '.zip'; } /** * Generates chat folder name using template */ function generateChatFolderName(conversationData, projectData, template) { const variables = { ...createStandardTemplateVariables(conversationData), '{timestamp}': generateTimestamp(new Date()), '{project_name}': projectData ? sanitizeFileName(projectData.name) : '', '{project_id}': projectData ? projectData.uuid : '' }; return applyTemplate(template, variables); } // ============================================= // 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 = createSettingsHTML(currentSettings); // Add styles to head and modal to body document.head.insertAdjacentHTML('beforeend', getSettingsStyles()); document.body.appendChild(overlay); // Initialize functionality initTabs(); initEventListeners(); updatePreviews(); } /** * Creates the settings HTML structure */ function createSettingsHTML(currentSettings) { return `
`; } /** * Returns compressed CSS styles for settings UI */ function getSettingsStyles() { return ``; } 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'); }); }); } function initEventListeners() { // Check for mass exporter and show/hide settings const massExportSettings = document.getElementById('massExportArchiveSettings'); if (typeof window.claudeMassExporter !== 'undefined') { massExportSettings.style.display = 'block'; } setupConditionalSettings(); setupFormElementListeners(); setupVariablesPanel(); setupControlButtons(); setupModalHandlers(); } function setupConditionalSettings() { function updateConditionalElements() { const exportToArchive = document.getElementById('exportToArchive').checked; const createChatFolders = document.getElementById('createChatFolders').checked; const forceArchive = document.getElementById('forceArchiveForMassExport').checked; const forceChatFolders = document.getElementById('forceChatFoldersForMassExport').checked; const enableRawApi = document.getElementById('enableRawApiExport').checked; document.querySelectorAll('[data-depends="exportToArchive"]').forEach(el => { el.style.display = exportToArchive ? 'block' : 'none'; }); document.querySelectorAll('[data-depends="createChatFolders,exportToArchive"]').forEach(el => { el.style.display = (createChatFolders && exportToArchive) ? 'block' : 'none'; }); document.querySelectorAll('[data-depends="forceArchiveForMassExport"]').forEach(el => { el.style.display = forceArchive ? 'block' : 'none'; }); document.querySelectorAll('[data-depends="forceChatFoldersForMassExport,forceArchiveForMassExport"]').forEach(el => { el.style.display = (forceChatFolders && forceArchive) ? 'block' : 'none'; }); document.querySelectorAll('[data-depends="enableRawApiExport"]').forEach(el => { el.style.display = enableRawApi ? 'block' : 'none'; }); } ['exportToArchive', 'createChatFolders', 'forceArchiveForMassExport', 'forceChatFoldersForMassExport', 'enableRawApiExport'].forEach(id => { document.getElementById(id)?.addEventListener('change', updateConditionalElements); }); updateConditionalElements(); } function setupFormElementListeners() { const formElements = ['conversationTemplate', 'artifactTemplate', 'dateFormat', 'artifactExportMode', 'excludeCanceledArtifacts', 'conversationOnlyArtifactMode', 'includeArtifactMetadata', 'excludeAttachments', 'removeDoubleNewlinesFromConversation', 'removeDoubleNewlinesFromMarkdown', 'includeParentMessageUuid', 'conversationSortMode', 'exportToArchive', 'createChatFolders', 'archiveName', 'chatFolderName', 'forceArchiveForMassExport', 'forceChatFoldersForMassExport', 'massExportArchiveName', 'massExportProjectsChatFolderName', 'massExportSingleChatFolderName', 'useCurrentLeafForMainBranch', 'exportOnlyCurrentBranch','mainBranchOnlyIncludeAllMessages', 'enableConversationOnly', 'enableFinalArtifacts', 'enableAllArtifacts', 'enableLatestPerMessage', 'enableMainBranchOnly', 'enableRawApiExport', 'rawApiExportFormat']; formElements.forEach(id => { const element = document.getElementById(id); if (element) { element.addEventListener(element.type === 'checkbox' ? 'change' : 'input', updatePreviews); } }); } function setupVariablesPanel() { 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'; }); } function setupControlButtons() { 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(); } }); 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(); }); } function setupModalHandlers() { document.getElementById('cancelSettings').addEventListener('click', closeModal); document.querySelector('.claude-settings-close').addEventListener('click', closeModal); document.querySelector('.claude-settings-overlay').addEventListener('click', (e) => { if (e.target.classList.contains('claude-settings-overlay')) closeModal(); }); } function closeModal() { document.getElementById('claude-exporter-settings')?.remove(); document.getElementById('claude-exporter-styles')?.remove(); } 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 conversationOnlyArtifactMode = document.getElementById('conversationOnlyArtifactMode').value; const mainBranchOnlyIncludeAllMessages = document.getElementById('mainBranchOnlyIncludeAllMessages').checked; // Sample data for preview const sampleTime = new Date(); const sampleVariables = createTemplateVariables({ '{timestamp}': formatTimestamp(sampleTime, dateFormat), '{conversationId}': '12dasdh1-fa1j-f213-da13-dfa3124123ff', '{conversation_title}': 'ConversationTitle', '{artifact_title}': 'ArtifactTitle', '{artifactId}': 'artifact2', '{version}': '3', '{branch}': '2', '{main_suffix}': '_main', '{command}': 'create', '{extension}': '.js', '{canceled}': '_canceled', // Add all time variables ...createStandardTemplateVariables({ created_at: sampleTime.toISOString(), updated_at: sampleTime.toISOString(), uuid: '12dasdh1-fa1j-f213-da13-dfa3124123ff', name: 'ConversationTitle' }) }); document.getElementById('conversationPreview').textContent = 'Preview: ' + applyTemplate(conversationTemplate, sampleVariables); document.getElementById('artifactPreview').textContent = 'Preview: ' + applyTemplate(artifactTemplate, sampleVariables); // Update behavior descriptions updateBehaviorDescriptions(artifactExportMode, conversationOnlyArtifactMode, mainBranchOnlyIncludeAllMessages); } function updateBehaviorDescriptions(artifactExportMode, conversationOnlyArtifactMode, mainBranchOnlyIncludeAllMessages) { const behaviors = { conversationOnlyBehavior: { 'none': 'Pure conversation without artifacts', 'final': 'Embeds final artifacts in conversation file', 'all': 'Embeds all artifacts in conversation file', 'latest_per_message': 'Embeds latest artifacts per message in conversation file', 'main_branch_only': mainBranchOnlyIncludeAllMessages ? 'All messages + main branch artifacts in conversation file' : 'Main branch messages + main branch artifacts in conversation file' }[conversationOnlyArtifactMode], 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], latestPerMessageBehavior: { 'embed': 'Embeds latest artifacts per message in conversation file only', 'files': 'Pure conversation + latest artifacts per message as separate files', 'both': 'Embeds latest artifacts per message in conversation + exports as separate files' }[artifactExportMode], mainBranchOnlyBehavior: (() => { const messageMode = mainBranchOnlyIncludeAllMessages ? 'All messages' : 'Main branch messages'; return { 'embed': `${messageMode} + main branch artifacts in conversation file only`, 'files': `${messageMode} pure conversation + main branch artifacts as separate files`, 'both': `${messageMode} + main branch artifacts in conversation + exports as separate files` }[artifactExportMode]; })() }; Object.entries(behaviors).forEach(([id, text]) => { const element = document.getElementById(id); if (element) { element.textContent = text; } }); } // ============================================= // UTILITY FUNCTIONS // ============================================= // BASIC UTILITIES /** * 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); } // NOTIFICATION FUNCTIONS /** * Wraps async functions with error handling */ async function withErrorHandling(asyncFn, operationName) { try { return await asyncFn(); } catch (error) { console.error(`[Claude API Exporter] ${operationName} failed:`, error); showNotification(`${operationName} failed: ${error.message}`, 'error'); throw error; } } /** * 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") { // Remove any existing notifications to avoid overlap document.querySelectorAll('.claude-notification').forEach(n => n.remove()); const notification = document.createElement('div'); notification.className = 'claude-notification'; 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); } /** * Generates appropriate notification message based on export results */ function generateExportNotification(exportResult, exportData) { const { totalExported, artifactCount, archiveUsed } = exportResult; const { settings, mode } = exportData; if (archiveUsed) { // Archive mode notifications if (artifactCount > 0) { return `Archive export completed! Downloaded archive with conversation + ${artifactCount} artifacts (${mode})`; } else { return `Archive export completed! Downloaded archive with conversation (no artifacts found)`; } } else { // Regular download mode notifications if (settings.artifactExportMode !== 'embed' && artifactCount > 0) { // Has separate artifact files let modeText; if (settings.artifactExportMode === 'both') { modeText = 'embedded + separate files'; } else if (settings.artifactExportMode === 'files') { modeText = 'separate files'; } else { modeText = 'embedded in conversation'; } return `Export completed! Downloaded conversation + ${artifactCount} artifacts as ${modeText} (${mode})`; } else if (artifactCount > 0) { // Only embedded artifacts return `Export completed! Downloaded conversation with ${artifactCount} embedded artifacts (${mode})`; } else { return 'Export completed! No artifacts found in conversation'; } } } // ============================================= // ARCHIVE MANAGEMENT // ============================================= /** * Creates and manages ZIP archive for exports */ class ArchiveManager { constructor() { this.zip = null; this.fileCount = 0; this.isReady = false; } async initialize() { if (this.isReady) return; // console.log('[Claude API Exporter] Initializing ArchiveManager...'); await loadJSZip(); this.zip = new JSZip(); this.isReady = true; // console.log('[Claude API Exporter] ArchiveManager initialized successfully'); } async addFile(filename, content, useFolder = false, folderName = '') { if (!this.isReady) { await this.initialize(); } let finalFilename = filename; if (useFolder && folderName) { finalFilename = `${sanitizeFileName(folderName)}/${filename}`; } // console.log(`[Claude API Exporter] Adding file to archive: ${finalFilename}`); this.zip.file(finalFilename, content); this.fileCount++; // console.log(`[Claude API Exporter] File added. Total files: ${this.fileCount}`); } async downloadArchive(archiveName) { if (!this.isReady) { throw new Error('ArchiveManager not initialized'); } if (this.fileCount === 0) { throw new Error('No files to archive'); } console.log(`[Claude API Exporter] Creating archive with ${this.fileCount} files...`); showNotification(`Creating archive with ${this.fileCount} files...`, 'info'); try { // console.log('[Claude API Exporter] Generating ZIP blob...'); const zipBlob = await this.zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } }); console.log('[Claude API Exporter] ZIP generated successfully, size:', zipBlob.size); const url = URL.createObjectURL(zipBlob); const link = document.createElement('a'); link.href = url; link.download = archiveName; document.body.appendChild(link); link.click(); document.body.removeChild(link); // console.log('[Claude API Exporter] Archive download initiated'); // Cleanup setTimeout(() => { URL.revokeObjectURL(url); // console.log('[Claude API Exporter] Archive cleanup completed'); }, 1000); } catch (error) { console.error('[Claude API Exporter] Archive generation failed:', error); throw new Error(`Archive generation failed: ${error.message}`); } } } /** * Enhanced download function that supports both regular and archive modes */ async function downloadFileEnhanced(filename, content, archiveManager = null, createFolder = false) { if (archiveManager) { await archiveManager.addFile(filename, content, createFolder); } else { downloadFile(filename, content); } } /** * Helper to add file to archive with folder support */ async function addFileToArchive(archiveManager, filename, content, conversationData, settings, projectData = null) { if (!archiveManager) { downloadFile(filename, content); return; } const shouldUseFolder = settings.createChatFolders; let folderName = ''; if (shouldUseFolder) { folderName = generateChatFolderName(conversationData, projectData, settings.chatFolderName); } console.log(`[Claude API Exporter] Adding file to archive: ${filename}`); await archiveManager.addFile(filename, content, shouldUseFolder, folderName); } // ============================================= // API FUNCTIONS // ============================================= /** * Extracts conversation ID from current URL */ function getConversationId() { const match = window.location.pathname.match(/\/chat\/([^/?]+)/); return match ? match[1] : null; } /** * Gets organization ID from browser cookies */ 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 */ async function getConversationData(conversationId = null) { const id = conversationId || getConversationId(); if (!id) { return null; } const orgId = getOrgId(); const settings = loadSettings(); // Use tree=false if user wants only current branch const treeParam = settings.exportOnlyCurrentBranch ? 'false' : 'true'; const response = await fetch(`/api/organizations/${orgId}/chat_conversations/${id}?tree=${treeParam}&rendering_mode=messages&render_all_tools=true`); if (!response.ok) { throw new Error(`API request failed: ${response.status}`); } return await response.json(); } // ============================================= // MASS EXPORT DETECTION // ============================================= /** * Checks if current context supports mass export and handles it */ function checkAndHandleMassExport(finalVersionsOnly, latestPerMessage, mainBranchOnly) { const path = window.location.pathname; let context = null; if (path === '/projects') { context = { type: 'projects', title: 'All Projects' }; } else if (path.match(/^\/project\/[^/]+$/)) { context = { type: 'project', title: 'Current Project', projectId: path.split('/')[2] }; } else if (path === '/recents') { context = { type: 'recents', title: 'Recent Conversations' }; } else if (path === '/new') { context = { type: 'recents', title: 'Recent Conversations' }; } if (context && typeof window.claudeMassExporter !== 'undefined') { console.log(`[Claude API Exporter] Mass export mode detected: ${context.title}`); let exportMode = 'all'; if (latestPerMessage) { exportMode = 'latest_per_message'; } else if (finalVersionsOnly) { exportMode = 'final'; } if (context.type === 'projects') { return window.claudeMassExporter.exportAllProjects(exportMode, mainBranchOnly); } else if (context.type === 'project') { return window.claudeMassExporter.exportCurrentProject(exportMode, mainBranchOnly); } else if (context.type === 'recents') { return window.claudeMassExporter.exportAllRecentConversations(exportMode, mainBranchOnly); } } return null; } /** * Checks if current context supports mass export for conversation only */ function checkAndHandleMassExportConversationOnly() { const path = window.location.pathname; let context = null; if (path === '/projects') { context = { type: 'projects', title: 'All Projects' }; } else if (path.match(/^\/project\/[^/]+$/)) { context = { type: 'project', title: 'Current Project', projectId: path.split('/')[2] }; } else if (path === '/recents') { context = { type: 'recents', title: 'Recent Conversations' }; } else if (path === '/new') { context = { type: 'recents', title: 'Recent Conversations' }; } if (context && typeof window.claudeMassExporter !== 'undefined') { console.log(`[Claude API Exporter] Mass export mode detected: ${context.title}`); if (context.type === 'projects') { return window.claudeMassExporter.exportAllProjects('none', false); } else if (context.type === 'project') { return window.claudeMassExporter.exportCurrentProject('none', false); } else if (context.type === 'recents') { return window.claudeMassExporter.exportAllRecentConversations('none', false); } } return null; } /** * Checks if current context supports mass export for raw API data */ function checkAndHandleMassExportRawApi() { const path = window.location.pathname; if (path === '/projects' && typeof window.claudeMassExporter !== 'undefined') { return window.claudeMassExporter.exportAllProjects('final', false, true); // rawApiMode = true } else if (path.match(/^\/project\/[^/]+$/) && typeof window.claudeMassExporter !== 'undefined') { return window.claudeMassExporter.exportCurrentProject('final', false, true); // rawApiMode = true } else if (path === '/recents' && typeof window.claudeMassExporter !== 'undefined') { return window.claudeMassExporter.exportAllRecentConversations('final', false, true); // rawApiMode = true } else if (path === '/new' && typeof window.claudeMassExporter !== 'undefined') { return window.claudeMassExporter.exportAllRecentConversations('final', false, true); // rawApiMode = true } return null; } // ============================================= // 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: '' }; } 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 = []; // Create message map messages.forEach(message => { messageMap.set(message.uuid, { ...message, children: [], branchId: null, branchIndex: null }); }); // 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 }; } /** * Finds the main path using current settings */ function findMainBranchPath(tree, conversationData = null) { const settings = loadSettings(); if (settings.useCurrentLeafForMainBranch && conversationData && conversationData.current_leaf_message_uuid) { // Use current_leaf_message_uuid to build main path return buildPathFromLeafMessage(tree, conversationData.current_leaf_message_uuid); } else { // Use existing logic (max index) return buildPathFromMaxIndex(tree); } } function buildPathFromLeafMessage(tree, currentLeafUuid) { const mainPath = []; let currentMessage = tree.messageMap.get(currentLeafUuid); while (currentMessage) { mainPath.unshift(currentMessage); // Add to beginning const parentUuid = currentMessage.parent_message_uuid; if (parentUuid === "00000000-0000-4000-8000-000000000000" || !parentUuid) { break; } currentMessage = tree.messageMap.get(parentUuid); } return mainPath; } /** * Finds main branch path from message with maximum index */ function buildPathFromMaxIndex(tree) { // Find message with maximum index let maxIndexMessage = null; let maxIndex = -1; tree.messageMap.forEach(message => { if (message.index > maxIndex) { maxIndex = message.index; maxIndexMessage = message; } }); if (!maxIndexMessage) return []; // Build path backwards through parent_message_uuid const mainPath = []; let currentMessage = maxIndexMessage; while (currentMessage) { mainPath.unshift(currentMessage); const parentUuid = currentMessage.parent_message_uuid; if (parentUuid === "00000000-0000-4000-8000-000000000000" || !parentUuid) { break; } currentMessage = tree.messageMap.get(parentUuid); } return mainPath; } /** * Gets all branch information including branch points */ function getAllBranchInfo(tree, conversationData = null) { // Find main path using settings const mainBranchPath = findMainBranchPath(tree, conversationData); const mainBranchUuids = new Set(mainBranchPath.map(msg => msg.uuid)); // Two-pass approach: // Pass 1: Collect all branch starting points const branchStartPoints = []; function collectBranchPoints(node) { if (node.children.length > 1) { // Multiple children = branch point const sortedChildren = [...node.children].sort((a, b) => a.index - b.index); // Skip first child (continues parent branch) for (let i = 1; i < sortedChildren.length; i++) { branchStartPoints.push({ index: sortedChildren[i].index, node: sortedChildren[i] }); } } // Recurse to all children node.children.forEach(child => collectBranchPoints(child)); } // Collect branch points from all roots tree.rootMessages.forEach(root => collectBranchPoints(root)); // Also add additional root messages as branch starts (if multiple roots) for (let i = 1; i < tree.rootMessages.length; i++) { branchStartPoints.push({ index: tree.rootMessages[i].index, node: tree.rootMessages[i] }); } // Sort branch start points by index branchStartPoints.sort((a, b) => a.index - b.index); // Create a map from node to branch number const nodeToBranchNumber = new Map(); branchStartPoints.forEach((point, i) => { nodeToBranchNumber.set(point.node, i + 2); // +2 because main is 1 }); // Pass 2: Assign branch numbers and collect leaves const leafToBranchNumber = new Map(); function assignBranch(node, currentBranchNumber) { // If leaf node, record its branch number if (node.children.length === 0) { leafToBranchNumber.set(node.uuid, currentBranchNumber); return; } if (node.children.length === 1) { // Single child continues current branch assignBranch(node.children[0], currentBranchNumber); } else { // Multiple children const sortedChildren = [...node.children].sort((a, b) => a.index - b.index); // First child continues current branch assignBranch(sortedChildren[0], currentBranchNumber); // Other children use their assigned branch numbers for (let i = 1; i < sortedChildren.length; i++) { const child = sortedChildren[i]; const childBranchNumber = nodeToBranchNumber.get(child) || currentBranchNumber; assignBranch(child, childBranchNumber); } } } // Start assignment from first root with branch 1 if (tree.rootMessages.length > 0) { assignBranch(tree.rootMessages[0], 1); } // Assign other roots with their branch numbers for (let i = 1; i < tree.rootMessages.length; i++) { const root = tree.rootMessages[i]; const branchNumber = nodeToBranchNumber.get(root) || 1; assignBranch(root, branchNumber); } // Now collect leaves with their paths and branchStartIndex (original logic for compatibility) const branches = []; const leafMessages = []; function collectLeaves(node, currentPath = [], branchStartIndex = 0) { const newPath = [...currentPath, node]; if (node.children.length === 0) { leafMessages.push({ leaf: node, fullPath: newPath, branchStartIndex: branchStartIndex }); } else if (node.children.length === 1) { collectLeaves(node.children[0], newPath, branchStartIndex); } else { // Multiple children - branch point // Sort children by index const sortedChildren = [...node.children].sort((a, b) => a.index - b.index); sortedChildren.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; collectLeaves(child, newPath, newBranchStart); }); } } tree.rootMessages.forEach(root => { collectLeaves(root, [], 0); }); // Create branches using pre-assigned branch numbers leafMessages.forEach((leafData) => { const branchIndex = leafToBranchNumber.get(leafData.leaf.uuid); const isMainBranch = leafData.fullPath.every(msg => mainBranchUuids.has(msg.uuid)) && leafData.fullPath.length === mainBranchPath.length; branches.push({ branchId: leafData.leaf.uuid, branchIndex: branchIndex, fullPath: leafData.fullPath, branchStartIndex: leafData.branchStartIndex, isMainBranch: isMainBranch }); }); return { branches, mainBranchPath: mainBranchPath }; } /** * Creates a Map of messageUuid to branch index */ function createMessageBranchMap(branches) { const messageBranchMap = new Map(); branches.forEach(branch => { branch.fullPath.forEach(msg => { if (!messageBranchMap.has(msg.uuid)) { messageBranchMap.set(msg.uuid, branch.branchIndex); } }); }); return messageBranchMap; } // ============================================= // CONVERSATION PROCESSING // ============================================= /** * 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 settings = loadSettings(); 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; } /** * Filters conversation data for main branch only */ function filterConversationForMainBranch(conversationData, mainBranchUuids, settings) { if (!settings.mainBranchOnlyIncludeAllMessages) { return { ...conversationData, name: conversationData.name, chat_messages: conversationData.chat_messages.filter(message => mainBranchUuids && mainBranchUuids.has(message.uuid) ) }; } return conversationData; } /** * Filters artifacts for main branch only */ function filterArtifactsForMainBranch(branchArtifacts, mainBranchUuids) { const filteredBranchArtifacts = new Map(); for (const [branchId, artifactsMap] of branchArtifacts) { const filteredArtifactsMap = new Map(); for (const [artifactId, versions] of artifactsMap) { const mainVersions = versions.filter(version => mainBranchUuids && mainBranchUuids.has(version.messageUuid) ); if (mainVersions.length > 0) { filteredArtifactsMap.set(artifactId, mainVersions); } } if (filteredArtifactsMap.size > 0) { filteredBranchArtifacts.set(branchId, filteredArtifactsMap); } } return filteredBranchArtifacts; } /** * Sorts messages based on the specified mode */ function sortMessages(messages, sortMode) { if (sortMode === 'logical') { // Logical order by parent_message_uuid tree structure const messageMap = new Map(); const rootMessages = []; messages.forEach(msg => { messageMap.set(msg.uuid, { ...msg, children: [] }); }); messages.forEach(msg => { if (msg.parent_message_uuid === "00000000-0000-4000-8000-000000000000") { rootMessages.push(messageMap.get(msg.uuid)); } else if (messageMap.has(msg.parent_message_uuid)) { const parent = messageMap.get(msg.parent_message_uuid); parent.children.push(messageMap.get(msg.uuid)); } }); function sortChildren(node) { if (node.children.length > 0) { node.children.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); node.children.forEach(child => sortChildren(child)); } } rootMessages.forEach(root => sortChildren(root)); const orderedMessages = []; function collectMessages(node) { orderedMessages.push(node); node.children.forEach(child => collectMessages(child)); } rootMessages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); rootMessages.forEach(root => collectMessages(root)); return orderedMessages; } else if (sortMode === 'pairs') { // Sort by Human β Claude pairs in chronological order const humanMessages = messages .filter(msg => msg.sender === 'human') .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); const orderedMessages = []; const processed = new Set(); humanMessages.forEach(humanMsg => { // Add Human message orderedMessages.push(humanMsg); processed.add(humanMsg.uuid); // Find all Claude responses to this Human const claudeResponses = messages .filter(msg => msg.sender === 'assistant' && msg.parent_message_uuid === humanMsg.uuid && !processed.has(msg.uuid) ) .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); // Add Claude responses claudeResponses.forEach(claudeMsg => { orderedMessages.push(claudeMsg); processed.add(claudeMsg.uuid); }); }); return orderedMessages; } else { // Chronological order by index (current behavior) return messages.sort((a, b) => a.index - b.index); } } // ============================================= // CONTENT PROCESSING // ============================================= /** * 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; } /** * 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 (version.stop_reason === 'user_canceled') { metadataInfo.push(`Status: CANCELED`); } // 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' }; metadata += separators[start] || '\n# ---\n'; return metadata; } /** * Processes artifact content in conversation markdown */ function processArtifactInConversation(input, content, message, isCanceled, includeArtifacts, branchArtifacts, branchInfo, mainBranchUuids, settings) { const lines = []; if (input.title) { let titleLine = `**Artifact Created:** ${input.title}`; // User canceled message if (isCanceled) { titleLine += ` *(incomplete - generation was canceled)*`; } lines.push(titleLine); } lines.push(`*ID:* \`${input.id}\``); lines.push(`*Command:* \`${input.command}\``); // 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; // Determine if this is main branch based on message UUID let isMainBranch = false; if (mainBranchUuids && mainBranchUuids.has(artifactVersion.messageUuid)) { isMainBranch = true; } if (branchData) { if (isMainBranch) { branchLabel = `branch${branchData.branchIndex} (main) (${artifactVersion.branchId.substring(0, 8)}...)`; } else { branchLabel = `branch${branchData.branchIndex} (${artifactVersion.branchId.substring(0, 8)}...)`; } } lines.push(`*Version:* ${artifactVersion.version}`); lines.push(`*Branch:* ${branchLabel}`); lines.push(`*Created:* ${formatDate(artifactVersion.content_stop_timestamp || artifactVersion.timestamp_created_at)}`); // Show change description if available if (artifactVersion.changeDescription) { lines.push(`*Change:* ${artifactVersion.changeDescription}`); } if (artifactVersion.updateInfo) { lines.push(`*Update Info:* ${artifactVersion.updateInfo}`); } if (artifactVersion.stop_reason === 'user_canceled') { lines.push(`*Status:* CANCELED`); } } } // Include artifact content based on mode if (includeArtifacts !== 'none' && branchArtifacts) { // User canceled message if (shouldSkipCanceledMessage(message, settings)) { lines.push(''); lines.push('*Artifact content excluded (generation was canceled)*'); } else { let artifactContent = null; if (includeArtifacts === 'latest_per_message') { // Find the latest artifact version in this specific message using the same logic const latestInMessage = findLatestArtifactsInMessage(message); 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) { lines.push(''); lines.push('### Artifact Content'); lines.push(''); // 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); } lines.push('```' + language); lines.push(processedArtifactContent); lines.push('```'); } } } return lines.join('\n'); } /** * Checks if message should be skipped due to cancellation */ function shouldSkipCanceledMessage(message, settings) { return settings.excludeCanceledArtifacts && message.stop_reason === 'user_canceled'; } /** * Finds latest artifacts in a message */ function findLatestArtifactsInMessage(message) { const latestInMessage = new Map(); message.content.forEach(content => { if (content.type === 'tool_use' && content.name === 'artifacts' && content.input) { const artifactId = content.input.id; latestInMessage.set(artifactId, content); } }); return latestInMessage; } // ============================================= // ARTIFACT PROCESSING // ============================================= /** * 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 // If branchStartIndex > 0, first collect inherited states from parent path if (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 (shouldSkipCanceledMessage(message, settings)) { // 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: continue from inherited count if exists const inheritedCount = inheritedStates.has(artifactId) ? inheritedStates.get(artifactId).versionCount : 0; versionNumber = inheritedCount + 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, artifactMessageUuid: message.uuid }); } }); }); return { ownArtifacts, inheritedStates }; } /** * 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 (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; 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); processedVersions.push({ ...version, fullContent: currentContent, changeDescription: updateInfo ? changeDescription : changeDescription, updateInfo: updateInfo, branchId: branchId, isMainBranch: isMainBranch, inheritedContent: versionStartContent, finalType: currentType, finalLanguage: currentLanguage }); }); processedArtifacts.set(artifactId, processedVersions); }); return processedArtifacts; } /** * 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) { 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') + (version.old_str.length > 50 ? '...' : '') : ''; const newPreview = version.new_str ? version.new_str.substring(0, 50).replace(/\n/g, '\\n') + (version.new_str.length > 50 ? '...' : '') : ''; let changeDescription = `"${oldPreview}" β "${newPreview}"`; return changeDescription; default: return `Unknown command: ${version.command}`; } } /** * 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; } /** * Builds set of latest artifact timestamps for latest per message mode */ function buildLatestArtifactTimestamps(conversationData) { const latestArtifactTimestamps = new Set(); conversationData.chat_messages.forEach(message => { const latestInMessage = findLatestArtifactsInMessage(message); latestInMessage.forEach((content) => { if (content.stop_timestamp) { latestArtifactTimestamps.add(content.stop_timestamp); } }); }); return latestArtifactTimestamps; } /** * Extracts and processes all artifacts from all branches with proper inheritance * @param {Object} conversationData - Complete conversation data * @returns {Object} {branchArtifacts: Map, branchInfo: Array, mainBranchUuids: Set} */ function extractAllArtifacts(conversationData) { // Build conversation tree const tree = buildConversationTree(conversationData.chat_messages); const { branches, mainBranchPath } = getAllBranchInfo(tree, conversationData); const mainBranchUuids = new Set(mainBranchPath.map(msg => msg.uuid)); const messageBranchMap = createMessageBranchMap(branches); console.log(`Found ${branches.length} conversation branches`); const branchArtifacts = new Map(); // branchId -> Map