1069 lines
38 KiB
Markdown
1069 lines
38 KiB
Markdown
# Story 2.5: Export Functionality
|
|
|
|
## Status
|
|
Completed - 2025-08-26
|
|
|
|
## 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
|
|
|
|
- [x] **Task 1: Export Service Architecture** (AC: 1, 4) ✅
|
|
- [x] Create `ExportService` in `backend/services/export_service.py`
|
|
- [x] Implement format-specific exporters (Markdown, PDF, Text, JSON, HTML)
|
|
- [x] Design export data models and metadata inclusion
|
|
- [x] Create export job management with progress tracking
|
|
|
|
- [x] **Task 2: Format-Specific Exporters** (AC: 1, 5) ✅
|
|
- [x] Implement `MarkdownExporter` with clean formatting and structure
|
|
- [x] Create `PDFExporter` using ReportLab with professional layouts
|
|
- [x] Build `PlainTextExporter` with configurable formatting options
|
|
- [x] Develop `JSONExporter` with structured data and metadata
|
|
- [x] Create `HTMLExporter` with responsive design and styling
|
|
|
|
- [x] **Task 3: Template System** (AC: 2, 5) ✅
|
|
- [x] Design template engine for customizable export layouts (Jinja2)
|
|
- [x] Create default templates for each export format
|
|
- [x] Implement branding customization (logos, colors, headers)
|
|
- [x] Add template management and user-defined templates
|
|
|
|
- [x] **Task 4: Bulk Export Functionality** (AC: 3, 6) ✅
|
|
- [x] Implement batch export processing with job queuing
|
|
- [x] Create archive generation (ZIP) with organized folder structure
|
|
- [x] Add export filtering and selection criteria
|
|
- [x] Implement progress tracking for large export operations
|
|
|
|
- [x] **Task 5: API Endpoints and Integration** (AC: 1, 3, 6) ✅
|
|
- [x] Create `/api/export/single` endpoint for individual summary exports
|
|
- [x] Implement `/api/export/bulk` endpoint for batch operations
|
|
- [x] Add `/api/export/status` endpoint for progress monitoring
|
|
- [x] Create download endpoints with secure file serving
|
|
|
|
- [x] **Task 6: Frontend Export Interface** (AC: 1, 2, 3) ✅
|
|
- [x] Build export options modal with format selection (ExportDialog.tsx)
|
|
- [x] Create template customization interface
|
|
- [x] Implement bulk selection and export management (BulkExportDialog.tsx)
|
|
- [x] 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* |