// ==UserScript==
// @name Claude Exporter 0.9+
// @namespace http://tampermonkey.net/
// @version 0.9+
// @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();
}
// =============================================
// 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 all artifacts from conversation data and organizes them by ID
* @param {Object} conversationData - Complete conversation data
* @returns {Map} Map of artifact ID to array of artifact versions
*/
function extractArtifacts(conversationData) {
const artifacts = new Map(); // Map<artifactId, Array<{version, command, uuid, content, title, old_str, new_str}>>
conversationData.chat_messages.forEach(message => {
message.content.forEach(content => {
if (content.type === 'tool_use' && content.name === 'artifacts' && content.input) {
const input = content.input;
const artifactId = input.id;
if (!artifacts.has(artifactId)) {
artifacts.set(artifactId, []);
}
const versions = artifacts.get(artifactId);
versions.push({
version: versions.length + 1,
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
});
}
});
});
return artifacts;
}
/**
* 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 {string} Updated content
*/
function applyUpdate(previousContent, oldStr, newStr) {
if (!previousContent || !oldStr) {
console.warn('Cannot apply update: missing previousContent or oldStr');
return previousContent || '';
}
// Apply the string replacement
const updatedContent = previousContent.replace(oldStr, newStr);
if (updatedContent === previousContent) {
console.warn('Update did not change content - old string not found');
console.warn('Looking for:', oldStr.substring(0, 100) + '...');
console.warn('In content length:', previousContent.length);
// Try to find similar strings for debugging
const lines = previousContent.split('\n');
const oldLines = oldStr.split('\n');
if (oldLines.length > 0) {
const firstOldLine = oldLines[0].trim();
const foundLine = lines.find(line => line.includes(firstOldLine));
if (foundLine) {
console.warn('Found similar line:', foundLine);
}
}
}
return updatedContent;
}
/**
* Builds complete artifact versions by applying updates sequentially
* @param {Map} artifacts - Raw artifacts from extractArtifacts()
* @returns {Map} Map of artifact ID to processed versions with full content
*/
function buildArtifactVersions(artifacts) {
const processedArtifacts = new Map();
artifacts.forEach((versions, artifactId) => {
const processedVersions = [];
let currentContent = '';
versions.forEach((version, index) => {
let changeDescription = '';
switch (version.command) {
case 'create':
currentContent = version.content;
changeDescription = 'Created';
break;
case 'rewrite':
currentContent = version.content;
changeDescription = 'Rewritten';
break;
case 'update':
const oldContent = currentContent;
currentContent = applyUpdate(currentContent, version.old_str, version.new_str);
// Create more informative change description
const oldPreview = version.old_str ? version.old_str.substring(0, 100) + '...' : '';
const newPreview = version.new_str ? version.new_str.substring(0, 100) + '...' : '';
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)`;
}
break;
default:
console.warn(`Unknown command: ${version.command}`);
break;
}
processedVersions.push({
...version,
fullContent: currentContent,
changeDescription: changeDescription
});
});
processedArtifacts.set(artifactId, processedVersions);
});
return processedArtifacts;
}
// =============================================
// 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 += `*Сreated: ${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
const rawArtifacts = extractArtifacts(conversationData);
const processedArtifacts = buildArtifactVersions(rawArtifacts);
if (processedArtifacts.size === 0) {
showNotification('No artifacts found in conversation', 'info');
return;
}
let totalExported = 0;
// Export artifacts
processedArtifacts.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 filename = `${timestamp}_${conversationId}_${artifactId}_v${version.version}_${safeArtifactTitle}.md`;
let content = `# ${version.title}\n\n`;
content += `*Artifact ID:* \`${artifactId}\`\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`;
}
content += '\n---\n\n';
content += version.fullContent;
downloadFile(filename, content);
totalExported++;
});
});
const mode = finalVersionsOnly ? 'final versions' : 'all versions';
showNotification(`Export completed! Downloaded conversation + ${totalExported} artifacts (${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 + All Artifact Versions', () => exportConversation(false));
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();