claude-exporter/firefox/browse.js
tux tucker aec2906fb2 Import Backup: ask mode before opening file picker (v1.10.17)
Old flow: click Import Backup -> OS file picker opens -> select file
-> modal asks merge/replace. Awkward because the user was already
deep in a filesystem dialog before being asked the meaningful
question.

New flow: click Import Backup -> modal asks merge/replace + "Choose
File..." -> file picker opens -> import runs with the chosen mode.

Refactor:
- showImportBackupModal(backup, onConfirm) -> showImportModeModal(onConfirm).
  No longer takes a file/backup arg; no file-contents preview (snapshot
  /export counts, creation date) since the file isn't selected yet.
  Modal copy now reads "Choose how the imported data should be
  combined with your current data, then pick a backup file."
- importBackup(file, onComplete) -> importBackup(file, mode, onComplete).
  No internal modal call; just validates the file and applies the
  pre-chosen mode.
- options.js and browse.js each gained a pendingImportMode state
  variable that bridges the async modal-close -> file-picker-change
  boundary. Consumed and reset on file-picker change to prevent stale
  reuse.

Both browsers identical. Tests still pass (54/54).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:30:00 -04:00

1283 lines
46 KiB
JavaScript

// Helper function to format datetime in local time for filenames
function getLocalDateTimeString() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}${month}${day}-${hours}${minutes}${seconds}`;
}
// Helper function to escape HTML to prevent XSS
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Theme management
function initTheme() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
}
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
// State management
let allConversations = [];
let filteredConversations = [];
let allProjects = [];
let projectsMap = {}; // Map project UUID to project name
let orgId = null;
let currentSort = 'updated_desc';
let sortStack = []; // Track multi-level sorting: [{field: 'name', direction: 'asc'}, ...]
let selectedConversations = new Set(); // Track selected conversation IDs
let lastCheckedIndex = null; // Track last checked checkbox for shift+click range selection
let exportTimestamps = {}; // Map conversation UUID to last export timestamp
let modelSnapshots = {}; // Map conversation UUID to { firstSeen, current, ... } captured by content.js
let statusFilter = 'all'; // 'all', 'new', 'exported', or 'projects' (search scope = project names)
let dateFormat = 'mdy'; // 'mdy' or 'dmy'
let timeFormat = '12h'; // '12h' or '24h'
let modelDisplay = 'original'; // 'original' (first-seen) or 'current'
// Export timestamp storage helpers
async function loadExportTimestamps() {
return new Promise((resolve) => {
chrome.storage.local.get(['exportTimestamps'], (result) => {
exportTimestamps = result.exportTimestamps || {};
resolve();
});
});
}
// Model snapshots are written by content.js whenever the conversation list is
// fetched (see recordModelSnapshots). They preserve the original model even
// after a chat is bounced to a newer one on model retirement.
async function loadModelSnapshots() {
return new Promise((resolve) => {
chrome.storage.local.get(['modelSnapshots'], (result) => {
modelSnapshots = result.modelSnapshots || {};
resolve();
});
});
}
// Resolve which model to show for a conversation. Honors the modelDisplay
// preference ('original' default, or 'current'). When the chat has been
// bounced (current differs from first-seen), `bounced` is true and the
// `*` marker shows the "other" model in its tooltip.
function getDisplayModel(conv) {
const snap = modelSnapshots[conv.uuid];
if (snap && snap.firstSeen) {
const original = snap.firstSeen;
const current = snap.current || snap.firstSeen;
const bounced = !!snap.current && snap.current !== snap.firstSeen;
const useCurrent = modelDisplay === 'current';
return {
model: useCurrent ? current : original,
other: useCurrent ? original : current,
otherLabel: useCurrent ? 'Originally' : 'Currently',
bounced
};
}
return { model: conv.model, other: conv.model, otherLabel: '', bounced: false };
}
async function saveExportTimestamp(conversationId) {
exportTimestamps[conversationId] = new Date().toISOString();
return new Promise((resolve) => {
chrome.storage.local.set({ exportTimestamps }, resolve);
});
}
async function saveExportTimestamps(conversationIds) {
const now = new Date().toISOString();
for (const id of conversationIds) {
exportTimestamps[id] = now;
}
return new Promise((resolve) => {
chrome.storage.local.set({ exportTimestamps }, resolve);
});
}
async function loadDateTimePrefs() {
return new Promise((resolve) => {
chrome.storage.local.get(['dateFormat', 'timeFormat'], (result) => {
dateFormat = result.dateFormat || 'mdy';
timeFormat = result.timeFormat || '12h';
resolve();
});
});
}
async function loadModelDisplayPref() {
return new Promise((resolve) => {
chrome.storage.local.get(['modelDisplay'], (result) => {
modelDisplay = result.modelDisplay === 'current' ? 'current' : 'original';
resolve();
});
});
}
function formatDate(dt) {
const m = dt.getMonth() + 1;
const d = dt.getDate();
const y = dt.getFullYear();
return dateFormat === 'dmy' ? `${d}/${m}/${y}` : `${m}/${d}/${y}`;
}
function formatTime(dt) {
if (timeFormat === '24h') {
return dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
}
return dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true });
}
function isNewOrUpdated(conv) {
const lastExport = exportTimestamps[conv.uuid];
if (!lastExport) return true; // Never exported
return new Date(conv.updated_at) > new Date(lastExport);
}
// When user navigates back to this page from the options page (bfcache hit),
// reload so changed preferences (model display, date/time format, etc.) take
// effect without a manual refresh.
window.addEventListener('pageshow', (event) => {
if (event.persisted) window.location.reload();
});
// Initialize on page load
document.addEventListener('DOMContentLoaded', async () => {
initTheme();
// Wire up UI listeners (settings dropdown, filters, search, etc.) immediately
// so the chrome stays interactive while orgId / conversations are still loading.
setupEventListeners();
const loadingStart = Date.now();
await loadOrgId();
await loadExportTimestamps();
await loadModelSnapshots();
await loadDateTimePrefs();
await loadModelDisplayPref();
const elapsed = Date.now() - loadingStart;
if (elapsed < 1000) await new Promise(r => setTimeout(r, 1000 - elapsed));
const loadingText = document.getElementById('loadingText');
if (loadingText) loadingText.textContent = 'Loading conversations...';
await loadConversations();
});
// Load organization ID — auto-detect first, fall back to stored
async function loadOrgId() {
// Try auto-detect via content script on a claude.ai tab
try {
const response = await sendMessageToClaudeTab('detectOrgId', {});
if (response && response.success && response.orgId) {
orgId = response.orgId;
// Save for future use / fallback
chrome.storage.sync.set({ organizationId: orgId });
console.log('Auto-detected organization ID:', orgId);
return;
}
} catch (e) {
console.log('Auto-detect org ID failed, falling back to stored:', e);
}
// Fall back to stored org ID
return new Promise((resolve) => {
chrome.storage.sync.get(['organizationId'], (result) => {
orgId = result.organizationId;
if (!orgId) {
showError('Organization ID not configured. Please open a claude.ai tab and reload this page, or configure it manually in the extension options.');
}
resolve();
});
});
}
// Helper function to find a claude.ai tab and send a message
function sendMessageToClaudeTab(action, data) {
return new Promise((resolve, reject) => {
// Find a claude.ai tab using callback
chrome.tabs.query({ url: 'https://claude.ai/*' }, (tabs) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (!tabs || tabs.length === 0) {
reject(new Error('Please open a claude.ai tab first to use this feature'));
return;
}
// Send message to the first claude.ai tab
chrome.tabs.sendMessage(tabs[0].id, { action, ...data }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else if (response && response.success) {
resolve(response);
} else {
reject(new Error(response?.error || 'Request failed'));
}
});
});
});
}
// Load projects from API via content script
async function loadProjects() {
if (!orgId) return [];
try {
const response = await sendMessageToClaudeTab('loadProjects', { orgId });
const projects = response.projects;
console.log(`Loaded ${projects.length} projects:`, projects);
// Store projects globally and build map
allProjects = projects;
projectsMap = {};
projects.forEach(project => {
const projectId = project.uuid || project.id;
const projectName = project.name || project.title || 'Untitled Project';
projectsMap[projectId] = projectName;
});
return projects;
} catch (error) {
console.warn('Error loading projects:', error);
return [];
}
}
// Load all conversations
async function loadConversations() {
if (!orgId) return;
try {
// Load projects first
const projects = await loadProjects();
const response = await sendMessageToClaudeTab('loadConversations', { orgId });
allConversations = response.conversations;
console.log(`Loaded ${allConversations.length} conversations`);
// Log first conversation to see structure
if (allConversations.length > 0) {
console.log('Sample conversation structure:', allConversations[0]);
}
// Infer models for conversations with null model
allConversations = allConversations.map(conv => ({
...conv,
model: inferModel(conv)
}));
// Apply initial sort and display
applyFiltersAndSort();
} catch (error) {
console.error('Error loading conversations:', error);
showError(`Failed to load conversations: ${error.message}`);
}
}
// Get project name for a conversation
function getProjectName(conversation) {
const projectId = conversation.project_uuid || conversation.project_id || conversation.projectUuid;
if (!projectId) return '-';
return projectsMap[projectId] || '-';
}
// Apply filters and sorting
function applyFiltersAndSort() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
// Filter conversations
filteredConversations = allConversations.filter(conv => {
// 'projects' mode: search scope becomes the project name, status filters do not apply
if (statusFilter === 'projects') {
if (!searchTerm) return true;
const projectName = getProjectName(conv);
return projectName && projectName !== '-' && projectName.toLowerCase().includes(searchTerm);
}
const matchesSearch = !searchTerm ||
conv.name.toLowerCase().includes(searchTerm) ||
(conv.summary && conv.summary.toLowerCase().includes(searchTerm));
// Status filter
let matchesStatus = true;
if (statusFilter === 'new') {
matchesStatus = isNewOrUpdated(conv);
} else if (statusFilter === 'exported') {
matchesStatus = !isNewOrUpdated(conv);
}
return matchesSearch && matchesStatus;
});
// Sort conversations
sortConversations();
// Reset last checked index when list changes
lastCheckedIndex = null;
// Update display
displayConversations();
updateStats();
}
// Sort conversations based on current sort setting
function sortConversations() {
// If sortStack is empty, use currentSort from dropdown
if (sortStack.length === 0) {
const [field, direction] = currentSort.split('_');
sortStack = [{field, direction}];
}
filteredConversations.sort((a, b) => {
// Try each sort criterion in order until we find a difference
for (const {field, direction} of sortStack) {
let aVal, bVal;
switch (field) {
case 'name':
aVal = a.name.toLowerCase();
bVal = b.name.toLowerCase();
break;
case 'project':
aVal = getProjectName(a).toLowerCase();
bVal = getProjectName(b).toLowerCase();
break;
case 'created':
aVal = new Date(a.created_at);
bVal = new Date(b.created_at);
break;
case 'updated':
aVal = new Date(a.updated_at);
bVal = new Date(b.updated_at);
break;
case 'model':
aVal = formatModelName(getDisplayModel(a).model || '').toLowerCase();
bVal = formatModelName(getDisplayModel(b).model || '').toLowerCase();
break;
default:
continue;
}
let comparison = 0;
if (aVal > bVal) comparison = 1;
else if (aVal < bVal) comparison = -1;
if (comparison !== 0) {
return direction === 'asc' ? comparison : -comparison;
}
}
return 0;
});
}
// Handle column header click for sorting
function handleColumnSort(field) {
const existingIndex = sortStack.findIndex(s => s.field === field);
if (existingIndex === 0) {
// Clicking primary sort: toggle direction
sortStack[0].direction = sortStack[0].direction === 'asc' ? 'desc' : 'asc';
} else if (existingIndex > 0) {
// Clicking a secondary sort: move it to primary position
const [sortCriterion] = sortStack.splice(existingIndex, 1);
sortStack.unshift(sortCriterion);
} else {
// New sort: add to front with ascending direction
sortStack.unshift({field, direction: 'asc'});
}
applyFiltersAndSort();
}
// Get sort indicator for a column
function getSortIndicator(field) {
const sortIndex = sortStack.findIndex(s => s.field === field);
// Only show indicator for the primary (most recent) sort
if (sortIndex !== 0) return '';
const {direction} = sortStack[sortIndex];
const primaryArrow = direction === 'asc' ? '↑' : '↓';
const secondaryArrow = direction === 'asc' ? '↓' : '↑';
return ` <span class="sort-indicator">${primaryArrow}<sub>${secondaryArrow}</sub></span>`;
}
// Display conversations in table
function displayConversations() {
const tableContent = document.getElementById('tableContent');
if (filteredConversations.length === 0) {
tableContent.innerHTML = '<div class="no-results">No conversations found</div>';
return;
}
let html = `
<table>
<thead>
<tr>
<th class="sortable" data-sort="name">Name${getSortIndicator('name')}</th>
<th class="sortable" data-sort="project">Project${getSortIndicator('project')}</th>
<th class="sortable" data-sort="updated">Updated${getSortIndicator('updated')}</th>
<th class="sortable" data-sort="created">Created${getSortIndicator('created')}</th>
<th class="sortable" data-sort="model">Model${getSortIndicator('model')}</th>
<th>Actions</th>
<th class="checkbox-col">
<input type="checkbox" id="selectAll" class="select-all-checkbox" ${selectedConversations.size > 0 ? 'checked' : ''}>
</th>
</tr>
</thead>
<tbody>
`;
filteredConversations.forEach((conv, index) => {
const updatedDt = new Date(conv.updated_at);
const createdDt = new Date(conv.created_at);
const updatedDate = formatDate(updatedDt);
const updatedTime = formatTime(updatedDt);
const createdDate = formatDate(createdDt);
const createdTime = formatTime(createdDt);
const modelInfo = getDisplayModel(conv);
const modelBadgeClass = getModelBadgeClass(modelInfo.model);
const projectName = getProjectName(conv);
const newUpdated = isNewOrUpdated(conv);
html += `
<tr data-id="${escapeHtml(conv.uuid)}">
<td>
<div class="conversation-name">
${newUpdated ? '<span class="new-dot" title="New or updated since last export"></span>' : ''}
<a href="https://claude.ai/chat/${escapeHtml(conv.uuid)}" target="_blank" title="${escapeHtml(conv.name)}">
${escapeHtml(conv.name)}
</a>
</div>
</td>
<td>${escapeHtml(projectName)}</td>
<td class="date">${escapeHtml(updatedDate)}<br><span class="time">${escapeHtml(updatedTime)}</span></td>
<td class="date">${escapeHtml(createdDate)}<br><span class="time">${escapeHtml(createdTime)}</span></td>
<td>
${modelInfo.bounced
? `<span class="model-cell" title="${modelInfo.otherLabel} ${escapeHtml(formatModelName(modelInfo.other))}"><span class="model-badge ${modelBadgeClass}">${escapeHtml(formatModelName(modelInfo.model))}</span><span class="model-bounced ${modelBadgeClass}">*</span></span>`
: `<span class="model-badge ${modelBadgeClass}">${escapeHtml(formatModelName(modelInfo.model))}</span>`
}
</td>
<td>
<div class="actions">
<button class="btn-small btn-export" data-id="${escapeHtml(conv.uuid)}" data-name="${escapeHtml(conv.name)}">
Export
</button>
</div>
</td>
<td class="checkbox-col">
<input type="checkbox" class="conversation-checkbox" data-id="${escapeHtml(conv.uuid)}" data-index="${index}" ${selectedConversations.has(conv.uuid) ? 'checked' : ''}>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
// Security: All user-provided data in html has been sanitized with escapeHtml()
// before concatenation. The HTML structure itself is static/trusted template code.
tableContent.innerHTML = html;
// Add export button listeners
document.querySelectorAll('.btn-export').forEach(btn => {
btn.addEventListener('click', (e) => {
exportConversation(e.target.dataset.id, e.target.dataset.name);
});
});
// Add checkbox listeners (use 'click' instead of 'change' to capture shift key)
document.querySelectorAll('.conversation-checkbox').forEach(checkbox => {
checkbox.addEventListener('click', handleCheckboxChange);
});
// Add select all checkbox listener
const selectAllCheckbox = document.getElementById('selectAll');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('click', handleSelectAll);
}
// Add sortable header click listeners
document.querySelectorAll('.sortable').forEach(header => {
header.addEventListener('click', () => {
handleColumnSort(header.dataset.sort);
});
});
// Update export button text
updateExportButtonText();
// Enable export all button
document.getElementById('exportAllBtn').disabled = false;
}
// Handle individual checkbox change
function handleCheckboxChange(e) {
const checkbox = e.target;
const conversationId = checkbox.dataset.id;
const currentIndex = parseInt(checkbox.dataset.index);
// Handle shift+click for range selection
if (e.shiftKey && lastCheckedIndex !== null) {
const start = Math.min(lastCheckedIndex, currentIndex);
const end = Math.max(lastCheckedIndex, currentIndex);
// Get all checkboxes and select/deselect the range
const checkboxes = document.querySelectorAll('.conversation-checkbox');
const isChecking = checkbox.checked;
for (let i = start; i <= end; i++) {
const cb = checkboxes[i];
if (cb) {
cb.checked = isChecking;
const id = cb.dataset.id;
if (isChecking) {
selectedConversations.add(id);
} else {
selectedConversations.delete(id);
}
}
}
} else {
// Normal single checkbox toggle
if (checkbox.checked) {
selectedConversations.add(conversationId);
} else {
selectedConversations.delete(conversationId);
}
}
// Update last checked index
lastCheckedIndex = currentIndex;
updateExportButtonText();
updateSelectAllCheckbox();
}
// Handle select all checkbox
function handleSelectAll(e) {
const checkboxes = document.querySelectorAll('.conversation-checkbox');
if (e.target.checked) {
// Select all visible conversations
checkboxes.forEach(checkbox => {
checkbox.checked = true;
selectedConversations.add(checkbox.dataset.id);
});
} else {
// Deselect all
checkboxes.forEach(checkbox => {
checkbox.checked = false;
});
selectedConversations.clear();
}
// Reset last checked index when using select all
lastCheckedIndex = null;
updateExportButtonText();
}
// Update select all checkbox state
function updateSelectAllCheckbox() {
const selectAllCheckbox = document.getElementById('selectAll');
if (!selectAllCheckbox) return;
// Show header checkbox as checked when any conversations are selected
selectAllCheckbox.checked = selectedConversations.size > 0;
}
// Update export button text based on selection
function updateExportButtonText() {
const exportBtn = document.getElementById('exportAllBtn');
if (!exportBtn) return;
if (selectedConversations.size > 0) {
exportBtn.textContent = `Export Selected (${selectedConversations.size})`;
} else {
exportBtn.textContent = 'Export All';
}
}
// Update statistics
function updateStats() {
const stats = document.getElementById('stats');
const newCount = allConversations.filter(c => isNewOrUpdated(c)).length;
stats.textContent = `Showing ${filteredConversations.length} of ${allConversations.length} conversations (${newCount} new/updated)`;
}
// Auto-select new/updated conversations
function autoSelectNewUpdated() {
selectedConversations.clear();
filteredConversations.forEach(conv => {
if (isNewOrUpdated(conv)) {
selectedConversations.add(conv.uuid);
}
});
displayConversations();
updateExportButtonText();
}
// Export single conversation
async function exportConversation(conversationId, conversationName) {
const format = document.getElementById('exportFormat').value;
const includeChats = document.getElementById('includeChats').checked;
const includeThinking = document.getElementById('includeThinking').checked;
const includeMetadata = document.getElementById('includeMetadata').checked;
const includeArtifacts = document.getElementById('includeArtifacts').checked;
const extractArtifacts = document.getElementById('extractArtifacts').checked;
const artifactFormat = document.getElementById('artifactFormat').value;
const flattenArtifacts = document.getElementById('flattenArtifacts').checked;
// Tracked at function scope so the unified post-save toast can mention it
let artifactCount = 0;
try {
showToast(`Exporting ${conversationName}...`);
const response = await fetch(
`https://claude.ai/api/organizations/${orgId}/chat_conversations/${conversationId}?tree=True&rendering_mode=messages&render_all_tools=true`,
{
credentials: 'include',
headers: {
'Accept': 'application/json',
}
}
);
if (!response.ok) {
throw new Error(`Failed to fetch conversation: ${response.status}`);
}
const data = await response.json();
// Infer model if null
data.model = inferModel(data);
// Check if we need to extract artifacts to separate files
if (extractArtifacts || flattenArtifacts) {
const artifactFiles = extractArtifactFiles(data, artifactFormat);
if (artifactFiles.length > 0) {
artifactCount = artifactFiles.length;
// Create a ZIP with artifacts (and optionally conversation)
const zip = new JSZip();
// Add conversation file only if includeChats is true
if (includeChats !== false) {
let conversationContent, conversationFilename;
switch (format) {
case 'markdown':
conversationContent = convertToMarkdown(data, includeMetadata, conversationId, includeArtifacts, includeThinking);
conversationFilename = `${conversationName || conversationId}.md`;
break;
case 'text':
conversationContent = convertToText(data, includeMetadata, includeArtifacts, includeThinking);
conversationFilename = `${conversationName || conversationId}.txt`;
break;
default:
conversationContent = JSON.stringify(data, null, 2);
conversationFilename = `${conversationName || conversationId}.json`;
}
// Flat export: add to Chats folder
if (flattenArtifacts && !extractArtifacts) {
const chatsFolder = zip.folder('Chats');
chatsFolder.file(conversationFilename, conversationContent);
} else {
// Nested or no artifact extraction: add to root
zip.file(conversationFilename, conversationContent);
}
}
// Add artifact files
// Nested: create artifacts subfolder
if (extractArtifacts) {
const artifactsFolder = includeChats !== false ? zip.folder('artifacts') : zip;
for (const artifact of artifactFiles) {
artifactsFolder.file(artifact.filename, artifact.content);
}
}
// Flat: add artifacts with conversation name prefix to Artifacts folder
if (flattenArtifacts && !extractArtifacts) {
const artifactsFolder = zip.folder('Artifacts');
for (const artifact of artifactFiles) {
const filename = `${conversationName}_${artifact.filename}`;
artifactsFolder.file(filename, artifact.content);
}
}
// Generate and download ZIP
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${conversationName || conversationId}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Toast handled below after timestamp save
} else {
// No artifacts found, export normally
let content, filename, type;
switch (format) {
case 'markdown':
content = convertToMarkdown(data, includeMetadata, conversationId, includeArtifacts, includeThinking);
filename = `${conversationName || conversationId}.md`;
type = 'text/markdown';
break;
case 'text':
content = convertToText(data, includeMetadata, includeArtifacts, includeThinking);
filename = `${conversationName || conversationId}.txt`;
type = 'text/plain';
break;
default:
content = JSON.stringify(data, null, 2);
filename = `${conversationName || conversationId}.json`;
type = 'application/json';
}
downloadFile(content, filename, type);
}
} else {
// Normal export without artifact extraction
if (includeChats === false) {
// If chats are disabled and we're not extracting artifacts, there's nothing to export
showToast('Nothing to export. Enable "Chats" or "Artifacts nested".', true);
return;
} else {
let content, filename, type;
switch (format) {
case 'markdown':
content = convertToMarkdown(data, includeMetadata, conversationId, includeArtifacts, includeThinking);
filename = `${conversationName || conversationId}.md`;
type = 'text/markdown';
break;
case 'text':
content = convertToText(data, includeMetadata, includeArtifacts, includeThinking);
filename = `${conversationName || conversationId}.txt`;
type = 'text/plain';
break;
default:
content = JSON.stringify(data, null, 2);
filename = `${conversationName || conversationId}.json`;
type = 'application/json';
}
downloadFile(content, filename, type);
}
}
// Record export timestamp and refresh display
await saveExportTimestamp(conversationId);
showToast(artifactCount > 0
? `Exported: ${conversationName} with ${artifactCount} artifact(s)`
: `Exported: ${conversationName}`);
displayConversations();
updateStats();
} catch (error) {
console.error('Export error:', error);
showToast(`Failed to export: ${error.message}`, true);
}
}
// Export all filtered conversations
async function exportAllFiltered() {
const format = document.getElementById('exportFormat').value;
const includeChats = document.getElementById('includeChats').checked;
const includeThinking = document.getElementById('includeThinking').checked;
const includeMetadata = document.getElementById('includeMetadata').checked;
const includeArtifacts = document.getElementById('includeArtifacts').checked;
const extractArtifacts = document.getElementById('extractArtifacts').checked;
const artifactFormat = document.getElementById('artifactFormat').value;
const flattenArtifacts = document.getElementById('flattenArtifacts').checked;
const button = document.getElementById('exportAllBtn');
button.disabled = true;
const originalButtonText = button.textContent;
button.textContent = 'Preparing...';
// Determine which conversations to export
let conversationsToExport;
if (selectedConversations.size > 0) {
// Export ALL selected conversations, even ones currently hidden by the
// filter — the checkbox is the user's explicit choice, the filter is just
// a view. The "Export Selected (N)" button text already reflects the
// full selection count, so users aren't surprised.
conversationsToExport = allConversations.filter(conv => selectedConversations.has(conv.uuid));
} else {
// No explicit selection: export everything currently visible.
conversationsToExport = filteredConversations;
}
// Single conversation: delegate to exportConversation so we skip the ZIP
// when the output is a single file (artifact-extraction paths still ZIP there)
if (conversationsToExport.length === 1) {
const conv = conversationsToExport[0];
const progressModal = document.getElementById('progressModal');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const progressStats = document.getElementById('progressStats');
progressModal.style.display = 'block';
progressText.textContent = `Exporting ${conv.name}...`;
progressBar.style.width = '0%';
progressStats.textContent = '';
try {
await exportConversation(conv.uuid, conv.name);
progressBar.style.width = '100%';
} finally {
progressModal.style.display = 'none';
button.disabled = false;
button.textContent = originalButtonText;
}
return;
}
// Show progress modal
const progressModal = document.getElementById('progressModal');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const progressStats = document.getElementById('progressStats');
progressBar.style.width = '0%';
progressStats.textContent = '';
progressText.textContent = 'Preparing export...';
progressModal.style.display = 'block';
let cancelExport = false;
const cancelButton = document.getElementById('cancelExport');
cancelButton.onclick = () => {
cancelExport = true;
progressModal.style.display = 'none';
showToast('Export cancelled', true);
};
try {
// Create a new ZIP file
const zip = new JSZip();
const total = conversationsToExport.length;
let completed = 0;
let failed = 0;
const failedConversations = [];
progressText.textContent = `Exporting ${total} conversations...`;
// Process conversations in batches to avoid overwhelming the API
const batchSize = 3; // Process 3 at a time
for (let i = 0; i < total; i += batchSize) {
if (cancelExport) break;
const batch = conversationsToExport.slice(i, Math.min(i + batchSize, total));
const promises = batch.map(async (conv) => {
try {
const response = await fetch(
`https://claude.ai/api/organizations/${orgId}/chat_conversations/${conv.uuid}?tree=True&rendering_mode=messages&render_all_tools=true`,
{
credentials: 'include',
headers: {
'Accept': 'application/json',
}
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// Infer model if null
data.model = inferModel(data);
// Extract artifacts first to check if this conversation should be included
const artifactFiles = extractArtifactFiles(data, artifactFormat);
// If chats are disabled and no artifacts, skip this conversation
if (includeChats === false && artifactFiles.length === 0) {
console.log(`Skipping ${conv.name} - no artifacts found (chats disabled)`);
completed++; // Count as completed even though skipped
return; // Skip this conversation in the promise
}
// Generate filename and content based on format
let content, filename;
const safeName = conv.name.replace(/[<>:"/\\|?*]/g, '_'); // Remove invalid filename characters
switch (format) {
case 'markdown':
content = convertToMarkdown(data, includeMetadata, conv.uuid, includeArtifacts, includeThinking);
filename = `${safeName}.md`;
break;
case 'text':
content = convertToText(data, includeMetadata, includeArtifacts, includeThinking);
filename = `${safeName}.txt`;
break;
default: // json
content = JSON.stringify(data, null, 2);
filename = `${safeName}.json`;
}
// Flat export: use Chats and Artifacts top-level folders
if (flattenArtifacts && !extractArtifacts) {
// Add chat file to Chats folder if chats are enabled
if (includeChats !== false) {
const chatsFolder = zip.folder('Chats');
chatsFolder.file(filename, content);
}
// Add artifacts to Artifacts folder with conversation name prefix
if (artifactFiles.length > 0) {
const artifactsFolder = zip.folder('Artifacts');
for (const artifact of artifactFiles) {
const artifactFilename = `${safeName}_${artifact.filename}`;
artifactsFolder.file(artifactFilename, artifact.content);
}
}
}
// Nested export: create per-conversation folders with artifacts subfolder
else if (extractArtifacts) {
const convFolder = zip.folder(safeName);
// Add conversation file only if includeChats is true
if (includeChats !== false) {
convFolder.file(filename, content);
}
// Add artifact files in nested artifacts subfolder
if (artifactFiles.length > 0) {
const artifactsFolder = includeChats !== false ? convFolder.folder('artifacts') : convFolder;
for (const artifact of artifactFiles) {
artifactsFolder.file(artifact.filename, artifact.content);
}
}
} else {
// No artifact extraction - add file to ZIP root only if chats are enabled
if (includeChats !== false) {
zip.file(filename, content);
}
}
completed++;
} catch (error) {
console.error(`Failed to export ${conv.name}:`, error);
failed++;
failedConversations.push(conv.name);
}
});
// Wait for batch to complete
await Promise.all(promises);
// Update progress
const progress = Math.round((completed + failed) / total * 100);
progressBar.style.width = `${progress}%`;
progressStats.textContent = `${completed} succeeded, ${failed} failed out of ${total}`;
// Small delay between batches
if (i + batchSize < total && !cancelExport) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
if (cancelExport) return;
// Generate and download the ZIP file
progressText.textContent = 'Creating ZIP file...';
const blob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: {
level: 6 // Medium compression
}
}, (metadata) => {
// Update progress during ZIP creation
const zipProgress = Math.round(metadata.percent);
progressBar.style.width = `${zipProgress}%`;
});
// Download the ZIP file
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Format: claude-artifacts-20251031-143045.zip or claude-exports-20251031-143045.zip
const datetime = getLocalDateTimeString();
// Use 'claude-artifacts' when ONLY flat artifacts are exported
const prefix = (flattenArtifacts && !extractArtifacts && includeChats === false) ? 'claude-artifacts' : 'claude-exports';
a.download = `${prefix}-${datetime}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
progressModal.style.display = 'none';
// Record export timestamps for successfully exported conversations
const exportedIds = conversationsToExport
.filter(conv => !failedConversations.includes(conv.name))
.map(conv => conv.uuid);
await saveExportTimestamps(exportedIds);
displayConversations();
updateStats();
if (failed > 0) {
showToast(`Exported ${completed} of ${total} conversations (${failed} failed).`);
} else {
showToast(`Successfully exported all ${completed} conversations!`);
}
} catch (error) {
console.error('Export error:', error);
progressModal.style.display = 'none';
showToast(`Export failed: ${error.message}`, true);
} finally {
button.disabled = false;
button.textContent = originalButtonText;
}
}
// Conversion functions are now imported from utils.js
// Functions available: getCurrentBranch, convertToMarkdown, convertToText, downloadFile
// Show error message
function showError(message) {
const tableContent = document.getElementById('tableContent');
const errorDiv = document.createElement('div');
errorDiv.className = 'error';
errorDiv.textContent = message;
tableContent.innerHTML = '';
tableContent.appendChild(errorDiv);
}
// Show toast notification
function showToast(message, isError = false) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.style.background = isError ? '#d32f2f' : '#333';
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
// Setup event listeners
function setupEventListeners() {
// Handle checkbox dependencies
const includeChatsCheckbox = document.getElementById('includeChats');
const includeThinkingCheckbox = document.getElementById('includeThinking');
const includeMetadataCheckbox = document.getElementById('includeMetadata');
const includeArtifactsCheckbox = document.getElementById('includeArtifacts');
function updateCheckboxStates() {
const chatsEnabled = includeChatsCheckbox.checked;
// Disable thinking, metadata and inline artifacts when chats is unchecked
includeThinkingCheckbox.disabled = !chatsEnabled;
includeMetadataCheckbox.disabled = !chatsEnabled;
includeArtifactsCheckbox.disabled = !chatsEnabled;
// Optionally uncheck them when disabled
if (!chatsEnabled) {
includeThinkingCheckbox.checked = false;
includeMetadataCheckbox.checked = false;
includeArtifactsCheckbox.checked = false;
}
}
includeChatsCheckbox.addEventListener('change', updateCheckboxStates);
updateCheckboxStates(); // Initialize on load
// Settings dropdown
const settingsBtn = document.getElementById('settingsBtn');
const settingsDropdown = document.getElementById('settingsDropdown');
settingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
settingsDropdown.classList.toggle('open');
// Update org ID display when opening
if (settingsDropdown.classList.contains('open')) {
const orgDisplay = document.getElementById('orgIdDisplay');
if (orgId) {
orgDisplay.textContent = orgId.substring(0, 8) + '...';
orgDisplay.title = orgId;
} else {
orgDisplay.textContent = 'Not set';
}
// Update theme label
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
document.getElementById('themeLabel').textContent = theme === 'dark' ? 'Dark' : 'Light';
}
});
// Close dropdown when clicking outside
document.addEventListener('click', () => {
settingsDropdown.classList.remove('open');
});
settingsDropdown.addEventListener('click', (e) => {
e.stopPropagation();
});
// Theme toggle
document.getElementById('themeToggle').addEventListener('click', () => {
toggleTheme();
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
document.getElementById('themeLabel').textContent = theme === 'dark' ? 'Dark' : 'Light';
});
// Click org ID row to copy full ID to clipboard
document.getElementById('settingsOrgId').addEventListener('click', async () => {
if (!orgId) {
showToast('No org ID set', true);
return;
}
try {
await navigator.clipboard.writeText(orgId);
showToast('Org ID copied to clipboard');
} catch (e) {
showToast('Failed to copy org ID', true);
}
settingsDropdown.classList.remove('open');
});
// Edit org ID — open options in the same tab so the back button returns here
document.getElementById('editOrgId').addEventListener('click', () => {
window.location.href = chrome.runtime.getURL('options.html');
});
// Advanced Options — open options in the same tab so the back button returns here
document.getElementById('advancedOptions').addEventListener('click', () => {
window.location.href = chrome.runtime.getURL('options.html');
});
// Mark all as exported
document.getElementById('markAllExported').addEventListener('click', async () => {
const ids = allConversations.map(c => c.uuid);
await saveExportTimestamps(ids);
displayConversations();
updateStats();
settingsDropdown.classList.remove('open');
showToast(`Marked ${ids.length} conversations as exported`);
});
// Mark all as new
document.getElementById('markAllNew').addEventListener('click', async () => {
exportTimestamps = {};
await new Promise(resolve => chrome.storage.local.set({ exportTimestamps: {} }, resolve));
selectedConversations.clear();
autoSelectNewUpdated();
updateStats();
settingsDropdown.classList.remove('open');
showToast('All conversations marked as new');
});
// Backup / Restore Database submenu — shared logic lives in utils.js
document.getElementById('backupData').addEventListener('click', () => {
backupExtensionData((success, message) => showToast(message, !success));
settingsDropdown.classList.remove('open');
});
// Import flow: mode-choice modal → file picker → import.
// pendingImportMode bridges the async file-picker boundary.
let pendingImportMode = null;
document.getElementById('restoreData').addEventListener('click', () => {
settingsDropdown.classList.remove('open');
showImportModeModal((mode) => {
if (mode === null) return; // user cancelled
pendingImportMode = mode;
document.getElementById('restoreFileBrowse').click();
});
});
document.getElementById('restoreFileBrowse').addEventListener('change', (event) => {
const file = event.target.files[0];
event.target.value = ''; // allow re-selecting the same file later
const mode = pendingImportMode;
pendingImportMode = null; // consume; never reuse a stale mode
if (!file || !mode) return;
importBackup(file, mode, (success, message) => showToast(message, !success));
});
// Search input
const searchInput = document.getElementById('searchInput');
searchInput.addEventListener('input', (e) => {
const searchBox = document.getElementById('searchBox');
if (e.target.value) {
searchBox.classList.add('has-text');
} else {
searchBox.classList.remove('has-text');
}
applyFiltersAndSort();
});
// Clear search
document.getElementById('clearSearch').addEventListener('click', () => {
document.getElementById('searchInput').value = '';
document.getElementById('searchBox').classList.remove('has-text');
applyFiltersAndSort();
});
// Filter dropdown
const filterBtn = document.getElementById('filterBtn');
const filterDropdown = document.getElementById('filterDropdown');
filterBtn.addEventListener('click', (e) => {
e.stopPropagation();
filterDropdown.classList.toggle('open');
});
document.addEventListener('click', () => {
filterDropdown.classList.remove('open');
});
filterDropdown.addEventListener('click', (e) => {
e.stopPropagation();
});
document.querySelectorAll('.filter-option').forEach(option => {
option.addEventListener('click', () => {
statusFilter = option.dataset.value;
// Update selected state
document.querySelectorAll('.filter-option').forEach(o => o.classList.remove('selected'));
option.classList.add('selected');
// Search bar placeholder reflects the active scope
document.getElementById('searchInput').placeholder = statusFilter === 'projects'
? 'Search projects by name...'
: 'Search conversations by name...';
// Update button state
filterBtn.classList.toggle('active', statusFilter !== 'all');
filterDropdown.classList.remove('open');
applyFiltersAndSort();
});
});
// Set initial selected state
document.querySelector('.filter-option[data-value="all"]').classList.add('selected');
// Export all button
document.getElementById('exportAllBtn').addEventListener('click', exportAllFiltered);
}