youtube-summarizer/docs/stories/2.5.export-functionality.md

1069 lines
38 KiB
Markdown

# 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"• <b>{theme}</b>", 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<string[]>(['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<string | null>(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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
Export {summaryIds.length === 1 ? 'Summary' : `${summaryIds.length} Summaries`}
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Format Selection */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Export Formats</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{exportFormats.map(format => {
const Icon = format.icon;
return (
<div
key={format.value}
className={`flex items-start space-x-3 p-3 border rounded-lg cursor-pointer transition-colors ${
selectedFormats.includes(format.value)
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => handleFormatToggle(format.value)}
>
<Checkbox
checked={selectedFormats.includes(format.value)}
onChange={() => handleFormatToggle(format.value)}
/>
<Icon className="w-5 h-5 text-gray-500 mt-0.5" />
<div>
<div className="font-medium text-sm">{format.label}</div>
<div className="text-xs text-gray-500">{format.description}</div>
</div>
</div>
);
})}
</div>
</div>
{/* Options */}
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
checked={includeMetadata}
onCheckedChange={setIncludeMetadata}
/>
<label className="text-sm font-medium">Include processing metadata</label>
</div>
{summaryIds.length > 1 && (
<div className="space-y-2">
<label className="text-sm font-medium">Organize exports by:</label>
<Select value={organizeBy} onValueChange={setOrganizeBy}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="format">File Format</SelectItem>
<SelectItem value="date">Creation Date</SelectItem>
<SelectItem value="video">Video Title</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Progress */}
{isExporting && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Exporting...</span>
<span>{exportProgress}%</span>
</div>
<Progress value={exportProgress} className="w-full" />
</div>
)}
{/* Actions */}
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={onClose} disabled={isExporting}>
Cancel
</Button>
{downloadUrl ? (
<Button onClick={handleDownload}>
<Download className="w-4 h-4 mr-2" />
Download
</Button>
) : (
<Button
onClick={handleExport}
disabled={selectedFormats.length === 0 || isExporting}
>
{isExporting ? 'Exporting...' : 'Start Export'}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
```
### 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*