// ==UserScript==
// @name Claude Exporter 0.10+
// @namespace http://tampermonkey.net/
// @version 0.10+
// @description Export Claude conversations using API
// @author MRL
// @match https://claude.ai/chat/*
// @grant GM_registerMenuCommand
// @grant GM_download
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// =============================================
// UTILITY FUNCTIONS
// =============================================
/**
* Generates timestamp in format YYYYMMDDHHMMSS for file naming
* @returns {string} Formatted timestamp
*/
function generateTimestamp() {
const now = new Date();
return now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0');
}
/**
* Sanitizes filename by removing invalid characters and limiting length
* @param {string} name - Original filename
* @returns {string} Sanitized filename safe for file system
*/
function sanitizeFileName(name) {
return name.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, '_')
.replace(/__+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 100);
}
/**
* Downloads content as a file using browser's download functionality
* @param {string} filename - Name of the file to download
* @param {string} content - Content to save in the file
*/
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');
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);
`;
if (type === "error") {
notification.style.backgroundColor = '#f44336';
} else if (type === "success") {
notification.style.backgroundColor = '#4CAF50';
} else {
notification.style.backgroundColor = '#2196F3';
}
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();
}
// =============================================
// 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 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({
command: input.command,
content: input.content || '',
old_str: input.old_str || '',
new_str: input.new_str || '',
title: input.title || `Artifact ${artifactId}`,
timestamp: message.created_at
});
}
});
});
// Build final states from parent artifacts
parentArtifacts.forEach((versions, artifactId) => {
let currentContent = '';
let currentTitle = `Artifact ${artifactId}`;
let versionCount = 0;
versions.forEach(version => {
versionCount++;
switch (version.command) {
case 'create':
currentContent = version.content;
currentTitle = version.title;
break;
case 'rewrite':
currentContent = version.content;
currentTitle = version.title;
break;
case 'update':
const updateResult = applyUpdate(currentContent, version.old_str, version.new_str);
currentContent = updateResult.content;
break;
}
});
inheritedStates.set(artifactId, {
content: currentContent,
title: currentTitle,
versionCount: versionCount
});
});
}
// Now extract artifacts from this branch only (starting from branchStartIndex)
const branchMessages = branchPath.slice(branchStartIndex);
branchMessages.forEach((message, relativeIndex) => {
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({
version: versionNumber,
command: input.command,
uuid: input.version_uuid,
content: input.content || '',
old_str: input.old_str || '',
new_str: input.new_str || '',
title: input.title || `Artifact ${artifactId}`,
timestamp: message.created_at,
messageUuid: message.uuid,
messageIndex: branchStartIndex + relativeIndex,
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) {
return {
success: false,
content: previousContent || '',
info: 'Cannot apply update: missing previousContent or oldStr'
};
}
// Apply the string replacement
const updatedContent = previousContent.replace(oldStr, newStr);
if (updatedContent === previousContent) {
// 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`
};
}
/**
* 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}`;
if (!isMainBranch && inheritedStates.has(artifactId)) {
const inherited = inheritedStates.get(artifactId);
currentContent = inherited.content;
currentTitle = inherited.title;
}
versions.forEach((version, index) => {
let changeDescription = '';
let updateInfo = '';
let versionStartContent = currentContent;
switch (version.command) {
case 'create':
currentContent = version.content;
currentTitle = version.title;
changeDescription = isMainBranch ? 'Created' : 'Created (overriding inherited)';
break;
case 'rewrite':
currentContent = version.content;
currentTitle = version.title;
changeDescription = isMainBranch ? 'Rewritten' : 'Rewritten (from inherited)';
break;
case 'update':
const oldContent = currentContent;
const updateResult = applyUpdate(currentContent, version.old_str, version.new_str);
currentContent = updateResult.content;
updateInfo = updateResult.info;
// Create more informative change description
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') + '...' : '';
changeDescription = `Updated: "${oldPreview}" → "${newPreview}"`;
// Add information about character count changes
const oldLength = oldContent.length;
const newLength = currentContent.length;
const lengthDiff = newLength - oldLength;
if (lengthDiff > 0) {
changeDescription += ` (+${lengthDiff} chars)`;
} else if (lengthDiff < 0) {
changeDescription += ` (${lengthDiff} chars)`;
}
if (!updateResult.success) {
changeDescription += ` [WARNING: ${updateResult.info}]`;
}
break;
default:
console.warn(`Unknown command: ${version.command}`);
break;
}
processedVersions.push({
...version,
fullContent: currentContent,
changeDescription: changeDescription,
updateInfo: updateInfo,
branchId: branchId,
isMainBranch: isMainBranch,
inheritedContent: versionStartContent
});
});
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
// =============================================
/**
* Generates markdown content for the entire conversation
* @param {Object} conversationData - Complete conversation data from API
* @returns {string} Formatted markdown content
*/
function generateConversationMarkdown(conversationData) {
let markdown = '';
// Header with conversation metadata
markdown += `# ${conversationData.name}\n\n`;
markdown += `*URL: https://claude.ai/chat/${conversationData.uuid} *\n`;
// Project info (if available)
if (conversationData.project) {
markdown += `*Project:* [${conversationData.project.name}] (https://claude.ai/project/${conversationData.project.uuid})\n`;
}
markdown += `*Created: ${conversationData.created_at}*\n`;
markdown += `*Updated: ${conversationData.updated_at}*\n`;
markdown += `*Exported on: ${new Date().toLocaleString()}*\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 => {
const role = message.sender === 'human' ? 'Human' : 'Claude';
markdown += `## ${role}\n`;
markdown += `*UUID:* \`${message.uuid}\`\n`;
markdown += `*Created:* ${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`;
// Process message content
message.content.forEach(content => {
if (content.type === 'text') {
markdown += content.text + '\n\n';
} else if (content.type === 'tool_use' && content.name === 'artifacts') {
const input = content.input;
markdown += `**Artifact Created:** ${input.title}\n`;
markdown += `*ID:* \`${input.id}\`\n`;
markdown += `*Command:* \`${input.command}\`\n\n`;
} else if (content.type === 'thinking') {
if (content.thinking) {
markdown += `*[Claude thinking...]*\n\n`;
markdown += `<details>\n<summary>Thinking process</summary>\n\n`;
markdown += content.thinking + '\n\n';
markdown += `</details>\n\n`;
} else {
markdown += `*[Claude thinking...]*\n\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 (attachment.extracted_content) {
markdown += `<details>\n<summary>File content</summary>\n\n`;
markdown += '```\n';
markdown += attachment.extracted_content + '\n';
markdown += '```\n\n';
markdown += `</details>\n\n`;
}
});
}
});
return markdown;
}
/**
* Exports conversation with artifacts (all versions or final versions only)
* @param {boolean} finalVersionsOnly - If true, exports only final artifact versions
*/
async function exportConversation(finalVersionsOnly = false) {
try {
showNotification('Fetching conversation data...', 'info');
const conversationData = await getConversationData();
const timestamp = generateTimestamp();
const conversationId = conversationData.uuid;
const safeTitle = sanitizeFileName(conversationData.name);
// Export main conversation
const conversationMarkdown = generateConversationMarkdown(conversationData);
const conversationFilename = `${timestamp}_${conversationId}__${safeTitle}.md`;
downloadFile(conversationFilename, conversationMarkdown);
// Extract and process artifacts from all branches
const { branchArtifacts, branchInfo } = extractAllArtifacts(conversationData);
if (branchArtifacts.size === 0) {
showNotification('No artifacts found in conversation', 'info');
return;
}
let totalExported = 0;
// Export artifacts for each branch
branchArtifacts.forEach((artifactsMap, branchId) => {
const branchData = branchInfo.find(b => b.branchId === branchId);
const branchLabel = branchData ? `branch${branchData.branchIndex}` : 'unknown';
const isMain = branchData ? branchData.isMainBranch : false;
artifactsMap.forEach((versions, artifactId) => {
const versionsToExport = finalVersionsOnly ?
[versions[versions.length - 1]] : // Only last version
versions; // All versions
versionsToExport.forEach(version => {
const safeArtifactTitle = sanitizeFileName(version.title);
const mainSuffix = isMain ? '_main' : '';
const filename = `${timestamp}_${conversationId}_${artifactId}_${branchLabel}${mainSuffix}_v${version.version}_${safeArtifactTitle}.md`;
let content = `# ${version.title}\n\n`;
content += `*Artifact ID:* \`${artifactId}\`\n`;
content += `*Branch:* ${branchLabel}${isMain ? ' (main)' : ''} (${branchId.substring(0, 8)}...)\n`;
content += `*Version:* ${version.version}\n`;
content += `*Command:* \`${version.command}\`\n`;
content += `*UUID:* \`${version.uuid}\`\n`;
content += `*Created:* ${version.timestamp}\n`;
// Add change information
if (version.changeDescription) {
content += `*Change:* ${version.changeDescription}\n`;
}
// Add update info if available
if (version.updateInfo) {
content += `*Update Info:* ${version.updateInfo}\n`;
}
// Add inherited content info for non-main branches
if (!isMain && version.inheritedContent && version.command === 'update') {
content += `*Started from inherited content:* ${version.inheritedContent.length} chars\n`;
}
content += '\n---\n\n';
content += version.fullContent;
downloadFile(filename, content);
totalExported++;
});
});
});
const mode = finalVersionsOnly ? 'final versions' : 'all versions';
const branchCount = branchArtifacts.size;
const mainBranches = branchInfo.filter(b => b.isMainBranch).length;
const splitBranches = branchCount - mainBranches;
showNotification(`Export completed! Downloaded conversation + ${totalExported} artifacts from ${branchCount} branches (${mainBranches} main, ${splitBranches} split) (${mode})`, 'success');
} catch (error) {
console.error('Export failed:', error);
showNotification(`Export failed: ${error.message}`, 'error');
}
}
/**
* Exports only the conversation without any artifacts
*/
async function exportConversationOnly() {
try {
showNotification('Fetching conversation data...', 'info');
const conversationData = await getConversationData();
const timestamp = generateTimestamp();
const conversationId = conversationData.uuid;
const safeTitle = sanitizeFileName(conversationData.name);
// Export only main conversation
const conversationMarkdown = generateConversationMarkdown(conversationData);
const conversationFilename = `${timestamp}_${conversationId}__${safeTitle}.md`;
downloadFile(conversationFilename, conversationMarkdown);
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('Export Conversation Only', exportConversationOnly);
GM_registerMenuCommand('Export Conversation + Final Artifact Versions', () => exportConversation(true));
GM_registerMenuCommand('Export Conversation + All Artifact Versions', () => exportConversation(false));
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();