diff --git a/application/single_app/app.py b/application/single_app/app.py index cd04ff67..2354b1b5 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -74,6 +74,7 @@ from route_backend_public_documents import * from route_backend_public_prompts import * from route_backend_user_agreement import register_route_backend_user_agreement +from route_backend_conversation_export import register_route_backend_conversation_export from route_backend_speech import register_route_backend_speech from route_backend_tts import register_route_backend_tts from route_enhanced_citations import register_enhanced_citations_routes @@ -628,6 +629,9 @@ def list_semantic_kernel_plugins(): # ------------------- API Public Workspaces Routes ------- register_route_backend_public_workspaces(app) +# ------------------- API Conversation Export Routes ----- +register_route_backend_conversation_export(app) + # ------------------- API Public Documents Routes -------- register_route_backend_public_documents(app) diff --git a/application/single_app/config.py b/application/single_app/config.py index 6d548c0a..c68a32f2 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -15,6 +15,12 @@ import fitz # PyMuPDF import math import mimetypes +# Register font MIME types so Flask serves them correctly (required for +# X-Content-Type-Options: nosniff to not block Bootstrap Icons) +mimetypes.add_type('font/woff', '.woff') +mimetypes.add_type('font/woff2', '.woff2') +mimetypes.add_type('font/ttf', '.ttf') +mimetypes.add_type('font/otf', '.otf') import openpyxl import xlrd import traceback diff --git a/application/single_app/route_backend_conversation_export.py b/application/single_app/route_backend_conversation_export.py new file mode 100644 index 00000000..aad750e4 --- /dev/null +++ b/application/single_app/route_backend_conversation_export.py @@ -0,0 +1,288 @@ +# route_backend_conversation_export.py + +import io +import json +import zipfile +from datetime import datetime + +from config import * +from functions_authentication import * +from functions_settings import * +from flask import Response, jsonify, request, make_response +from functions_debug import debug_print +from swagger_wrapper import swagger_route, get_auth_security + + +def register_route_backend_conversation_export(app): + """Register conversation export API routes.""" + + @app.route('/api/conversations/export', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_export_conversations(): + """ + Export one or more conversations in JSON or Markdown format. + Supports single-file or ZIP packaging. + + Request body: + conversation_ids (list): List of conversation IDs to export. + format (str): Export format — "json" or "markdown". + packaging (str): Output packaging — "single" or "zip". + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json() + if not data: + return jsonify({'error': 'Request body is required'}), 400 + + conversation_ids = data.get('conversation_ids', []) + export_format = data.get('format', 'json').lower() + packaging = data.get('packaging', 'single').lower() + + if not conversation_ids or not isinstance(conversation_ids, list): + return jsonify({'error': 'At least one conversation_id is required'}), 400 + + if export_format not in ('json', 'markdown'): + return jsonify({'error': 'Format must be "json" or "markdown"'}), 400 + + if packaging not in ('single', 'zip'): + return jsonify({'error': 'Packaging must be "single" or "zip"'}), 400 + + try: + exported = [] + for conv_id in conversation_ids: + # Verify ownership and fetch conversation + try: + conversation = cosmos_conversations_container.read_item( + item=conv_id, + partition_key=conv_id + ) + except Exception: + debug_print(f"Export: conversation {conv_id} not found or access denied") + continue + + # Verify user owns this conversation + if conversation.get('user_id') != user_id: + debug_print(f"Export: user {user_id} does not own conversation {conv_id}") + continue + + # Fetch messages ordered by timestamp + message_query = f""" + SELECT * FROM c + WHERE c.conversation_id = '{conv_id}' + ORDER BY c.timestamp ASC + """ + messages = list(cosmos_messages_container.query_items( + query=message_query, + partition_key=conv_id + )) + + # Filter for active thread messages only + filtered_messages = [] + for msg in messages: + thread_info = msg.get('metadata', {}).get('thread_info', {}) + active = thread_info.get('active_thread') + if active is True or active is None or 'active_thread' not in thread_info: + filtered_messages.append(msg) + + exported.append({ + 'conversation': _sanitize_conversation(conversation), + 'messages': [_sanitize_message(m) for m in filtered_messages] + }) + + if not exported: + return jsonify({'error': 'No accessible conversations found'}), 404 + + # Generate export content + timestamp_str = datetime.utcnow().strftime('%Y%m%d_%H%M%S') + + if packaging == 'zip': + return _build_zip_response(exported, export_format, timestamp_str) + else: + return _build_single_file_response(exported, export_format, timestamp_str) + + except Exception as e: + debug_print(f"Export error: {str(e)}") + return jsonify({'error': f'Export failed: {str(e)}'}), 500 + + def _sanitize_conversation(conv): + """Return only user-facing conversation fields.""" + return { + 'id': conv.get('id'), + 'title': conv.get('title', 'Untitled'), + 'last_updated': conv.get('last_updated', ''), + 'chat_type': conv.get('chat_type', 'personal'), + 'tags': conv.get('tags', []), + 'is_pinned': conv.get('is_pinned', False), + 'context': conv.get('context', []) + } + + def _sanitize_message(msg): + """Return only user-facing message fields.""" + result = { + 'role': msg.get('role', ''), + 'content': msg.get('content', ''), + 'timestamp': msg.get('timestamp', ''), + } + # Include citations if present + if msg.get('citations'): + result['citations'] = msg['citations'] + # Include context/tool info if present + if msg.get('context'): + result['context'] = msg['context'] + return result + + def _build_single_file_response(exported, export_format, timestamp_str): + """Build a single-file download response.""" + if export_format == 'json': + content = json.dumps(exported, indent=2, ensure_ascii=False, default=str) + filename = f"conversations_export_{timestamp_str}.json" + content_type = 'application/json; charset=utf-8' + else: + parts = [] + for entry in exported: + parts.append(_conversation_to_markdown(entry)) + content = '\n\n---\n\n'.join(parts) + filename = f"conversations_export_{timestamp_str}.md" + content_type = 'text/markdown; charset=utf-8' + + response = make_response(content) + response.headers['Content-Type'] = content_type + response.headers['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + def _build_zip_response(exported, export_format, timestamp_str): + """Build a ZIP archive containing one file per conversation.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + for entry in exported: + conv = entry['conversation'] + safe_title = _safe_filename(conv.get('title', 'Untitled')) + conv_id_short = conv.get('id', 'unknown')[:8] + + if export_format == 'json': + file_content = json.dumps(entry, indent=2, ensure_ascii=False, default=str) + ext = 'json' + else: + file_content = _conversation_to_markdown(entry) + ext = 'md' + + file_name = f"{safe_title}_{conv_id_short}.{ext}" + zf.writestr(file_name, file_content) + + buffer.seek(0) + filename = f"conversations_export_{timestamp_str}.zip" + + response = make_response(buffer.read()) + response.headers['Content-Type'] = 'application/zip' + response.headers['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + def _conversation_to_markdown(entry): + """Convert a conversation + messages entry to Markdown format.""" + conv = entry['conversation'] + messages = entry['messages'] + + lines = [] + title = conv.get('title', 'Untitled') + lines.append(f"# {title}") + lines.append('') + + # Metadata + last_updated = conv.get('last_updated', '') + chat_type = conv.get('chat_type', 'personal') + tags = conv.get('tags', []) + + lines.append(f"**Last Updated:** {last_updated} ") + lines.append(f"**Chat Type:** {chat_type} ") + if tags: + tag_strs = [str(t) for t in tags] + lines.append(f"**Tags:** {', '.join(tag_strs)} ") + lines.append(f"**Messages:** {len(messages)} ") + lines.append('') + lines.append('---') + lines.append('') + + # Messages + for msg in messages: + role = msg.get('role', 'unknown') + timestamp = msg.get('timestamp', '') + raw_content = msg.get('content', '') + content = _normalize_content(raw_content) + + role_label = role.capitalize() + if role == 'assistant': + role_label = 'Assistant' + elif role == 'user': + role_label = 'User' + elif role == 'system': + role_label = 'System' + elif role == 'tool': + role_label = 'Tool' + + lines.append(f"### {role_label}") + if timestamp: + lines.append(f"*{timestamp}*") + lines.append('') + lines.append(content) + lines.append('') + + # Citations + citations = msg.get('citations') + if citations: + lines.append('**Citations:**') + if isinstance(citations, list): + for cit in citations: + if isinstance(cit, dict): + source = cit.get('title') or cit.get('filepath') or cit.get('url', 'Unknown') + lines.append(f"- {source}") + else: + lines.append(f"- {cit}") + lines.append('') + + lines.append('---') + lines.append('') + + return '\n'.join(lines) + + def _normalize_content(content): + """Normalize message content to a plain string. + + Content may be a string, a list of content-part dicts + (e.g. [{"type": "text", "text": "..."}, ...]), or a dict. + """ + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + if item.get('type') == 'text': + parts.append(item.get('text', '')) + elif item.get('type') == 'image_url': + parts.append('[Image]') + else: + parts.append(str(item)) + else: + parts.append(str(item)) + return '\n'.join(parts) + if isinstance(content, dict): + if content.get('type') == 'text': + return content.get('text', '') + return str(content) + return str(content) if content else '' + + def _safe_filename(title): + """Create a filesystem-safe filename from a conversation title.""" + import re + # Remove or replace unsafe characters + safe = re.sub(r'[<>:"/\\|?*]', '_', title) + safe = re.sub(r'\s+', '_', safe) + safe = safe.strip('_. ') + # Truncate to reasonable length + if len(safe) > 50: + safe = safe[:50] + return safe or 'Untitled' diff --git a/application/single_app/static/css/sidebar.css b/application/single_app/static/css/sidebar.css index 999b44c7..ebc40910 100644 --- a/application/single_app/static/css/sidebar.css +++ b/application/single_app/static/css/sidebar.css @@ -304,6 +304,22 @@ body.sidebar-nav-enabled.has-classification-banner .container-fluid { #conversations-actions { opacity: 1; transition: opacity 0.2s ease; + flex-shrink: 0; + gap: 2px; +} + +/* Compact action buttons in selection mode */ +#conversations-actions .btn { + padding: 2px 4px !important; + font-size: 0.7rem !important; + margin-right: 0 !important; + line-height: 1; +} + +/* Reduce toggle row padding when selection actions are visible */ +#conversations-toggle.selection-active { + padding-left: 0.5rem !important; + padding-right: 0.25rem !important; } #sidebar-delete-selected-btn { diff --git a/application/single_app/static/images/custom_logo.png b/application/single_app/static/images/custom_logo.png deleted file mode 100644 index ecf6e652..00000000 Binary files a/application/single_app/static/images/custom_logo.png and /dev/null differ diff --git a/application/single_app/static/images/custom_logo_dark.png b/application/single_app/static/images/custom_logo_dark.png deleted file mode 100644 index 4f281945..00000000 Binary files a/application/single_app/static/images/custom_logo_dark.png and /dev/null differ diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js index 221b5aa5..c4b7345b 100644 --- a/application/single_app/static/js/chat/chat-conversations.js +++ b/application/single_app/static/js/chat/chat-conversations.js @@ -11,6 +11,7 @@ const newConversationBtn = document.getElementById("new-conversation-btn"); const deleteSelectedBtn = document.getElementById("delete-selected-btn"); const pinSelectedBtn = document.getElementById("pin-selected-btn"); const hideSelectedBtn = document.getElementById("hide-selected-btn"); +const exportSelectedBtn = document.getElementById("export-selected-btn"); const conversationsList = document.getElementById("conversations-list"); const currentConversationTitleEl = document.getElementById("current-conversation-title"); const currentConversationClassificationsEl = document.getElementById("current-conversation-classifications"); @@ -96,6 +97,9 @@ function enterSelectionMode() { if (hideSelectedBtn) { hideSelectedBtn.style.display = "block"; } + if (exportSelectedBtn) { + exportSelectedBtn.style.display = "block"; + } // Only reload conversations if we're transitioning from inactive to active // This shows hidden conversations in selection mode @@ -129,6 +133,9 @@ function exitSelectionMode() { if (hideSelectedBtn) { hideSelectedBtn.style.display = "none"; } + if (exportSelectedBtn) { + exportSelectedBtn.style.display = "none"; + } // Clear any selections selectedConversations.clear(); @@ -503,6 +510,14 @@ export function createConversationItem(convo) { selectA.href = "#"; selectA.innerHTML = 'Select'; selectLi.appendChild(selectA); + + // Add Export option + const exportLi = document.createElement("li"); + const exportA = document.createElement("a"); + exportA.classList.add("dropdown-item", "export-btn"); + exportA.href = "#"; + exportA.innerHTML = 'Export'; + exportLi.appendChild(exportA); const editLi = document.createElement("li"); const editA = document.createElement("a"); @@ -522,6 +537,8 @@ export function createConversationItem(convo) { dropdownMenu.appendChild(pinLi); dropdownMenu.appendChild(hideLi); dropdownMenu.appendChild(selectLi); + dropdownMenu.appendChild(exportLi); + dropdownMenu.appendChild(editLi); dropdownMenu.appendChild(deleteLi); rightDiv.appendChild(dropdownBtn); @@ -571,6 +588,16 @@ export function createConversationItem(convo) { enterSelectionMode(); }); + // Add event listener for the Export button + exportA.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + closeDropdownMenu(dropdownBtn); + if (window.chatExport && window.chatExport.openExportWizard) { + window.chatExport.openExportWizard([convo.id], true); + } + }); + // Add event listener for the Pin button pinA.addEventListener("click", (event) => { event.preventDefault(); @@ -1423,6 +1450,17 @@ if (hideSelectedBtn) { hideSelectedBtn.addEventListener("click", bulkHideConversations); } +if (exportSelectedBtn) { + exportSelectedBtn.addEventListener("click", () => { + if (window.chatExport && window.chatExport.openExportWizard) { + const selectedIds = Array.from(selectedConversations); + if (selectedIds.length > 0) { + window.chatExport.openExportWizard(selectedIds, false); + } + } + }); +} + // Helper function to set show hidden conversations state and return a promise export function setShowHiddenConversations(value) { showHiddenConversations = value; diff --git a/application/single_app/static/js/chat/chat-export.js b/application/single_app/static/js/chat/chat-export.js new file mode 100644 index 00000000..269cbfe0 --- /dev/null +++ b/application/single_app/static/js/chat/chat-export.js @@ -0,0 +1,517 @@ +// chat-export.js +import { showToast } from "./chat-toast.js"; + +'use strict'; + +/** + * Conversation Export Wizard Module + * + * Provides a multi-step modal wizard for exporting conversations + * in JSON or Markdown format with single-file or ZIP packaging. + */ + +// --- Wizard State --- +let exportConversationIds = []; +let exportConversationTitles = {}; +let exportFormat = 'json'; +let exportPackaging = 'single'; +let currentStep = 1; +let totalSteps = 3; +let skipSelectionStep = false; + +// Modal reference +let exportModal = null; + +// --- DOM Helpers --- +function getEl(id) { + return document.getElementById(id); +} + +// --- Initialize --- +document.addEventListener('DOMContentLoaded', () => { + const modalEl = getEl('export-wizard-modal'); + if (modalEl) { + exportModal = new bootstrap.Modal(modalEl); + } +}); + +// --- Public Entry Point --- + +/** + * Open the export wizard. + * @param {string[]} conversationIds - Array of conversation IDs to export. + * @param {boolean} skipSelection - If true, skip step 1 (review) and start at format choice. + */ +function openExportWizard(conversationIds, skipSelection) { + if (!conversationIds || conversationIds.length === 0) { + showToast('No conversations selected for export.', 'warning'); + return; + } + + // Reset state + exportConversationIds = [...conversationIds]; + exportConversationTitles = {}; + exportFormat = 'json'; + exportPackaging = conversationIds.length > 1 ? 'zip' : 'single'; + skipSelectionStep = !!skipSelection; + + // Determine step configuration + if (skipSelectionStep) { + totalSteps = 3; + currentStep = 1; // Format step (mapped to visual step) + } else { + totalSteps = 4; + currentStep = 1; // Selection review step + } + + // Initialize the modal if not already + if (!exportModal) { + const modalEl = getEl('export-wizard-modal'); + if (modalEl) { + exportModal = new bootstrap.Modal(modalEl); + } + } + + if (!exportModal) { + showToast('Export wizard not available.', 'danger'); + return; + } + + // Load conversation titles, then show the modal + _loadConversationTitles().then(() => { + _renderCurrentStep(); + _updateStepIndicators(); + _updateNavigationButtons(); + exportModal.show(); + }); +} + +// --- Step Navigation --- + +function nextStep() { + if (currentStep < totalSteps) { + currentStep++; + _renderCurrentStep(); + _updateStepIndicators(); + _updateNavigationButtons(); + } +} + +function prevStep() { + if (currentStep > 1) { + currentStep--; + _renderCurrentStep(); + _updateStepIndicators(); + _updateNavigationButtons(); + } +} + +// --- Data Loading --- + +async function _loadConversationTitles() { + try { + const response = await fetch('/api/get_conversations'); + if (!response.ok) throw new Error('Failed to fetch conversations'); + const data = await response.json(); + const conversations = data.conversations || []; + exportConversationTitles = {}; + conversations.forEach(c => { + if (exportConversationIds.includes(c.id)) { + exportConversationTitles[c.id] = c.title || 'Untitled'; + } + }); + // Fill in any missing titles + exportConversationIds.forEach(id => { + if (!exportConversationTitles[id]) { + exportConversationTitles[id] = 'Untitled Conversation'; + } + }); + } catch (err) { + console.error('Error loading conversation titles for export:', err); + // Use placeholder titles + exportConversationIds.forEach(id => { + exportConversationTitles[id] = exportConversationTitles[id] || 'Conversation'; + }); + } +} + +// --- Step Rendering --- + +function _renderCurrentStep() { + const stepBody = getEl('export-wizard-body'); + if (!stepBody) return; + + if (skipSelectionStep) { + // Steps: 1=Format, 2=Packaging, 3=Download + switch (currentStep) { + case 1: _renderFormatStep(stepBody); break; + case 2: _renderPackagingStep(stepBody); break; + case 3: _renderDownloadStep(stepBody); break; + } + } else { + // Steps: 1=Selection, 2=Format, 3=Packaging, 4=Download + switch (currentStep) { + case 1: _renderSelectionStep(stepBody); break; + case 2: _renderFormatStep(stepBody); break; + case 3: _renderPackagingStep(stepBody); break; + case 4: _renderDownloadStep(stepBody); break; + } + } +} + +function _renderSelectionStep(container) { + const count = exportConversationIds.length; + let listHtml = ''; + exportConversationIds.forEach(id => { + const title = _escapeHtml(exportConversationTitles[id] || 'Untitled'); + listHtml += ` +
+
+ + ${title} +
+ +
`; + }); + + container.innerHTML = ` +
+
Review Conversations
+

You have ${count} conversation${count !== 1 ? 's' : ''} selected for export. Remove any you don't want to include.

+
+
+ ${listHtml || '
No conversations selected
'} +
`; + + // Wire remove buttons + container.querySelectorAll('.export-remove-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const removeId = btn.dataset.id; + exportConversationIds = exportConversationIds.filter(id => id !== removeId); + delete exportConversationTitles[removeId]; + if (exportConversationIds.length === 0) { + showToast('All conversations removed. Closing export wizard.', 'warning'); + exportModal.hide(); + return; + } + _renderSelectionStep(container); + _updateNavigationButtons(); + }); + }); +} + +function _renderFormatStep(container) { + container.innerHTML = ` +
+
Choose Export Format
+

Select the format for your exported conversations.

+
+
+
+
+
+ +
JSON
+

Structured data format. Ideal for programmatic analysis or re-import.

+
+
+
+
+
+
+ +
Markdown
+

Human-readable format. Great for documentation and sharing.

+
+
+
+
`; + + // Wire card clicks + container.querySelectorAll('.action-type-card[data-format]').forEach(card => { + card.addEventListener('click', () => { + exportFormat = card.dataset.format; + container.querySelectorAll('.action-type-card[data-format]').forEach(c => c.classList.remove('selected')); + card.classList.add('selected'); + }); + card.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + card.click(); + } + }); + }); +} + +function _renderPackagingStep(container) { + const count = exportConversationIds.length; + const singleDesc = count > 1 + ? 'All conversations combined into one file.' + : 'Export as a single file.'; + const zipDesc = count > 1 + ? 'Each conversation in a separate file, bundled in a ZIP archive.' + : 'Single conversation wrapped in a ZIP archive.'; + + container.innerHTML = ` +
+
Choose Output Packaging
+

Select how the exported file(s) should be packaged.

+
+
+
+
+
+ +
Single File
+

${singleDesc}

+
+
+
+
+
+
+ +
ZIP Archive
+

${zipDesc}

+
+
+
+
`; + + // Wire card clicks + container.querySelectorAll('.action-type-card[data-packaging]').forEach(card => { + card.addEventListener('click', () => { + exportPackaging = card.dataset.packaging; + container.querySelectorAll('.action-type-card[data-packaging]').forEach(c => c.classList.remove('selected')); + card.classList.add('selected'); + }); + card.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + card.click(); + } + }); + }); +} + +function _renderDownloadStep(container) { + const count = exportConversationIds.length; + const formatLabel = exportFormat === 'json' ? 'JSON' : 'Markdown'; + const packagingLabel = exportPackaging === 'zip' ? 'ZIP Archive' : 'Single File'; + const ext = exportPackaging === 'zip' ? '.zip' : (exportFormat === 'json' ? '.json' : '.md'); + + let conversationsList = ''; + exportConversationIds.forEach(id => { + const title = _escapeHtml(exportConversationTitles[id] || 'Untitled'); + conversationsList += `
  • ${title}
  • `; + }); + + container.innerHTML = ` +
    +
    Ready to Export
    +

    Review your export settings and click Download.

    +
    +
    +
    +
    +
    Conversations:
    +
    ${count} conversation${count !== 1 ? 's' : ''}
    +
    +
    +
    Format:
    +
    ${formatLabel}
    +
    +
    +
    Packaging:
    +
    ${packagingLabel}
    +
    +
    +
    File type:
    +
    ${ext}
    +
    +
    +
    +
    + +
    +
    + +
    +
    `; + + // Wire download button + const downloadBtn = getEl('export-download-btn'); + if (downloadBtn) { + downloadBtn.addEventListener('click', _executeExport); + } +} + +// --- Step Indicator & Navigation --- + +function _updateStepIndicators() { + const stepsContainer = getEl('export-steps-container'); + if (!stepsContainer) return; + + let steps; + if (skipSelectionStep) { + steps = [ + { label: 'Format', icon: 'bi-filetype-json' }, + { label: 'Packaging', icon: 'bi-box' }, + { label: 'Download', icon: 'bi-download' } + ]; + } else { + steps = [ + { label: 'Select', icon: 'bi-list-check' }, + { label: 'Format', icon: 'bi-filetype-json' }, + { label: 'Packaging', icon: 'bi-box' }, + { label: 'Download', icon: 'bi-download' } + ]; + } + + let html = ''; + steps.forEach((step, index) => { + const stepNum = index + 1; + let circleClass = 'step-circle'; + let indicatorClass = 'step-indicator'; + if (stepNum < currentStep) { + circleClass += ' completed'; + indicatorClass += ' completed'; + } else if (stepNum === currentStep) { + circleClass += ' active'; + indicatorClass += ' active'; + } + + // Add connector line between steps + const connector = index < steps.length - 1 + ? '
    ' + : ''; + + html += ` +
    +
    ${stepNum < currentStep ? '' : stepNum}
    +
    ${step.label}
    + ${connector} +
    `; + }); + + stepsContainer.innerHTML = html; +} + +function _updateNavigationButtons() { + const prevBtn = getEl('export-prev-btn'); + const nextBtn = getEl('export-next-btn'); + + if (prevBtn) { + prevBtn.style.display = currentStep > 1 ? 'inline-block' : 'none'; + prevBtn.onclick = prevStep; + } + + if (nextBtn) { + const isLastStep = currentStep === totalSteps; + nextBtn.style.display = isLastStep ? 'none' : 'inline-block'; + nextBtn.onclick = nextStep; + + // Validate selection step — need at least 1 conversation + if (!skipSelectionStep && currentStep === 1 && exportConversationIds.length === 0) { + nextBtn.disabled = true; + } else { + nextBtn.disabled = false; + } + } +} + +// --- Export Execution --- + +async function _executeExport() { + const downloadBtn = getEl('export-download-btn'); + const statusDiv = getEl('export-download-status'); + + if (downloadBtn) { + downloadBtn.disabled = true; + downloadBtn.innerHTML = 'Generating export...'; + } + if (statusDiv) { + statusDiv.innerHTML = 'This may take a moment for large conversations...'; + } + + try { + const response = await fetch('/api/conversations/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + conversation_ids: exportConversationIds, + format: exportFormat, + packaging: exportPackaging + }) + }); + + if (!response.ok) { + const errData = await response.json().catch(() => ({})); + throw new Error(errData.error || `Server responded with status ${response.status}`); + } + + // Get filename from Content-Disposition header + const disposition = response.headers.get('Content-Disposition') || ''; + const filenameMatch = disposition.match(/filename="?([^"]+)"?/); + const filename = filenameMatch ? filenameMatch[1] : `conversations_export.${exportPackaging === 'zip' ? 'zip' : (exportFormat === 'json' ? 'json' : 'md')}`; + + // Download the blob + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + if (downloadBtn) { + downloadBtn.disabled = false; + downloadBtn.innerHTML = 'Downloaded!'; + downloadBtn.classList.remove('btn-primary'); + downloadBtn.classList.add('btn-success'); + } + if (statusDiv) { + statusDiv.innerHTML = 'Export downloaded successfully.'; + } + + showToast('Conversations exported successfully.', 'success'); + + // Auto-close modal after a short delay + setTimeout(() => { + if (exportModal) exportModal.hide(); + }, 1500); + + } catch (err) { + console.error('Export error:', err); + if (downloadBtn) { + downloadBtn.disabled = false; + downloadBtn.innerHTML = 'Retry Download'; + } + if (statusDiv) { + statusDiv.innerHTML = `Error: ${_escapeHtml(err.message)}`; + } + showToast(`Export failed: ${err.message}`, 'danger'); + } +} + +// --- Utility --- + +function _escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// --- Expose Globally --- +window.chatExport = { + openExportWizard +}; diff --git a/application/single_app/static/js/chat/chat-sidebar-conversations.js b/application/single_app/static/js/chat/chat-sidebar-conversations.js index c8bd3729..4e89144f 100644 --- a/application/single_app/static/js/chat/chat-sidebar-conversations.js +++ b/application/single_app/static/js/chat/chat-sidebar-conversations.js @@ -155,6 +155,7 @@ function createSidebarConversationItem(convo) {
  • ${isPinned ? 'Unpin' : 'Pin'}
  • ${isHidden ? 'Unhide' : 'Hide'}
  • Select
  • +
  • Export
  • Edit title
  • Delete
  • @@ -285,6 +286,7 @@ function createSidebarConversationItem(convo) { const pinBtn = convoItem.querySelector('.pin-btn'); const hideBtn = convoItem.querySelector('.hide-btn'); const selectBtn = convoItem.querySelector('.select-btn'); + const exportBtn = convoItem.querySelector('.export-btn'); const editBtn = convoItem.querySelector('.edit-btn'); const deleteBtn = convoItem.querySelector('.delete-btn'); @@ -400,6 +402,25 @@ function createSidebarConversationItem(convo) { }); } + if (exportBtn) { + exportBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + // Close dropdown after action + const dropdownBtn = convoItem.querySelector('[data-bs-toggle="dropdown"]'); + if (dropdownBtn) { + const dropdownInstance = bootstrap.Dropdown.getInstance(dropdownBtn); + if (dropdownInstance) { + dropdownInstance.hide(); + } + } + // Open export wizard for this single conversation + if (window.chatExport && window.chatExport.openExportWizard) { + window.chatExport.openExportWizard([convo.id], true); + } + }); + } + if (editBtn) { editBtn.addEventListener('click', (e) => { e.preventDefault(); @@ -523,6 +544,7 @@ export function setSidebarSelectionMode(isActive) { if (isActive) { conversationsToggle.style.color = '#856404'; conversationsToggle.style.fontWeight = '600'; + conversationsToggle.classList.add('selection-active'); conversationsActions.style.display = 'flex !important'; conversationsActions.style.setProperty('display', 'flex', 'important'); // Hide the search and eye buttons in selection mode @@ -564,6 +586,7 @@ export function setSidebarSelectionMode(isActive) { } else { conversationsToggle.style.color = ''; conversationsToggle.style.fontWeight = ''; + conversationsToggle.classList.remove('selection-active'); conversationsActions.style.display = 'none !important'; conversationsActions.style.setProperty('display', 'none', 'important'); if (sidebarDeleteBtn) { @@ -575,6 +598,10 @@ export function setSidebarSelectionMode(isActive) { if (sidebarHideBtn) { sidebarHideBtn.style.display = 'none'; } + const sidebarExportBtn = document.getElementById('sidebar-export-selected-btn'); + if (sidebarExportBtn) { + sidebarExportBtn.style.display = 'none'; + } // Show the search and eye buttons again when exiting selection mode if (sidebarSettingsBtn) { sidebarSettingsBtn.style.display = 'inline-block'; @@ -596,6 +623,7 @@ export function updateSidebarDeleteButton(selectedCount) { const sidebarDeleteBtn = document.getElementById('sidebar-delete-selected-btn'); const sidebarPinBtn = document.getElementById('sidebar-pin-selected-btn'); const sidebarHideBtn = document.getElementById('sidebar-hide-selected-btn'); + const sidebarExportBtn = document.getElementById('sidebar-export-selected-btn'); if (selectedCount > 0) { if (sidebarDeleteBtn) { @@ -610,6 +638,10 @@ export function updateSidebarDeleteButton(selectedCount) { sidebarHideBtn.style.display = 'inline-flex'; sidebarHideBtn.title = `Hide ${selectedCount} selected conversation${selectedCount > 1 ? 's' : ''}`; } + if (sidebarExportBtn) { + sidebarExportBtn.style.display = 'inline-flex'; + sidebarExportBtn.title = `Export ${selectedCount} selected conversation${selectedCount > 1 ? 's' : ''}`; + } } else { if (sidebarDeleteBtn) { sidebarDeleteBtn.style.display = 'none'; @@ -620,6 +652,9 @@ export function updateSidebarDeleteButton(selectedCount) { if (sidebarHideBtn) { sidebarHideBtn.style.display = 'none'; } + if (sidebarExportBtn) { + sidebarExportBtn.style.display = 'none'; + } } } @@ -821,6 +856,22 @@ document.addEventListener('DOMContentLoaded', () => { }); } + // Handle sidebar export selected button click + const sidebarExportBtn = document.getElementById('sidebar-export-selected-btn'); + if (sidebarExportBtn) { + sidebarExportBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + // Open export wizard for selected conversations + if (window.chatExport && window.chatExport.openExportWizard && window.chatConversations && window.chatConversations.getSelectedConversations) { + const selectedIds = window.chatConversations.getSelectedConversations(); + if (selectedIds && selectedIds.length > 0) { + window.chatExport.openExportWizard(Array.from(selectedIds), false); + } + } + }); + } + // Handle sidebar settings button click (toggle show/hide hidden conversations) const sidebarSettingsBtn = document.getElementById('sidebar-conversations-settings-btn'); if (sidebarSettingsBtn) { diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index 2f169573..a0bceee8 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -573,15 +573,18 @@
    - - - +
    diff --git a/application/single_app/templates/_sidebar_short_nav.html b/application/single_app/templates/_sidebar_short_nav.html index 954eab25..34413abc 100644 --- a/application/single_app/templates/_sidebar_short_nav.html +++ b/application/single_app/templates/_sidebar_short_nav.html @@ -43,6 +43,9 @@ + diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index 37c8c27d..b6c212cc 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -246,6 +246,9 @@
    Conversations
    + + + + + + + +
    @@ -1038,6 +1073,7 @@