MicroFish/docs/superpowers/plans/2026-04-25-report-pdf-downl...

578 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Report PDF/MD Download Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Afegir descàrrega del report generat en format MD i PDF des del frontend, amb un botó desplegable que apareix quan el report s'ha completat.
**Architecture:** S'estén l'endpoint de descàrrega existent `GET /api/report/<id>/download` amb el paràmetre `?format=md|pdf`. El backend converteix `full_report.md` → HTML (via `markdown`) → PDF (via `PyMuPDF` / `fitz.Story`). El frontend afegeix un botó desplegable a `Step4Report.vue` amb opcions MD i PDF que obren una URL de descàrrega directa.
**Tech Stack:** Python `markdown>=3.6` (nova dep), `PyMuPDF>=1.24.0` (ja instal·lat), Vue 3 SPA.
---
## Arxius afectats
| Arxiu | Canvi |
|---|---|
| `backend/pyproject.toml` | +`"markdown>=3.6"` a dependencies |
| `backend/app/api/report.py` | Ampliar `download_report()` amb `?format` + generar PDF |
| `frontend/src/api/report.js` | +`getReportDownloadUrl(reportId, format)` |
| `frontend/src/components/Step4Report.vue` | +botó desplegable MD/PDF quan `isComplete` |
---
## Task 1: Afegir dependència `markdown` al backend
**Files:**
- Modify: `backend/pyproject.toml`
- [ ] **Step 1: Afegir la dependència**
A `backend/pyproject.toml`, afegir `"markdown>=3.6"` a la llista `dependencies`, just després de `"PyMuPDF>=1.24.0"`:
```toml
# 文件处理
"PyMuPDF>=1.24.0",
"markdown>=3.6",
```
- [ ] **Step 2: Instal·lar la dependència**
```bash
cd backend && uv sync
```
Resultat esperat: `markdown` s'instal·la sense errors.
- [ ] **Step 3: Verificar que s'importa correctament**
```bash
cd backend && uv run python -c "import markdown; print(markdown.__version__)"
```
Resultat esperat: imprimeix una versió ≥ 3.6 (p.ex. `3.7`).
- [ ] **Step 4: Commit**
```bash
git add backend/pyproject.toml backend/uv.lock
git commit -m "chore(deps): add markdown>=3.6 for PDF generation"
```
---
## Task 2: Escriure el test de l'endpoint de descàrrega PDF
**Files:**
- Test: `backend/tests/test_report_download.py`
- [ ] **Step 1: Escriure el test**
Crear el fitxer `backend/tests/test_report_download.py`:
```python
"""Tests for report download endpoint (MD and PDF formats)."""
import io
import os
import json
import tempfile
import pytest
from unittest.mock import patch, MagicMock
from app import create_app
@pytest.fixture
def app():
app = create_app({'TESTING': True})
yield app
@pytest.fixture
def client(app):
return app.test_client()
def _make_mock_report(report_id="report_test123", content="# Test Report\n\nHello **world**."):
mock = MagicMock()
mock.report_id = report_id
mock.markdown_content = content
return mock
def _make_md_file(tmp_path, report_id, content):
md_path = os.path.join(tmp_path, f"{report_id}_full_report.md")
with open(md_path, 'w', encoding='utf-8') as f:
f.write(content)
return md_path
class TestDownloadMD:
def test_download_md_format_param(self, client, tmp_path):
"""?format=md returns a .md file."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download?format=md')
assert resp.status_code == 200
assert 'attachment' in resp.headers.get('Content-Disposition', '')
assert '.md' in resp.headers.get('Content-Disposition', '')
def test_download_default_is_md(self, client, tmp_path):
"""No format param defaults to md."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download')
assert resp.status_code == 200
assert '.md' in resp.headers.get('Content-Disposition', '')
class TestDownloadPDF:
def test_download_pdf_returns_pdf_bytes(self, client, tmp_path):
"""?format=pdf returns a valid PDF file."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download?format=pdf')
assert resp.status_code == 200
assert resp.headers.get('Content-Type', '').startswith('application/pdf')
assert resp.data[:4] == b'%PDF'
def test_download_pdf_report_not_found(self, client):
"""Returns 404 when report does not exist."""
with patch('app.api.report.ReportManager.get_report', return_value=None):
resp = client.get('/api/report/nonexistent/download?format=pdf')
assert resp.status_code == 404
def test_download_pdf_invalid_format(self, client, tmp_path):
"""Returns 400 for unknown format parameter."""
mock_report = _make_mock_report()
md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
resp = client.get(f'/api/report/{mock_report.report_id}/download?format=docx')
assert resp.status_code == 400
```
- [ ] **Step 2: Executar els tests per verificar que fallen**
```bash
cd backend && uv run pytest tests/test_report_download.py -v 2>&1 | head -40
```
Resultat esperat: FAILED (la nova funcionalitat PDF encara no existeix).
---
## Task 3: Implementar la generació de PDF al backend
**Files:**
- Modify: `backend/app/api/report.py` (funció `download_report`, línies 398441)
- [ ] **Step 1: Afegir imports al capdamunt de `report.py`**
Localitza el bloc d'imports a `backend/app/api/report.py` (línies 119) i afegeix:
```python
import io
import tempfile
import markdown as md_lib
import fitz # PyMuPDF
```
Si `import io` ja existeix, no el dupliquis. Afegeix els que faltin just després de `import traceback`.
- [ ] **Step 2: Afegir la funció helper `_generate_pdf_bytes`**
Afegeix aquesta funció just **abans** de `@report_bp.route('/<report_id>/download', ...)` (línia 398):
```python
def _generate_pdf_bytes(markdown_content: str) -> bytes:
"""Convert Markdown string to PDF bytes using PyMuPDF (fitz.Story)."""
html_body = md_lib.markdown(
markdown_content,
extensions=['tables', 'fenced_code']
)
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: sans-serif; font-size: 12pt; line-height: 1.6;
margin: 40px; color: #1a1a1a; }}
h1 {{ font-size: 22pt; border-bottom: 2px solid #333; padding-bottom: 6px; }}
h2 {{ font-size: 16pt; margin-top: 28px; }}
h3 {{ font-size: 13pt; }}
pre {{ background: #f4f4f4; padding: 10px; border-radius: 4px;
font-size: 10pt; overflow-x: auto; }}
code {{ background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-size: 10pt; }}
blockquote {{ border-left: 3px solid #aaa; margin-left: 0;
padding-left: 12px; color: #555; }}
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
th, td {{ border: 1px solid #ccc; padding: 6px 10px; text-align: left; }}
th {{ background: #f0f0f0; }}
</style>
</head>
<body>{html_body}</body>
</html>"""
story = fitz.Story(html)
buf = io.BytesIO()
writer = fitz.DocumentWriter(buf)
mediabox = fitz.paper_rect("a4")
where = mediabox + (36, 36, -36, -36) # margins
more = True
while more:
device = writer.begin_page(mediabox)
more, _ = story.place(where)
story.draw(device)
writer.end_page()
writer.close()
return buf.getvalue()
```
- [ ] **Step 3: Substituir la funció `download_report` completa**
Substitueix tot el contingut de `download_report` (línies 398441) per:
```python
@report_bp.route('/<report_id>/download', methods=['GET'])
def download_report(report_id: str):
"""
Download report in the requested format.
Query params:
format: 'md' (default) | 'pdf'
"""
try:
fmt = request.args.get('format', 'md').lower()
if fmt not in ('md', 'pdf'):
return jsonify({
"success": False,
"error": f"Unsupported format '{fmt}'. Use 'md' or 'pdf'."
}), 400
report = ReportManager.get_report(report_id)
if not report:
return jsonify({
"success": False,
"error": t('api.reportNotFound', id=report_id)
}), 404
md_path = ReportManager._get_report_markdown_path(report_id)
if os.path.exists(md_path):
with open(md_path, 'r', encoding='utf-8') as f:
markdown_content = f.read()
else:
markdown_content = report.markdown_content
if fmt == 'md':
if os.path.exists(md_path):
return send_file(
md_path,
as_attachment=True,
download_name=f"{report_id}.md"
)
with tempfile.NamedTemporaryFile(mode='w', suffix='.md',
delete=False, encoding='utf-8') as f:
f.write(markdown_content)
temp_path = f.name
return send_file(temp_path, as_attachment=True,
download_name=f"{report_id}.md")
# fmt == 'pdf'
pdf_bytes = _generate_pdf_bytes(markdown_content)
return send_file(
io.BytesIO(pdf_bytes),
mimetype='application/pdf',
as_attachment=True,
download_name=f"{report_id}.pdf"
)
except Exception as e:
logger.error(f"Failed to download report: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
```
- [ ] **Step 4: Executar els tests**
```bash
cd backend && uv run pytest tests/test_report_download.py -v
```
Resultat esperat: tots els tests en PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/app/api/report.py backend/tests/test_report_download.py
git commit -m "feat(report): add PDF download endpoint via PyMuPDF"
```
---
## Task 4: Afegir helper al frontend API
**Files:**
- Modify: `frontend/src/api/report.js`
- [ ] **Step 1: Afegir `getReportDownloadUrl` al final de `report.js`**
Afegeix al final de `frontend/src/api/report.js`:
```javascript
/**
* Build the direct download URL for a report.
* @param {string} reportId
* @param {'md'|'pdf'} format
* @returns {string} URL absoluta per fer servir com a href de descàrrega
*/
export const getReportDownloadUrl = (reportId, format = 'md') => {
const base = import.meta.env.VITE_API_BASE_URL || ''
return `${base}/api/report/${reportId}/download?format=${format}`
}
```
- [ ] **Step 2: Verificar que el frontend compila sense errors**
```bash
cd frontend && npm run build 2>&1 | tail -20
```
Resultat esperat: build sense errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/api/report.js
git commit -m "feat(report): add getReportDownloadUrl helper"
```
---
## Task 5: Afegir botó desplegable MD/PDF al component Vue
**Files:**
- Modify: `frontend/src/components/Step4Report.vue`
Els canvis s'estructuren en 3 sub-passos: (a) importar la funció, (b) afegir el template, (c) afegir els estils.
- [ ] **Step 1: Afegir l'import de `getReportDownloadUrl`**
A `Step4Report.vue`, localitza la línia d'imports de `report.js`. Busca quelcom com:
```javascript
import { getReport, ... } from '../api/report'
```
Afegeix `getReportDownloadUrl` a la llista d'imports d'aquell fitxer. Si no hi ha imports de `report.js`, afegeix:
```javascript
import { getReportDownloadUrl } from '../api/report'
```
- [ ] **Step 2: Afegir l'estat reactiu del menú**
Localitza la línia `const isComplete = ref(false)` (línia ~427) i afegeix just a sota:
```javascript
const showDownloadMenu = ref(false)
```
- [ ] **Step 3: Afegir la funció de tancament del menú al clicar fora**
Localitza la funció `goToInteraction` (línia ~410) i afegeix just a sobre:
```javascript
const closeDownloadMenu = () => { showDownloadMenu.value = false }
```
- [ ] **Step 4: Afegir el botó desplegable al template**
Localitza el bloc del botó "next step" al template (línia ~130):
```html
<!-- Next Step Button - 在完成后显示 -->
<button v-if="isComplete" class="next-step-btn" @click="goToInteraction">
```
Afegeix el botó desplegable de descàrrega **just abans** d'aquest bloc:
```html
<!-- Download Button - apareix quan el report és complet -->
<div v-if="isComplete" class="download-wrapper" v-click-outside="closeDownloadMenu">
<button class="download-toggle-btn" @click="showDownloadMenu = !showDownloadMenu">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span>{{ $t('step4.downloadReport') }}</span>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" class="chevron-icon" :class="{ open: showDownloadMenu }">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div v-if="showDownloadMenu" class="download-menu">
<a :href="getReportDownloadUrl(reportId, 'md')" download class="download-option" @click="showDownloadMenu = false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<span>Markdown (.md)</span>
</a>
<a :href="getReportDownloadUrl(reportId, 'pdf')" download class="download-option" @click="showDownloadMenu = false">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<span>PDF (.pdf)</span>
</a>
</div>
</div>
```
**Nota:** Vue 3 no té `v-click-outside` built-in. Si el projecte no té aquesta directiva, substitueix `v-click-outside="closeDownloadMenu"` per res i afegeix al `<script setup>` un event listener:
```javascript
// Tanca el menú si es clica fora
import { onMounted, onUnmounted } from 'vue'
const handleClickOutside = (e) => {
if (!e.target.closest('.download-wrapper')) showDownloadMenu.value = false
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
```
- [ ] **Step 5: Afegir les claus i18n**
A `locales/en.json`, dins l'objecte `step4`, afegeix:
```json
"downloadReport": "Download Report"
```
A `locales/zh.json`, dins l'objecte `step4`, afegeix:
```json
"downloadReport": "下载报告"
```
- [ ] **Step 6: Afegir estils CSS**
Al final del bloc `<style>` de `Step4Report.vue` (just abans del tancament `</style>`), afegeix:
```css
/* Download dropdown */
.download-wrapper {
position: relative;
margin: 8px 20px 0 20px;
}
.download-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 11px 20px;
font-size: 13px;
font-weight: 500;
color: #D1D5DB;
background: #111827;
border: 1px solid #374151;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.download-toggle-btn:hover {
background: #1F2937;
color: #F9FAFB;
}
.chevron-icon {
transition: transform 0.2s ease;
margin-left: auto;
}
.chevron-icon.open {
transform: rotate(180deg);
}
.download-menu {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
right: 0;
background: #1F2937;
border: 1px solid #374151;
border-radius: 8px;
overflow: hidden;
z-index: 100;
box-shadow: 0 -4px 12px rgba(0,0,0,0.3);
}
.download-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
font-size: 13px;
color: #D1D5DB;
text-decoration: none;
transition: background 0.15s ease;
}
.download-option:hover {
background: #374151;
color: #F9FAFB;
}
```
- [ ] **Step 7: Verificar que el frontend compila**
```bash
cd frontend && npm run build 2>&1 | tail -20
```
Resultat esperat: build sense errors.
- [ ] **Step 8: Commit**
```bash
git add frontend/src/components/Step4Report.vue locales/en.json locales/zh.json
git commit -m "feat(report): add MD/PDF download dropdown button"
```
---
## Verificació end-to-end
1. Inicia el servidor: `npm run dev` (des de l'arrel del projecte)
2. Genera un report complet en qualsevol projecte existent
3. Quan el report es completa, ha d'aparèixer el botó "Download Report" sobre el botó "Go to Interaction"
4. Fes clic al botó: ha d'aparèixer un menú desplegable amb les opcions "Markdown (.md)" i "PDF (.pdf)"
5. Fes clic a "Markdown (.md)": ha de descarregar-se un fitxer `.md` llegible
6. Fes clic a "PDF (.pdf)": ha de descarregar-se un fitxer `.pdf` que s'obre correctament
7. Tanca el menú clicant fora: ha de tancar-se el desplegable
8. Executa els tests del backend: `cd backend && uv run pytest tests/test_report_download.py -v`