// ==UserScript== // @name NinjaCat Chat Export // @namespace http://tampermonkey.net/ // @version 2.6.0 // @description Export NinjaCat agent chats to PDF (print) or Markdown, with expand/collapse controls // @author NinjaCat Tweaks // @match https://app.ninjacat.io/agency/data/agents/*/chat/* // @match https://app.mymarketingreports.com/agency/data/agents/*/chat/* // @grant none // @run-at document-end // @homepage https://github.com/jms830/ninjacat-tweaks // @updateURL https://raw.githubusercontent.com/jms830/ninjacat-tweaks/main/userscripts/ninjacat-chat-export.meta.js // @downloadURL https://raw.githubusercontent.com/jms830/ninjacat-tweaks/main/userscripts/ninjacat-chat-export.user.js // ==/UserScript== (function() { 'use strict'; console.log('[NinjaCat Chat Export] Script loaded v2.6.0'); let exportButtonAdded = false; let printEnhancementsAdded = false; // ---- Print Styles ---- // Goal: Preserve original layout as much as possible, only hide sidebars and add header const printStyles = ` @media print { /* ============================================ HIDE SIDEBARS AND NON-CONTENT ELEMENTS ============================================ */ /* Hide left sidebar */ .njc-main-menu { display: none !important; } /* Hide right sidebar (agent info panel) */ .flex.flex-col.min-w-\\[320px\\].w-\\[320px\\] { display: none !important; } /* Hide the back button / header navigation */ .flex.text-blue-100.items-center.py-\\[15px\\] { display: none !important; } /* Hide the message input area at bottom */ .flex.flex-col.relative.max-w-\\[840px\\] { display: none !important; } /* Hide our export controls */ #ninjacat-export-controls { display: none !important; } /* Hide hover buttons (edit, copy) */ .flex.justify-end .opacity-0 { display: none !important; } /* Hide download/expand buttons on images */ .absolute.right-4.bottom-6 { display: none !important; } /* ============================================ LAYOUT: Expand chat to full width ============================================ */ .flex.h-screen.ml-auto.w-\\[95\\%\\] { width: 100% !important; margin-left: 0 !important; height: auto !important; max-height: none !important; } .max-h-screen.flex.flex-col.flex-grow { padding: 0 20px !important; max-width: 100% !important; max-height: none !important; height: auto !important; } /* Remove scroll constraints to show full chat */ .overflow-y-auto, .overflow-auto, .conversationMessagesContainer { overflow: visible !important; max-height: none !important; height: auto !important; } .conversationMessagesContainer { max-width: 100% !important; padding: 0 !important; } .max-w-\\[840px\\] { max-width: 100% !important; } .h-screen { height: auto !important; max-height: none !important; } .flex-grow { flex-grow: 0 !important; } #assistants-ui, #assistants-ui > div, #assistants-ui .flex { height: auto !important; max-height: none !important; overflow: visible !important; } .njc-body { padding: 0 !important; margin: 0 !important; } .no-print-padding { padding: 0 !important; } /* ============================================ PAGE SETTINGS ============================================ */ @page { margin: 0.5in; size: auto; } /* ============================================ IMAGES ============================================ */ img { max-width: 100% !important; page-break-inside: avoid; } .styled-chat-message { page-break-inside: avoid; } /* ============================================ PRINT HEADER ============================================ */ #ninjacat-print-header { display: block !important; padding: 20px 0 !important; margin: 0 0 20px 0 !important; border-bottom: 2px solid #3B82F6 !important; } /* ============================================ USER MESSAGE LABELS (print only) ============================================ */ .ninjacat-user-label { display: block !important; margin-top: 24px !important; } .ninjacat-agent-label { display: block !important; margin-top: 24px !important; } /* ============================================ LINKS - Make them blue ============================================ */ a, a:visited { color: #2563EB !important; text-decoration: underline !important; } /* ============================================ TASK COMPLETED SECTION - Keep original style but ensure it's not inside user message box ============================================ */ /* Hide the collapse arrow icon */ [data-is-collapsed] { display: none !important; } /* Ensure tasks completed stays visible and separate */ .cursor-pointer { margin-top: 8px !important; } } /* ============================================ SCREEN STYLES - Hide print-only elements ============================================ */ #ninjacat-print-header { display: none; } .ninjacat-user-label, .ninjacat-agent-label { display: none; } `; // ---- Initialize ---- function init() { injectPrintStyles(); setupKeyboardShortcuts(); const checkInterval = setInterval(() => { const chatContainer = document.querySelector('#assistants-ui'); if (chatContainer && !exportButtonAdded) { addExportControls(); exportButtonAdded = true; clearInterval(checkInterval); } }, 1000); setTimeout(() => clearInterval(checkInterval), 30000); } function injectPrintStyles() { const styleEl = document.createElement('style'); styleEl.id = 'ninjacat-print-styles'; styleEl.textContent = printStyles; document.head.appendChild(styleEl); console.log('[NinjaCat Chat Export] Print styles injected'); } // ---- Add Print Enhancements (labels, header) ---- function addPrintEnhancements() { if (printEnhancementsAdded) { updatePrintHeader(); return; } const messagesContainer = document.querySelector('.conversationMessagesContainer'); if (!messagesContainer) { console.log('[NinjaCat Chat Export] No messages container found'); return; } addPrintHeader(messagesContainer); addMessageLabels(messagesContainer); printEnhancementsAdded = true; console.log('[NinjaCat Chat Export] Print enhancements added'); } function addPrintHeader(container) { const existing = document.getElementById('ninjacat-print-header'); if (existing) existing.remove(); const agentName = getAgentName(); const agentDescription = getAgentDescription(); const exportDate = new Date().toLocaleString(); const header = document.createElement('div'); header.id = 'ninjacat-print-header'; header.innerHTML = `
${escapeHTML(agentName)}
${agentDescription ? `
${escapeHTML(agentDescription)}
` : ''}
Exported: ${exportDate}
`; container.insertBefore(header, container.firstChild); } function updatePrintHeader() { const header = document.getElementById('ninjacat-print-header'); if (header) { const exportDate = new Date().toLocaleString(); const dateEl = header.querySelector('div:last-child'); if (dateEl) { dateEl.textContent = `Exported: ${exportDate}`; } } } function addMessageLabels(container) { const allMessageElements = container.querySelectorAll('[index]'); allMessageElements.forEach((el) => { if (el.querySelector('.ninjacat-user-label, .ninjacat-agent-label')) return; const isUserMessage = el.classList.contains('self-end') || el.closest('.self-end'); if (isUserMessage) { // Add label BEFORE the message element (not inside) const existingLabel = el.parentElement?.querySelector('.ninjacat-user-label'); if (!existingLabel) { const label = document.createElement('div'); label.className = 'ninjacat-user-label'; label.style.cssText = ` font-size: 11px; font-weight: 700; color: #3B82F6; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; display: none; `; label.textContent = '● YOU'; el.parentElement?.insertBefore(label, el); } } else { // Add label before agent message blocks const styledMessage = el.querySelector('.styled-chat-message'); if (styledMessage) { const parent = styledMessage.closest('[index]') || styledMessage.parentElement; const existingLabel = parent?.parentElement?.querySelector(`.ninjacat-agent-label[data-for-index="${el.getAttribute('index')}"]`); if (!existingLabel && parent?.parentElement) { const label = document.createElement('div'); label.className = 'ninjacat-agent-label'; label.dataset.forIndex = el.getAttribute('index'); label.style.cssText = ` font-size: 11px; font-weight: 700; color: #059669; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; display: none; `; label.textContent = '● AGENT'; parent.parentElement.insertBefore(label, parent); } } } }); } // ---- Export Controls (Floating Menu) ---- function addExportControls() { const chatContainer = document.querySelector('#assistants-ui'); if (!chatContainer) { console.log('[NinjaCat Chat Export] Could not find chat container'); return; } // Create floating button container const container = document.createElement('div'); container.id = 'ninjacat-export-controls'; container.style.cssText = ` position: fixed; top: 80px; right: 340px; z-index: 9999; `; // Create main trigger button (icon) const triggerBtn = document.createElement('button'); triggerBtn.id = 'ninjacat-export-trigger'; triggerBtn.innerHTML = ` `; triggerBtn.title = 'Export Chat'; triggerBtn.style.cssText = ` width: 40px; height: 40px; border-radius: 50%; background: #3B82F6; color: white; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.15); transition: all 0.2s; `; triggerBtn.onmouseenter = () => { triggerBtn.style.transform = 'scale(1.05)'; }; triggerBtn.onmouseleave = () => { triggerBtn.style.transform = 'scale(1)'; }; // Create dropdown menu const dropdown = document.createElement('div'); dropdown.id = 'ninjacat-export-dropdown'; dropdown.style.cssText = ` position: absolute; top: 45px; right: 0; background: white; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); min-width: 180px; display: none; overflow: hidden; `; // Menu items const menuItems = [ { icon: '📄', text: 'Print PDF', shortcut: '', action: handlePDFExport, id: 'ninjacat-pdf-btn' }, { icon: '📝', text: 'Markdown', shortcut: 'Ctrl+Shift+M', action: handleMarkdownExport, id: 'ninjacat-md-btn' }, { icon: '📋', text: 'Copy Text', shortcut: 'Ctrl+Shift+C', action: handleCopyToClipboard, id: 'ninjacat-copy-btn' }, { type: 'divider' }, { icon: '▼', text: 'Expand All', shortcut: '', action: () => handleExpandAll(dropdown), id: 'ninjacat-expand-btn' }, { icon: '▲', text: 'Collapse All', shortcut: '', action: () => handleCollapseAll(dropdown), id: 'ninjacat-collapse-btn' }, ]; menuItems.forEach(item => { if (item.type === 'divider') { const divider = document.createElement('div'); divider.style.cssText = 'height: 1px; background: #E5E7EB; margin: 4px 0;'; dropdown.appendChild(divider); return; } const menuItem = document.createElement('button'); menuItem.id = item.id; menuItem.style.cssText = ` display: flex; align-items: center; width: 100%; padding: 10px 14px; border: none; background: transparent; cursor: pointer; font-size: 13px; color: #374151; text-align: left; transition: background 0.15s; `; menuItem.innerHTML = ` ${item.icon} ${item.text} ${item.shortcut ? `${item.shortcut}` : ''} `; menuItem.onmouseenter = () => { menuItem.style.background = '#F3F4F6'; }; menuItem.onmouseleave = () => { menuItem.style.background = 'transparent'; }; menuItem.onclick = (e) => { e.stopPropagation(); item.action(); // Close dropdown after action (except for expand/collapse which show feedback) if (!item.id.includes('expand') && !item.id.includes('collapse')) { dropdown.style.display = 'none'; } }; dropdown.appendChild(menuItem); }); // Toggle dropdown on click triggerBtn.onclick = (e) => { e.stopPropagation(); dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; }; // Close dropdown when clicking outside document.addEventListener('click', () => { dropdown.style.display = 'none'; }); container.appendChild(triggerBtn); container.appendChild(dropdown); document.body.appendChild(container); console.log('[NinjaCat Chat Export] Export controls added (floating menu)'); } function handleExpandAll(dropdown) { const btn = document.getElementById('ninjacat-expand-btn'); const originalText = btn.innerHTML; btn.innerHTML = `Expanding...`; toggleAllTasks(true); setTimeout(() => { btn.innerHTML = `Expanded!`; setTimeout(() => { btn.innerHTML = originalText; dropdown.style.display = 'none'; }, 1000); }, 500); } function handleCollapseAll(dropdown) { const btn = document.getElementById('ninjacat-collapse-btn'); const originalText = btn.innerHTML; btn.innerHTML = `Collapsing...`; toggleAllTasks(false); setTimeout(() => { btn.innerHTML = `Collapsed!`; setTimeout(() => { btn.innerHTML = originalText; dropdown.style.display = 'none'; }, 1000); }, 500); } // ---- Keyboard Shortcuts ---- function setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'm') { e.preventDefault(); handleMarkdownExport(); } if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'c') { e.preventDefault(); handleCopyToClipboard(); } }); console.log('[NinjaCat Chat Export] Keyboard shortcuts registered (Ctrl+Shift+M, Ctrl+Shift+C)'); } // ---- Toggle Tasks ---- function toggleAllTasks(expand) { let totalToggled = 0; // Method 1: Toggle elements with data-is-collapsed attribute function toggleDataCollapsed() { const toggles = document.querySelectorAll('[data-is-collapsed]'); let toggledCount = 0; toggles.forEach(toggle => { const isCollapsed = toggle.getAttribute('data-is-collapsed') === 'true'; if ((expand && isCollapsed) || (!expand && !isCollapsed)) { const clickTarget = toggle.closest('.cursor-pointer'); if (clickTarget) { clickTarget.click(); toggledCount++; } } }); return toggledCount; } // Method 2: Toggle overflow-hidden elements (subtasks within Tasks Completed) // These use height:0 when collapsed, height:auto when expanded function toggleOverflowHidden() { const overflowElements = document.querySelectorAll('.overflow-hidden'); let toggledCount = 0; overflowElements.forEach(el => { const computedHeight = el.style.height; const isCollapsed = computedHeight === '0px' || computedHeight === '0'; const isExpanded = computedHeight === 'auto' || (computedHeight && parseInt(computedHeight) > 0); // Find the clickable toggle - usually a sibling or parent with cursor-pointer // Look for previous sibling first (common pattern: header then content) let clickTarget = el.previousElementSibling; if (!clickTarget || !clickTarget.classList.contains('cursor-pointer')) { // Try parent's cursor-pointer clickTarget = el.closest('.cursor-pointer'); } if (!clickTarget) { // Look for cursor-pointer within the parent const parent = el.parentElement; if (parent) { clickTarget = parent.querySelector('.cursor-pointer'); } } if (clickTarget) { if ((expand && isCollapsed) || (!expand && isExpanded)) { clickTarget.click(); toggledCount++; } } }); return toggledCount; } // Method 3: Toggle elements with display:none style (code blocks, query tabs) // Find clickable elements near hidden content function toggleDisplayNone() { let toggledCount = 0; // Look for tab-like interfaces with aria-selected const tabs = document.querySelectorAll('[aria-selected="false"].cursor-pointer'); tabs.forEach(tab => { if (expand) { // Click inactive tabs to reveal their content tab.click(); toggledCount++; } }); return toggledCount; } // Combined toggle function function doToggle() { const count1 = toggleDataCollapsed(); const count2 = toggleOverflowHidden(); const count3 = toggleDisplayNone(); return count1 + count2 + count3; } // First pass - expand top-level items totalToggled += doToggle(); // Multiple passes with delays to catch nested items that appear after expansion const runPass = (passNum, delay) => { setTimeout(() => { const count = doToggle(); if (count > 0) { console.log(`[NinjaCat Chat Export] Pass ${passNum}: ${expand ? 'Expanded' : 'Collapsed'} ${count} additional sections`); totalToggled += count; // Continue if we found more items (up to 5 passes) if (passNum < 5) { runPass(passNum + 1, 200); } } }, delay); }; // Start additional passes runPass(2, 200); console.log(`[NinjaCat Chat Export] ${expand ? 'Expanded' : 'Collapsed'} ${totalToggled} task sections (checking for nested...)`); return totalToggled; } // ---- Handlers ---- function handlePDFExport() { addPrintEnhancements(); setTimeout(() => { window.print(); }, 150); } function handleMarkdownExport() { try { exportToMarkdown(); console.log('[NinjaCat Chat Export] Markdown exported'); } catch (e) { console.error('[NinjaCat Chat Export] Markdown export error:', e); alert('Export failed. Check console for details.'); } } function handleCopyToClipboard() { try { copyToClipboard(); // Show brief visual feedback const trigger = document.getElementById('ninjacat-export-trigger'); if (trigger) { trigger.style.background = '#10B981'; setTimeout(() => { trigger.style.background = '#3B82F6'; }, 1000); } } catch (e) { console.error('[NinjaCat Chat Export] Copy error:', e); alert('Copy failed. Check console for details.'); } } // ---- Helper Functions ---- function getAgentName() { return document.querySelector('h2')?.textContent?.trim() || 'NinjaCat Chat'; } function getAgentDescription() { return document.querySelector('.text-sm.text-grey-70.text-center')?.textContent?.trim() || ''; } function getFormattedDate() { const now = new Date(); return now.toISOString().split('T')[0]; } function escapeHTML(str) { if (!str) return ''; return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function sanitizeFilename(name) { return name.replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-').substring(0, 50); } // ---- Copy to Clipboard ---- function copyToClipboard() { const agentName = getAgentName(); const agentDescription = getAgentDescription(); const exportDate = new Date().toLocaleString(); let text = `${agentName}\n`; if (agentDescription) { text += `${agentDescription}\n`; } text += `Exported: ${exportDate}\n`; text += `${'─'.repeat(50)}\n\n`; const messagesContainer = document.querySelector('.conversationMessagesContainer'); if (!messagesContainer) { throw new Error('No conversation found'); } const allMessageElements = messagesContainer.querySelectorAll('[index]'); allMessageElements.forEach(el => { const isUserMessage = el.classList.contains('self-end') || el.closest('.self-end'); if (isUserMessage) { const msgText = el.querySelector('.whitespace-pre-wrap')?.textContent?.trim() || el.textContent?.trim(); if (msgText) { text += `YOU:\n${msgText}\n\n`; } } else { const styledMessage = el.querySelector('.styled-chat-message') || el; const msgText = styledMessage.textContent?.trim(); if (msgText) { text += `AGENT:\n${msgText}\n\n`; } } }); navigator.clipboard.writeText(text); console.log('[NinjaCat Chat Export] Copied to clipboard'); } // ---- Markdown Export ---- function exportToMarkdown() { const agentName = getAgentName(); const agentDescription = getAgentDescription(); const exportDate = new Date().toLocaleString(); const fileDate = getFormattedDate(); let markdown = `# ${agentName}\n\n`; if (agentDescription) { markdown += `> ${agentDescription}\n\n`; } markdown += `*Exported: ${exportDate}*\n\n---\n\n`; const messagesContainer = document.querySelector('.conversationMessagesContainer'); if (!messagesContainer) { alert('No conversation found to export.'); return; } const allMessageElements = messagesContainer.querySelectorAll('[index]'); allMessageElements.forEach(el => { const isUserMessage = el.classList.contains('self-end') || el.closest('.self-end'); if (isUserMessage) { const text = el.querySelector('.whitespace-pre-wrap')?.textContent?.trim() || el.textContent?.trim(); if (text) { markdown += `## You\n\n${text}\n\n---\n\n`; } } else { // Get main message content const styledMessage = el.querySelector('.styled-chat-message'); let messageContent = ''; if (styledMessage) { messageContent = extractMarkdownContent(styledMessage); } // Also capture expanded task/subtask content // Look for overflow-hidden elements that are expanded (height: auto) const expandedSections = el.querySelectorAll('.overflow-hidden'); expandedSections.forEach(section => { const style = section.getAttribute('style') || ''; const isExpanded = style.includes('height: auto') || style.includes('overflow: visible'); if (isExpanded) { // Check if this content is already in styledMessage if (!styledMessage || !styledMessage.contains(section)) { const sectionContent = extractMarkdownContent(section); if (sectionContent && sectionContent.trim()) { messageContent += '\n\n' + sectionContent; } } } }); // Also look for task content boxes (bg-blue-2 styled boxes) const taskBoxes = el.querySelectorAll('.bg-blue-2'); taskBoxes.forEach(box => { if (!styledMessage || !styledMessage.contains(box)) { const boxContent = extractMarkdownContent(box); if (boxContent && boxContent.trim()) { messageContent += '\n\n**Task Output:**\n' + boxContent; } } }); if (messageContent && messageContent.trim()) { markdown += `## Agent\n\n${messageContent.trim()}\n\n---\n\n`; } } }); const images = messagesContainer.querySelectorAll('img[src*="ai-service"]'); if (images.length > 0) { markdown += `\n### Images in conversation\n\n`; images.forEach((img, i) => { markdown += `![Image ${i + 1}](${img.src})\n\n`; }); } const filename = `${sanitizeFilename(agentName)}-${fileDate}-chat.md`; downloadFile(filename, markdown, 'text/markdown'); } function extractMarkdownContent(element) { if (!element) return ''; const clone = element.cloneNode(true); // Remove our injected labels clone.querySelectorAll('.ninjacat-agent-label, .ninjacat-user-label').forEach(el => el.remove()); // Remove UI elements that shouldn't be in export clone.querySelectorAll('.tableModifiers, .table-pagination').forEach(el => el.remove()); clone.querySelectorAll('[data-automation-id="search-bar"]').forEach(el => el.closest('.flex')?.remove()); // Convert tables to Markdown FIRST (before other processing) clone.querySelectorAll('table').forEach(table => { const mdTable = convertTableToMarkdown(table); const placeholder = document.createElement('div'); placeholder.textContent = mdTable; table.replaceWith(placeholder); }); // Convert headers clone.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(h => { const level = parseInt(h.tagName[1]); const prefix = '#'.repeat(level + 1); h.textContent = `\n${prefix} ${h.textContent.trim()}\n`; }); // Convert code blocks - handle PRE first to avoid double processing clone.querySelectorAll('pre').forEach(pre => { const codeEl = pre.querySelector('code'); const codeText = codeEl ? codeEl.textContent : pre.textContent; // Try to detect language from class const langClass = (codeEl?.className || pre.className || '').match(/language-(\w+)/); const lang = langClass ? langClass[1] : ''; pre.textContent = `\n\`\`\`${lang}\n${codeText.trim()}\n\`\`\`\n`; }); // Convert inline code (but not ones inside pre that we already processed) clone.querySelectorAll('code').forEach(code => { if (!code.closest('pre')) { code.textContent = `\`${code.textContent}\``; } }); // Convert lists clone.querySelectorAll('ul').forEach(ul => { ul.querySelectorAll(':scope > li').forEach(li => { const indent = getListIndent(li); li.innerHTML = `${indent}- ${li.innerHTML}`; }); }); clone.querySelectorAll('ol').forEach(ol => { ol.querySelectorAll(':scope > li').forEach((li, i) => { const indent = getListIndent(li); li.innerHTML = `${indent}${i + 1}. ${li.innerHTML}`; }); }); // Convert text formatting clone.querySelectorAll('strong, b').forEach(el => { el.textContent = `**${el.textContent}**`; }); clone.querySelectorAll('em, i').forEach(el => { // Skip if it's an icon if (!el.closest('svg') && !el.querySelector('svg')) { el.textContent = `*${el.textContent}*`; } }); // Convert links clone.querySelectorAll('a').forEach(a => { const href = a.href; const text = a.textContent.trim(); if (href && text && !href.startsWith('javascript:')) { a.textContent = `[${text}](${href})`; } }); // Get final text content let content = clone.textContent?.trim() || ''; // Clean up excessive whitespace content = content.replace(/\n{3,}/g, '\n\n'); content = content.replace(/[ \t]+\n/g, '\n'); // Remove trailing spaces return content; } // Helper: Convert HTML table to Markdown table function convertTableToMarkdown(table) { const rows = []; const headerRow = table.querySelector('thead tr'); const bodyRows = table.querySelectorAll('tbody tr'); // Extract headers if (headerRow) { const headers = Array.from(headerRow.querySelectorAll('th, td')).map(cell => cell.textContent.trim().replace(/\|/g, '\\|') ); if (headers.length > 0) { rows.push(`| ${headers.join(' | ')} |`); rows.push(`| ${headers.map(() => '---').join(' | ')} |`); } } // Extract body rows bodyRows.forEach(tr => { const cells = Array.from(tr.querySelectorAll('td, th')).map(cell => cell.textContent.trim().replace(/\|/g, '\\|') ); if (cells.length > 0) { rows.push(`| ${cells.join(' | ')} |`); } }); return rows.length > 0 ? '\n' + rows.join('\n') + '\n' : ''; } // Helper: Get indentation for nested list items function getListIndent(li) { let depth = 0; let parent = li.parentElement; while (parent) { if (parent.tagName === 'UL' || parent.tagName === 'OL') { depth++; } parent = parent.parentElement; } return ' '.repeat(Math.max(0, depth - 1)); } function downloadFile(filename, content, mimeType) { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); console.log(`[NinjaCat Chat Export] Downloaded: ${filename}`); } // ---- Start ---- if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();