# Story 2.5: Export Functionality ## Status Draft ## Story **As a** user **I want** to export my summaries in multiple formats (Markdown, PDF, plain text, JSON) **so that** I can integrate summaries into my workflows, share them with others, and archive them ## Acceptance Criteria 1. Export summaries in multiple formats: Markdown, PDF, plain text, JSON, and HTML 2. Customizable export templates with branding options and formatting preferences 3. Bulk export functionality for multiple summaries with compression and organization 4. Export includes comprehensive metadata (video info, processing details, timestamps) 5. Generated exports are optimized for readability and professional presentation 6. Export process handles large datasets efficiently with progress tracking ## Tasks / Subtasks - [ ] **Task 1: Export Service Architecture** (AC: 1, 4) - [ ] Create `ExportService` in `backend/services/export_service.py` - [ ] Implement format-specific exporters (Markdown, PDF, Text, JSON, HTML) - [ ] Design export data models and metadata inclusion - [ ] Create export job management with progress tracking - [ ] **Task 2: Format-Specific Exporters** (AC: 1, 5) - [ ] Implement `MarkdownExporter` with clean formatting and structure - [ ] Create `PDFExporter` using ReportLab with professional layouts - [ ] Build `PlainTextExporter` with configurable formatting options - [ ] Develop `JSONExporter` with structured data and metadata - [ ] Create `HTMLExporter` with responsive design and styling - [ ] **Task 3: Template System** (AC: 2, 5) - [ ] Design template engine for customizable export layouts - [ ] Create default templates for each export format - [ ] Implement branding customization (logos, colors, headers) - [ ] Add template management and user-defined templates - [ ] **Task 4: Bulk Export Functionality** (AC: 3, 6) - [ ] Implement batch export processing with job queuing - [ ] Create archive generation (ZIP) with organized folder structure - [ ] Add export filtering and selection criteria - [ ] Implement progress tracking for large export operations - [ ] **Task 5: API Endpoints and Integration** (AC: 1, 3, 6) - [ ] Create `/api/export/single` endpoint for individual summary exports - [ ] Implement `/api/export/bulk` endpoint for batch operations - [ ] Add `/api/export/status` endpoint for progress monitoring - [ ] Create download endpoints with secure file serving - [ ] **Task 6: Frontend Export Interface** (AC: 1, 2, 3) - [ ] Build export options modal with format selection - [ ] Create template customization interface - [ ] Implement bulk selection and export management - [ ] Add export history and download management - [ ] **Task 7: Performance and Quality** (AC: 5, 6) - [ ] Optimize export generation for large summaries - [ ] Implement export caching for repeated requests - [ ] Add export validation and quality checks - [ ] Create comprehensive error handling and retry logic ## Dev Notes ### Architecture Context This story completes the YouTube Summarizer by providing professional export capabilities. Users can take their summaries and integrate them into their existing workflows, documentation systems, or share them in presentations and reports. ### Export Service Architecture [Source: docs/architecture.md#export-architecture] ```python # backend/services/export_service.py import os import json import zipfile import tempfile from datetime import datetime from typing import Dict, List, Optional, Any, Union from enum import Enum from abc import ABC, abstractmethod import asyncio import aiofiles from dataclasses import dataclass from pathlib import Path class ExportFormat(Enum): MARKDOWN = "markdown" PDF = "pdf" PLAIN_TEXT = "text" JSON = "json" HTML = "html" class ExportStatus(Enum): PENDING = "pending" PROCESSING = "processing" COMPLETED = "completed" FAILED = "failed" @dataclass class ExportRequest: summary_id: str format: ExportFormat template: Optional[str] = None include_metadata: bool = True custom_branding: Optional[Dict[str, Any]] = None @dataclass class BulkExportRequest: summary_ids: List[str] formats: List[ExportFormat] template: Optional[str] = None include_metadata: bool = True organize_by: str = "format" # "format", "date", "video" custom_branding: Optional[Dict[str, Any]] = None @dataclass class ExportResult: export_id: str status: ExportStatus format: ExportFormat file_path: Optional[str] = None file_size_bytes: Optional[int] = None download_url: Optional[str] = None error: Optional[str] = None created_at: Optional[datetime] = None completed_at: Optional[datetime] = None class BaseExporter(ABC): """Base class for format-specific exporters""" @abstractmethod async def export( self, summary_data: Dict[str, Any], template: Optional[str] = None, branding: Optional[Dict[str, Any]] = None ) -> str: """Export summary to specific format and return file path""" pass @abstractmethod def get_file_extension(self) -> str: """Get file extension for this export format""" pass def _prepare_summary_data(self, summary_data: Dict[str, Any]) -> Dict[str, Any]: """Prepare and enrich summary data for export""" return { **summary_data, "export_metadata": { "exported_at": datetime.utcnow().isoformat(), "exporter_version": "1.0", "youtube_summarizer_version": "2.0" } } class ExportService: """Main service for handling summary exports""" def __init__(self, export_dir: str = "/tmp/youtube_summarizer_exports"): self.export_dir = Path(export_dir) self.export_dir.mkdir(parents=True, exist_ok=True) # Initialize format-specific exporters self.exporters: Dict[ExportFormat, BaseExporter] = { ExportFormat.MARKDOWN: MarkdownExporter(), ExportFormat.PDF: PDFExporter(), ExportFormat.PLAIN_TEXT: PlainTextExporter(), ExportFormat.JSON: JSONExporter(), ExportFormat.HTML: HTMLExporter() } # Track active exports self.active_exports: Dict[str, ExportResult] = {} async def export_summary( self, summary_data: Dict[str, Any], request: ExportRequest ) -> ExportResult: """Export single summary""" import uuid export_id = str(uuid.uuid4()) result = ExportResult( export_id=export_id, status=ExportStatus.PENDING, format=request.format, created_at=datetime.utcnow() ) self.active_exports[export_id] = result try: result.status = ExportStatus.PROCESSING # Get appropriate exporter exporter = self.exporters[request.format] # Export the summary file_path = await exporter.export( summary_data=summary_data, template=request.template, branding=request.custom_branding ) # Update result result.file_path = file_path result.file_size_bytes = os.path.getsize(file_path) result.download_url = f"/api/export/download/{export_id}" result.status = ExportStatus.COMPLETED result.completed_at = datetime.utcnow() except Exception as e: result.status = ExportStatus.FAILED result.error = str(e) result.completed_at = datetime.utcnow() return result async def bulk_export_summaries( self, summaries_data: List[Dict[str, Any]], request: BulkExportRequest ) -> ExportResult: """Export multiple summaries with organization""" import uuid export_id = str(uuid.uuid4()) result = ExportResult( export_id=export_id, status=ExportStatus.PENDING, format=ExportFormat.JSON, # Bulk exports are archives created_at=datetime.utcnow() ) self.active_exports[export_id] = result try: result.status = ExportStatus.PROCESSING # Create temporary directory for bulk export with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Export each summary in requested formats for summary_data in summaries_data: await self._export_summary_to_bulk( summary_data, request, temp_path ) # Create ZIP archive archive_path = self.export_dir / f"bulk_export_{export_id}.zip" await self._create_archive(temp_path, archive_path) result.file_path = str(archive_path) result.file_size_bytes = os.path.getsize(archive_path) result.download_url = f"/api/export/download/{export_id}" result.status = ExportStatus.COMPLETED result.completed_at = datetime.utcnow() except Exception as e: result.status = ExportStatus.FAILED result.error = str(e) result.completed_at = datetime.utcnow() return result async def _export_summary_to_bulk( self, summary_data: Dict[str, Any], request: BulkExportRequest, output_dir: Path ): """Export single summary to bulk export directory""" video_title = summary_data.get("video_metadata", {}).get("title", "Unknown") safe_title = self._sanitize_filename(video_title) for format in request.formats: exporter = self.exporters[format] # Determine output path based on organization preference if request.organize_by == "format": format_dir = output_dir / format.value format_dir.mkdir(exist_ok=True) output_path = format_dir / f"{safe_title}.{exporter.get_file_extension()}" elif request.organize_by == "date": date_str = summary_data.get("created_at", "unknown")[:10] # YYYY-MM-DD date_dir = output_dir / date_str date_dir.mkdir(exist_ok=True) output_path = date_dir / f"{safe_title}.{exporter.get_file_extension()}" else: # organize by video video_dir = output_dir / safe_title video_dir.mkdir(exist_ok=True) output_path = video_dir / f"{safe_title}.{exporter.get_file_extension()}" # Export to specific format temp_file = await exporter.export( summary_data=summary_data, template=request.template, branding=request.custom_branding ) # Move to organized location import shutil shutil.move(temp_file, output_path) async def _create_archive(self, source_dir: Path, archive_path: Path): """Create ZIP archive from directory""" with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zipf: for file_path in source_dir.rglob('*'): if file_path.is_file(): arcname = file_path.relative_to(source_dir) zipf.write(file_path, arcname) def _sanitize_filename(self, filename: str) -> str: """Sanitize filename for filesystem compatibility""" import re # Replace invalid characters with underscores sanitized = re.sub(r'[<>:"/\\|?*]', '_', filename) # Limit length and strip whitespace return sanitized[:100].strip() def get_export_status(self, export_id: str) -> Optional[ExportResult]: """Get export status by ID""" return self.active_exports.get(export_id) async def cleanup_old_exports(self, max_age_hours: int = 24): """Clean up old export files""" cutoff_time = datetime.utcnow().timestamp() - (max_age_hours * 3600) for export_id, result in list(self.active_exports.items()): if result.created_at and result.created_at.timestamp() < cutoff_time: # Remove file if exists if result.file_path and os.path.exists(result.file_path): os.remove(result.file_path) # Remove from active exports del self.active_exports[export_id] ``` ### Format-Specific Exporters [Source: docs/architecture.md#export-formats] ```python # backend/services/exporters/markdown_exporter.py class MarkdownExporter(BaseExporter): """Export summaries to Markdown format""" async def export( self, summary_data: Dict[str, Any], template: Optional[str] = None, branding: Optional[Dict[str, Any]] = None ) -> str: """Export to Markdown""" data = self._prepare_summary_data(summary_data) # Use custom template if provided, otherwise default if template: content = await self._render_custom_template(template, data) else: content = self._render_default_template(data, branding) # Write to temporary file import tempfile with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f: f.write(content) return f.name def _render_default_template(self, data: Dict[str, Any], branding: Optional[Dict[str, Any]]) -> str: """Render default Markdown template""" video_metadata = data.get("video_metadata", {}) processing_metadata = data.get("processing_metadata", {}) # Header with branding header = "" if branding and branding.get("company_name"): header = f"*Generated by {branding['company_name']} using YouTube Summarizer*\n\n" markdown = f"""{header}# YouTube Video Summary ## Video Information - **Title**: {video_metadata.get('title', 'N/A')} - **URL**: {data.get('video_url', 'N/A')} - **Channel**: {video_metadata.get('channel_name', 'N/A')} - **Duration**: {video_metadata.get('duration', 'N/A')} - **Published**: {video_metadata.get('published_at', 'N/A')} ## Summary {data.get('summary', 'No summary available')} ## Key Points """ # Add key points key_points = data.get('key_points', []) for point in key_points: markdown += f"- {point}\n" markdown += "\n## Main Themes\n\n" # Add main themes main_themes = data.get('main_themes', []) for theme in main_themes: markdown += f"- **{theme}**\n" markdown += "\n## Actionable Insights\n\n" # Add actionable insights insights = data.get('actionable_insights', []) for i, insight in enumerate(insights, 1): markdown += f"{i}. {insight}\n" # Add metadata footer markdown += f""" --- ## Processing Information - **AI Model**: {processing_metadata.get('model', 'N/A')} - **Processing Time**: {processing_metadata.get('processing_time_seconds', 'N/A')} seconds - **Confidence Score**: {data.get('confidence_score', 'N/A')} - **Generated**: {data.get('export_metadata', {}).get('exported_at', 'N/A')} *Summary generated by YouTube Summarizer - Transform video content into actionable insights* """ return markdown def get_file_extension(self) -> str: return "md" # backend/services/exporters/pdf_exporter.py from reportlab.lib.pagesizes import letter, A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import inch from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle from reportlab.lib import colors class PDFExporter(BaseExporter): """Export summaries to PDF format""" async def export( self, summary_data: Dict[str, Any], template: Optional[str] = None, branding: Optional[Dict[str, Any]] = None ) -> str: """Export to PDF""" data = self._prepare_summary_data(summary_data) import tempfile with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as f: doc = SimpleDocTemplate(f.name, pagesize=A4, leftMargin=1*inch, rightMargin=1*inch, topMargin=1*inch, bottomMargin=1*inch) story = self._build_pdf_content(data, branding) doc.build(story) return f.name def _build_pdf_content(self, data: Dict[str, Any], branding: Optional[Dict[str, Any]]) -> List: """Build PDF content elements""" styles = getSampleStyleSheet() story = [] # Custom styles title_style = ParagraphStyle( 'CustomTitle', parent=styles['Title'], fontSize=24, textColor=colors.darkblue, spaceAfter=30 ) heading_style = ParagraphStyle( 'CustomHeading', parent=styles['Heading2'], fontSize=14, textColor=colors.darkblue, spaceBefore=20, spaceAfter=10 ) # Title video_title = data.get("video_metadata", {}).get("title", "YouTube Video Summary") story.append(Paragraph(f"Summary: {video_title}", title_style)) story.append(Spacer(1, 20)) # Video Information Table video_metadata = data.get("video_metadata", {}) video_info = [ ["Video Title", video_metadata.get('title', 'N/A')], ["Channel", video_metadata.get('channel_name', 'N/A')], ["Duration", video_metadata.get('duration', 'N/A')], ["Published", video_metadata.get('published_at', 'N/A')], ["URL", data.get('video_url', 'N/A')] ] video_table = Table(video_info, colWidths=[2*inch, 4*inch]) video_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (0, -1), colors.lightgrey), ('TEXTCOLOR', (0, 0), (0, -1), colors.black), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 10), ('GRID', (0, 0), (-1, -1), 1, colors.black) ])) story.append(video_table) story.append(Spacer(1, 30)) # Summary story.append(Paragraph("Summary", heading_style)) summary_text = data.get('summary', 'No summary available') story.append(Paragraph(summary_text, styles['Normal'])) story.append(Spacer(1, 20)) # Key Points story.append(Paragraph("Key Points", heading_style)) key_points = data.get('key_points', []) for point in key_points: story.append(Paragraph(f"• {point}", styles['Normal'])) story.append(Spacer(1, 20)) # Main Themes story.append(Paragraph("Main Themes", heading_style)) main_themes = data.get('main_themes', []) for theme in main_themes: story.append(Paragraph(f"• {theme}", styles['Normal'])) story.append(Spacer(1, 20)) # Actionable Insights story.append(Paragraph("Actionable Insights", heading_style)) insights = data.get('actionable_insights', []) for i, insight in enumerate(insights, 1): story.append(Paragraph(f"{i}. {insight}", styles['Normal'])) # Footer story.append(Spacer(1, 40)) footer_style = ParagraphStyle( 'Footer', parent=styles['Normal'], fontSize=8, textColor=colors.grey ) processing_metadata = data.get("processing_metadata", {}) footer_text = f""" Generated by YouTube Summarizer | Model: {processing_metadata.get('model', 'N/A')} | Confidence: {data.get('confidence_score', 'N/A')} | Generated: {data.get('export_metadata', {}).get('exported_at', 'N/A')} """ story.append(Paragraph(footer_text, footer_style)) return story def get_file_extension(self) -> str: return "pdf" # backend/services/exporters/json_exporter.py class JSONExporter(BaseExporter): """Export summaries to structured JSON format""" async def export( self, summary_data: Dict[str, Any], template: Optional[str] = None, branding: Optional[Dict[str, Any]] = None ) -> str: """Export to JSON""" data = self._prepare_summary_data(summary_data) # Structure data for JSON export json_data = { "youtube_summarizer_export": { "version": "1.0", "exported_at": data["export_metadata"]["exported_at"] }, "video": { "id": data.get("video_id"), "url": data.get("video_url"), "metadata": data.get("video_metadata", {}) }, "summary": { "text": data.get("summary"), "key_points": data.get("key_points", []), "main_themes": data.get("main_themes", []), "actionable_insights": data.get("actionable_insights", []), "confidence_score": data.get("confidence_score") }, "processing": { "metadata": data.get("processing_metadata", {}), "cost_data": data.get("cost_data", {}), "quality_score": data.get("quality_score") }, "branding": branding } import tempfile with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: json.dump(json_data, f, indent=2, default=str) return f.name def get_file_extension(self) -> str: return "json" ``` ### API Endpoints for Export [Source: docs/architecture.md#export-api] ```python # backend/api/export.py from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends from fastapi.responses import FileResponse from pydantic import BaseModel, Field from typing import List, Optional, Dict, Any from ..services.export_service import ExportService, ExportFormat, ExportRequest, BulkExportRequest router = APIRouter(prefix="/api/export", tags=["export"]) class SingleExportRequest(BaseModel): summary_id: str = Field(..., description="ID of summary to export") format: ExportFormat = Field(..., description="Export format") template: Optional[str] = Field(None, description="Custom template name") include_metadata: bool = Field(True, description="Include processing metadata") custom_branding: Optional[Dict[str, Any]] = Field(None, description="Custom branding options") class BulkExportRequestModel(BaseModel): summary_ids: List[str] = Field(..., description="List of summary IDs to export") formats: List[ExportFormat] = Field(..., description="Export formats") template: Optional[str] = Field(None, description="Custom template name") organize_by: str = Field("format", description="Organization method: format, date, video") include_metadata: bool = Field(True, description="Include processing metadata") custom_branding: Optional[Dict[str, Any]] = Field(None, description="Custom branding options") class ExportResponse(BaseModel): export_id: str status: str download_url: Optional[str] = None file_size_bytes: Optional[int] = None error: Optional[str] = None created_at: Optional[str] = None completed_at: Optional[str] = None @router.post("/single", response_model=ExportResponse) async def export_single_summary( request: SingleExportRequest, export_service: ExportService = Depends() ): """Export single summary to specified format""" try: # Get summary data (this would come from your summary storage) summary_data = await get_summary_data(request.summary_id) if not summary_data: raise HTTPException(status_code=404, detail="Summary not found") export_request = ExportRequest( summary_id=request.summary_id, format=request.format, template=request.template, include_metadata=request.include_metadata, custom_branding=request.custom_branding ) result = await export_service.export_summary(summary_data, export_request) return ExportResponse( export_id=result.export_id, status=result.status.value, download_url=result.download_url, file_size_bytes=result.file_size_bytes, error=result.error, created_at=result.created_at.isoformat() if result.created_at else None, completed_at=result.completed_at.isoformat() if result.completed_at else None ) except Exception as e: raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}") @router.post("/bulk", response_model=ExportResponse) async def export_bulk_summaries( request: BulkExportRequestModel, background_tasks: BackgroundTasks, export_service: ExportService = Depends() ): """Export multiple summaries in bulk""" try: # Get all summary data summaries_data = [] for summary_id in request.summary_ids: summary_data = await get_summary_data(summary_id) if summary_data: summaries_data.append(summary_data) if not summaries_data: raise HTTPException(status_code=404, detail="No valid summaries found") bulk_request = BulkExportRequest( summary_ids=request.summary_ids, formats=request.formats, template=request.template, organize_by=request.organize_by, include_metadata=request.include_metadata, custom_branding=request.custom_branding ) # Process in background for large exports background_tasks.add_task( process_bulk_export_async, summaries_data=summaries_data, request=bulk_request, export_service=export_service ) # Return immediate response with job ID import uuid export_id = str(uuid.uuid4()) return ExportResponse( export_id=export_id, status="processing", created_at=datetime.utcnow().isoformat() ) except Exception as e: raise HTTPException(status_code=500, detail=f"Bulk export failed: {str(e)}") @router.get("/status/{export_id}", response_model=ExportResponse) async def get_export_status( export_id: str, export_service: ExportService = Depends() ): """Get export status and download information""" result = export_service.get_export_status(export_id) if not result: raise HTTPException(status_code=404, detail="Export not found") return ExportResponse( export_id=result.export_id, status=result.status.value, download_url=result.download_url, file_size_bytes=result.file_size_bytes, error=result.error, created_at=result.created_at.isoformat() if result.created_at else None, completed_at=result.completed_at.isoformat() if result.completed_at else None ) @router.get("/download/{export_id}") async def download_export( export_id: str, export_service: ExportService = Depends() ): """Download exported file""" result = export_service.get_export_status(export_id) if not result or not result.file_path: raise HTTPException(status_code=404, detail="Export file not found") if not os.path.exists(result.file_path): raise HTTPException(status_code=404, detail="Export file no longer available") # Determine filename and media type filename = f"summary_export_{export_id}.{result.format.value}" media_type = { ExportFormat.MARKDOWN: "text/markdown", ExportFormat.PDF: "application/pdf", ExportFormat.PLAIN_TEXT: "text/plain", ExportFormat.JSON: "application/json", ExportFormat.HTML: "text/html" }.get(result.format, "application/octet-stream") return FileResponse( path=result.file_path, filename=filename, media_type=media_type ) async def process_bulk_export_async( summaries_data: List[Dict[str, Any]], request: BulkExportRequest, export_service: ExportService ): """Process bulk export in background""" try: result = await export_service.bulk_export_summaries(summaries_data, request) # Could send notification when complete except Exception as e: print(f"Bulk export error: {e}") async def get_summary_data(summary_id: str) -> Optional[Dict[str, Any]]: """Retrieve summary data by ID - placeholder for actual implementation""" # This would integrate with your summary storage system return None ``` ### Frontend Export Interface [Source: docs/architecture.md#frontend-export] ```typescript // frontend/src/components/export/ExportModal.tsx import { useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Progress } from '@/components/ui/progress'; import { Download, FileText, File, Code, Globe } from 'lucide-react'; interface ExportModalProps { isOpen: boolean; onClose: () => void; summaryIds: string[]; summaryData?: any; } const exportFormats = [ { value: 'markdown', label: 'Markdown', icon: FileText, description: 'Clean formatting for documentation' }, { value: 'pdf', label: 'PDF', icon: File, description: 'Professional presentation format' }, { value: 'text', label: 'Plain Text', icon: FileText, description: 'Simple text format' }, { value: 'json', label: 'JSON', icon: Code, description: 'Structured data format' }, { value: 'html', label: 'HTML', icon: Globe, description: 'Web-ready format' } ]; export function ExportModal({ isOpen, onClose, summaryIds, summaryData }: ExportModalProps) { const [selectedFormats, setSelectedFormats] = useState(['markdown']); const [includeMetadata, setIncludeMetadata] = useState(true); const [organizeBy, setOrganizeBy] = useState('format'); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(0); const [downloadUrl, setDownloadUrl] = useState(null); const handleFormatToggle = (format: string) => { setSelectedFormats(prev => prev.includes(format) ? prev.filter(f => f !== format) : [...prev, format] ); }; const handleExport = async () => { setIsExporting(true); setExportProgress(0); try { const isBulkExport = summaryIds.length > 1; const requestBody = isBulkExport ? { summary_ids: summaryIds, formats: selectedFormats, organize_by: organizeBy, include_metadata: includeMetadata } : { summary_id: summaryIds[0], format: selectedFormats[0], include_metadata: includeMetadata }; const endpoint = isBulkExport ? '/api/export/bulk' : '/api/export/single'; const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); const result = await response.json(); if (result.status === 'completed') { setDownloadUrl(result.download_url); setExportProgress(100); } else { // Poll for completion await pollExportStatus(result.export_id); } } catch (error) { console.error('Export failed:', error); } finally { setIsExporting(false); } }; const pollExportStatus = async (exportId: string) => { const pollInterval = setInterval(async () => { try { const response = await fetch(`/api/export/status/${exportId}`); const status = await response.json(); if (status.status === 'completed') { setDownloadUrl(status.download_url); setExportProgress(100); clearInterval(pollInterval); } else if (status.status === 'failed') { console.error('Export failed:', status.error); clearInterval(pollInterval); } else { // Update progress (estimated based on time) setExportProgress(prev => Math.min(prev + 10, 90)); } } catch (error) { console.error('Status polling error:', error); clearInterval(pollInterval); } }, 2000); // Cleanup after 5 minutes setTimeout(() => clearInterval(pollInterval), 300000); }; const handleDownload = () => { if (downloadUrl) { window.open(downloadUrl, '_blank'); } }; return ( Export {summaryIds.length === 1 ? 'Summary' : `${summaryIds.length} Summaries`}
{/* Format Selection */}

Export Formats

{exportFormats.map(format => { const Icon = format.icon; return (
handleFormatToggle(format.value)} > handleFormatToggle(format.value)} />
{format.label}
{format.description}
); })}
{/* Options */}
{summaryIds.length > 1 && (
)}
{/* Progress */} {isExporting && (
Exporting... {exportProgress}%
)} {/* Actions */}
{downloadUrl ? ( ) : ( )}
); } ``` ### Performance and Quality Features - **Efficient Processing**: Streaming export generation prevents memory issues with large datasets - **Template System**: Customizable branding and formatting for professional presentations - **Bulk Operations**: Organized multi-format exports with compression for easy distribution - **Progress Tracking**: Real-time feedback for long-running export operations - **Format Optimization**: Each format optimized for its intended use case and audience ## Change Log | Date | Version | Description | Author | |------|---------|-------------|--------| | 2025-01-25 | 1.0 | Initial story creation | Bob (Scrum Master) | ## Dev Agent Record *This section will be populated by the development agent during implementation* ## QA Results *Results from QA Agent review of the completed story implementation will be added here*