Refacto and add cp paste btn

This commit is contained in:
pjmalandrino 2026-03-21 15:03:10 +01:00
parent 7bb4f8e298
commit 2db251182f
23 changed files with 262 additions and 283 deletions

View file

@ -41,13 +41,16 @@ COPY --from=frontend-build /build/dist /usr/share/nginx/html
# Nginx config
COPY nginx.conf /etc/nginx/sites-enabled/default
# Non-root user
RUN useradd --create-home --shell /bin/bash appuser
# Data directories
RUN mkdir -p /app/uploads /app/data
RUN mkdir -p /app/uploads /app/data && chown -R appuser:appuser /app
ENV UPLOAD_DIR=/app/uploads
ENV DB_PATH=/app/data/docling_studio.db
EXPOSE 3000
# Start both nginx and uvicorn
CMD ["sh", "-c", "nginx && exec uvicorn main:app --host 127.0.0.1 --port 8000"]
# nginx needs to run as root for port binding, then drops to appuser for uvicorn
CMD ["sh", "-c", "nginx && exec su appuser -c 'uvicorn main:app --host 127.0.0.1 --port 8000'"]

View file

@ -2,8 +2,8 @@ services:
document-parser:
build:
context: ./document-parser
ports:
- "8000:8000"
expose:
- "8000"
volumes:
- uploads_data:/app/uploads
- db_data:/app/data

View file

@ -13,11 +13,14 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/uploads /app/data
RUN useradd --create-home --shell /bin/bash appuser \
&& mkdir -p /app/uploads /app/data \
&& chown -R appuser:appuser /app
EXPOSE 8000
ENV UPLOAD_DIR=/app/uploads
ENV DB_PATH=/app/data/docling_studio.db
USER appuser
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -31,6 +31,10 @@ async def upload(file: UploadFile):
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
# Reject early if Content-Length exceeds limit (before reading body)
if file.size and file.size > document_service.MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
content = await file.read()
try:

View file

@ -38,6 +38,10 @@ logger = logging.getLogger(__name__)
# Thread lock — DocumentConverter is not thread-safe
_converter_lock = threading.Lock()
# US Letter page dimensions (points) — fallback when page size is unknown
_DEFAULT_PAGE_WIDTH = 612.0
_DEFAULT_PAGE_HEIGHT = 792.0
# Default converter (lazy-init on first request)
_default_converter: DocumentConverter | None = None
@ -62,6 +66,23 @@ class PageDetail:
elements: list[PageElement] = field(default_factory=list)
@dataclass
class ConversionOptions:
do_ocr: bool = True
do_table_structure: bool = True
table_mode: str = "accurate"
do_code_enrichment: bool = False
do_formula_enrichment: bool = False
do_picture_classification: bool = False
do_picture_description: bool = False
generate_picture_images: bool = False
generate_page_images: bool = False
images_scale: float = 1.0
def is_default(self) -> bool:
return self == ConversionOptions()
@dataclass
class ConversionResult:
page_count: int
@ -102,35 +123,26 @@ def _get_element_type(item: DocItem) -> str:
# Pipeline factory
# ---------------------------------------------------------------------------
def build_converter(
do_ocr: bool = True,
do_table_structure: bool = True,
table_mode: str = "accurate",
do_code_enrichment: bool = False,
do_formula_enrichment: bool = False,
do_picture_classification: bool = False,
do_picture_description: bool = False,
generate_picture_images: bool = False,
generate_page_images: bool = False,
images_scale: float = 1.0,
) -> DocumentConverter:
def build_converter(options: ConversionOptions | None = None) -> DocumentConverter:
"""Build a DocumentConverter with the given pipeline options."""
opts = options or ConversionOptions()
table_options = TableStructureOptions(
do_cell_matching=True,
mode=TableFormerMode.ACCURATE if table_mode == "accurate" else TableFormerMode.FAST,
mode=TableFormerMode.ACCURATE if opts.table_mode == "accurate" else TableFormerMode.FAST,
)
pipeline_options = PdfPipelineOptions(
do_ocr=do_ocr,
do_table_structure=do_table_structure,
do_ocr=opts.do_ocr,
do_table_structure=opts.do_table_structure,
table_structure_options=table_options,
do_code_enrichment=do_code_enrichment,
do_formula_enrichment=do_formula_enrichment,
do_picture_classification=do_picture_classification,
do_picture_description=do_picture_description,
generate_page_images=generate_page_images,
generate_picture_images=generate_picture_images,
images_scale=images_scale,
do_code_enrichment=opts.do_code_enrichment,
do_formula_enrichment=opts.do_formula_enrichment,
do_picture_classification=opts.do_picture_classification,
do_picture_description=opts.do_picture_description,
generate_page_images=opts.generate_page_images,
generate_picture_images=opts.generate_picture_images,
images_scale=opts.images_scale,
)
return DocumentConverter(
@ -191,7 +203,7 @@ def _process_content_item(
try:
page_no = prov.page_no
if page_no not in pages:
pages[page_no] = PageDetail(page_number=page_no, width=612.0, height=792.0)
pages[page_no] = PageDetail(page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT)
page_height = pages[page_no].height
@ -205,13 +217,13 @@ def _process_content_item(
if isinstance(item, TableItem):
try:
content = item.export_to_markdown()
except Exception:
except (AttributeError, ValueError):
pass
pages[page_no].elements.append(
PageElement(type=element_type, bbox=bbox, content=content, level=level)
)
except Exception:
except (AttributeError, KeyError, TypeError, ValueError):
logger.warning(
"Skipping item %s on page %s",
type(item).__name__,
@ -227,77 +239,54 @@ def _process_content_item(
# Main conversion entry point
# ---------------------------------------------------------------------------
def _select_converter(options: ConversionOptions) -> DocumentConverter:
"""Return the cached default converter or build a custom one."""
if options.is_default():
return get_default_converter()
return build_converter(options)
def _build_fallback_pages(doc, page_count: int) -> list[PageDetail]:
"""Create empty PageDetail entries when extraction yields nothing."""
return [
PageDetail(
page_number=i + 1,
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
height=doc.pages[i + 1].size.height if (i + 1) in doc.pages else _DEFAULT_PAGE_HEIGHT,
)
for i in range(page_count)
]
def convert_document(
file_path: str,
*,
do_ocr: bool = True,
do_table_structure: bool = True,
table_mode: str = "accurate",
do_code_enrichment: bool = False,
do_formula_enrichment: bool = False,
do_picture_classification: bool = False,
do_picture_description: bool = False,
generate_picture_images: bool = False,
generate_page_images: bool = False,
images_scale: float = 1.0,
options: ConversionOptions | None = None,
) -> ConversionResult:
"""Convert a document and return structured results.
This is the main entry point for document parsing. Runs synchronously
(caller should use asyncio.to_thread for non-blocking execution).
"""
# Use cached default converter only when all options match defaults
is_default = (
do_ocr and do_table_structure and table_mode == "accurate"
and not do_code_enrichment and not do_formula_enrichment
and not do_picture_classification and not do_picture_description
and not generate_picture_images and not generate_page_images
and images_scale == 1.0
)
opts = options or ConversionOptions()
with _converter_lock:
if is_default:
conv = get_default_converter()
else:
conv = build_converter(
do_ocr=do_ocr,
do_table_structure=do_table_structure,
table_mode=table_mode,
do_code_enrichment=do_code_enrichment,
do_formula_enrichment=do_formula_enrichment,
do_picture_classification=do_picture_classification,
do_picture_description=do_picture_description,
generate_picture_images=generate_picture_images,
generate_page_images=generate_page_images,
images_scale=images_scale,
)
conv = _select_converter(opts)
result = conv.convert(file_path)
doc = result.document
content_markdown = doc.export_to_markdown()
content_html = doc.export_to_html()
page_count = len(doc.pages)
pages_detail, skipped = extract_pages_detail(result)
if not pages_detail and page_count > 0:
pages_detail = [
PageDetail(
page_number=i + 1,
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else 612.0,
height=doc.pages[i + 1].size.height if (i + 1) in doc.pages else 792.0,
)
for i in range(page_count)
]
pages_detail = _build_fallback_pages(doc, page_count)
if skipped > 0:
logger.info("Parsed: %d pages, %d items skipped", page_count, skipped)
return ConversionResult(
page_count=page_count or len(pages_detail) or 1,
content_markdown=content_markdown,
content_html=content_html,
content_markdown=doc.export_to_markdown(),
content_html=doc.export_to_html(),
pages=pages_detail,
skipped_items=skipped,
)

View file

@ -48,8 +48,8 @@ app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in allowed_origins],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
)
# Mount routers

View file

@ -35,6 +35,10 @@ CREATE TABLE IF NOT EXISTS analysis_jobs (
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_analysis_jobs_status ON analysis_jobs(status);
CREATE INDEX IF NOT EXISTS idx_analysis_jobs_created_at ON analysis_jobs(created_at);
CREATE INDEX IF NOT EXISTS idx_documents_created_at ON documents(created_at);
"""

View file

@ -8,7 +8,7 @@ import logging
from dataclasses import asdict
from domain.models import AnalysisJob
from domain.parsing import ConversionResult, convert_document
from domain.parsing import ConversionOptions, ConversionResult, convert_document
from persistence import analysis_repo, document_repo
logger = logging.getLogger(__name__)
@ -43,7 +43,7 @@ def _on_task_done(task: asyncio.Task) -> None:
return
exc = task.exception()
if exc:
logger.error("Unhandled exception in analysis task: %s", exc, exc_info=exc)
logger.error("Unhandled exception in analysis task: %s", exc, exc_info=True)
async def find_all() -> list[AnalysisJob]:
@ -72,12 +72,12 @@ async def _run_analysis(
await analysis_repo.update_status(job)
logger.info("Analysis started: %s (file: %s)", job_id, filename)
# Build kwargs from pipeline options
convert_kwargs = pipeline_options or {}
# Build conversion options from pipeline dict
options = ConversionOptions(**(pipeline_options or {}))
# Run blocking Docling conversion in a thread with timeout
result: ConversionResult = await asyncio.wait_for(
asyncio.to_thread(convert_document, file_path, **convert_kwargs),
asyncio.to_thread(convert_document, file_path, options),
timeout=CONVERSION_TIMEOUT,
)

View file

@ -32,7 +32,8 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum
os.makedirs(UPLOAD_DIR, exist_ok=True)
safe_name = f"{uuid.uuid4()}_{filename}"
ext = os.path.splitext(filename)[1] or ".pdf"
safe_name = f"{uuid.uuid4()}{ext}"
file_path = os.path.join(UPLOAD_DIR, safe_name)
with open(file_path, "wb") as f:

View file

@ -12,7 +12,7 @@ from docling.datamodel.pipeline_options import (
TableFormerMode,
)
from domain.parsing import build_converter, convert_document, get_default_converter
from domain.parsing import ConversionOptions, build_converter, convert_document, get_default_converter
# ---------------------------------------------------------------------------
@ -42,62 +42,62 @@ class TestBuildConverter:
assert opts.images_scale == 1.0
def test_ocr_disabled(self):
conv = build_converter(do_ocr=False)
conv = build_converter(ConversionOptions(do_ocr=False))
opts = self._get_pipeline_options(conv)
assert opts.do_ocr is False
def test_table_mode_fast(self):
conv = build_converter(table_mode="fast")
conv = build_converter(ConversionOptions(table_mode="fast"))
opts = self._get_pipeline_options(conv)
assert opts.table_structure_options.mode == TableFormerMode.FAST
def test_table_mode_accurate(self):
conv = build_converter(table_mode="accurate")
conv = build_converter(ConversionOptions(table_mode="accurate"))
opts = self._get_pipeline_options(conv)
assert opts.table_structure_options.mode == TableFormerMode.ACCURATE
def test_table_structure_disabled(self):
conv = build_converter(do_table_structure=False)
conv = build_converter(ConversionOptions(do_table_structure=False))
opts = self._get_pipeline_options(conv)
assert opts.do_table_structure is False
def test_code_enrichment_enabled(self):
conv = build_converter(do_code_enrichment=True)
conv = build_converter(ConversionOptions(do_code_enrichment=True))
opts = self._get_pipeline_options(conv)
assert opts.do_code_enrichment is True
def test_formula_enrichment_enabled(self):
conv = build_converter(do_formula_enrichment=True)
conv = build_converter(ConversionOptions(do_formula_enrichment=True))
opts = self._get_pipeline_options(conv)
assert opts.do_formula_enrichment is True
def test_picture_classification_enabled(self):
conv = build_converter(do_picture_classification=True)
conv = build_converter(ConversionOptions(do_picture_classification=True))
opts = self._get_pipeline_options(conv)
assert opts.do_picture_classification is True
def test_picture_description_enabled(self):
conv = build_converter(do_picture_description=True)
conv = build_converter(ConversionOptions(do_picture_description=True))
opts = self._get_pipeline_options(conv)
assert opts.do_picture_description is True
def test_generate_picture_images(self):
conv = build_converter(generate_picture_images=True)
conv = build_converter(ConversionOptions(generate_picture_images=True))
opts = self._get_pipeline_options(conv)
assert opts.generate_picture_images is True
def test_generate_page_images(self):
conv = build_converter(generate_page_images=True)
conv = build_converter(ConversionOptions(generate_page_images=True))
opts = self._get_pipeline_options(conv)
assert opts.generate_page_images is True
def test_images_scale(self):
conv = build_converter(images_scale=2.0)
conv = build_converter(ConversionOptions(images_scale=2.0))
opts = self._get_pipeline_options(conv)
assert opts.images_scale == 2.0
def test_all_options_combined(self):
conv = build_converter(
conv = build_converter(ConversionOptions(
do_ocr=False,
do_table_structure=True,
table_mode="fast",
@ -108,7 +108,7 @@ class TestBuildConverter:
generate_picture_images=True,
generate_page_images=True,
images_scale=1.5,
)
))
opts = self._get_pipeline_options(conv)
assert opts.do_ocr is False
assert opts.do_table_structure is True
@ -158,7 +158,7 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", do_ocr=False)
convert_document("/tmp/test.pdf", ConversionOptions(do_ocr=False))
mock_build.assert_called_once()
mock_get_default.assert_not_called()
@ -175,10 +175,10 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", table_mode="fast")
opts = ConversionOptions(table_mode="fast")
convert_document("/tmp/test.pdf", opts)
mock_build.assert_called_once()
assert mock_build.call_args.kwargs["table_mode"] == "fast"
mock_build.assert_called_once_with(opts)
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
@ -192,10 +192,10 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", do_code_enrichment=True)
opts = ConversionOptions(do_code_enrichment=True)
convert_document("/tmp/test.pdf", opts)
mock_build.assert_called_once()
assert mock_build.call_args.kwargs["do_code_enrichment"] is True
mock_build.assert_called_once_with(opts)
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
@ -209,7 +209,7 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", do_formula_enrichment=True)
convert_document("/tmp/test.pdf", ConversionOptions(do_formula_enrichment=True))
mock_build.assert_called_once()
@ -225,7 +225,7 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", do_picture_classification=True)
convert_document("/tmp/test.pdf", ConversionOptions(do_picture_classification=True))
mock_build.assert_called_once()
@ -241,7 +241,7 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", generate_picture_images=True)
convert_document("/tmp/test.pdf", ConversionOptions(generate_picture_images=True))
mock_build.assert_called_once()
@ -257,10 +257,10 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", images_scale=2.0)
opts = ConversionOptions(images_scale=2.0)
convert_document("/tmp/test.pdf", opts)
mock_build.assert_called_once()
assert mock_build.call_args.kwargs["images_scale"] == 2.0
mock_build.assert_called_once_with(opts)
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
@ -274,8 +274,7 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document(
"/tmp/test.pdf",
opts = ConversionOptions(
do_ocr=False,
do_table_structure=False,
table_mode="fast",
@ -287,19 +286,9 @@ class TestConvertDocumentRouting:
generate_page_images=True,
images_scale=1.5,
)
convert_document("/tmp/test.pdf", opts)
mock_build.assert_called_once_with(
do_ocr=False,
do_table_structure=False,
table_mode="fast",
do_code_enrichment=True,
do_formula_enrichment=True,
do_picture_classification=True,
do_picture_description=True,
generate_picture_images=True,
generate_page_images=True,
images_scale=1.5,
)
mock_build.assert_called_once_with(opts)
# ---------------------------------------------------------------------------
@ -392,18 +381,15 @@ class TestServiceForwardsPipelineOptions:
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts)
mock_convert.assert_called_once_with(
"/tmp/test.pdf",
do_ocr=False,
table_mode="fast",
do_code_enrichment=True,
do_formula_enrichment=False,
do_picture_classification=False,
do_picture_description=False,
generate_picture_images=True,
generate_page_images=False,
images_scale=2.0,
)
mock_convert.assert_called_once()
call_args = mock_convert.call_args
assert call_args[0][0] == "/tmp/test.pdf"
conv_opts = call_args[0][1]
assert conv_opts.do_ocr is False
assert conv_opts.table_mode == "fast"
assert conv_opts.do_code_enrichment is True
assert conv_opts.generate_picture_images is True
assert conv_opts.images_scale == 2.0
@patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo")
@ -428,8 +414,11 @@ class TestServiceForwardsPipelineOptions:
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
# Called with file_path only (no kwargs spread from empty dict)
mock_convert.assert_called_once_with("/tmp/test.pdf")
# Called with file_path and default ConversionOptions
mock_convert.assert_called_once()
call_args = mock_convert.call_args
assert call_args[0][0] == "/tmp/test.pdf"
assert call_args[0][1] == ConversionOptions()
@patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo")

View file

@ -2,7 +2,7 @@ FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npm ci
COPY . .
RUN npm run build

View file

@ -6,6 +6,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
const analyses = ref([])
const currentAnalysis = ref(null)
const running = ref(false)
const error = ref(null)
const pollingInterval = ref(null)
const currentPages = computed(() => {
@ -17,16 +18,21 @@ export const useAnalysisStore = defineStore('analysis', () => {
}
})
function clearError() { error.value = null }
async function load() {
try {
error.value = null
analyses.value = await api.fetchAnalyses()
} catch (e) {
error.value = e.message || 'Failed to load analyses'
console.error('Failed to load analyses', e)
}
}
async function run(documentId, pipelineOptions = null) {
running.value = true
error.value = null
try {
const analysis = await api.createAnalysis(documentId, pipelineOptions)
currentAnalysis.value = analysis
@ -35,6 +41,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
return analysis
} catch (e) {
running.value = false
error.value = e.message || 'Failed to start analysis'
console.error('Failed to start analysis', e)
throw e
}
@ -46,7 +53,6 @@ export const useAnalysisStore = defineStore('analysis', () => {
try {
const updated = await api.fetchAnalysis(id)
currentAnalysis.value = updated
// Update in list
const idx = analyses.value.findIndex(a => a.id === id)
if (idx !== -1) analyses.value[idx] = updated
if (updated.status === 'COMPLETED' || updated.status === 'FAILED') {
@ -54,6 +60,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
running.value = false
}
} catch (e) {
error.value = e.message || 'Polling error'
console.error('Polling error', e)
stopPolling()
running.value = false
@ -72,6 +79,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
try {
currentAnalysis.value = await api.fetchAnalysis(id)
} catch (e) {
error.value = e.message || 'Failed to load analysis'
console.error('Failed to load analysis', e)
}
}
@ -82,9 +90,10 @@ export const useAnalysisStore = defineStore('analysis', () => {
analyses.value = analyses.value.filter(a => a.id !== id)
if (currentAnalysis.value?.id === id) currentAnalysis.value = null
} catch (e) {
error.value = e.message || 'Failed to delete analysis'
console.error('Failed to delete analysis', e)
}
}
return { analyses, currentAnalysis, currentPages, running, load, run, select, remove, stopPolling }
return { analyses, currentAnalysis, currentPages, running, error, clearError, load, run, select, remove, stopPolling }
})

View file

@ -36,6 +36,20 @@
{{ el.type }}
</span>
<span class="element-level" v-if="el.level">L{{ el.level }}</span>
<button
v-if="el.content"
class="copy-btn copy-btn-element"
:title="t('results.copy')"
@click.stop="copyElement(idx, el.content)"
>
<svg v-if="!copiedElements[idx]" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"/>
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"/>
</svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</button>
</div>
<div class="element-content" v-if="el.content">
<MarkdownViewer v-if="el.type === 'table'" :content="el.content" />
@ -50,6 +64,15 @@
<!-- RAW MARKDOWN -->
<div v-else-if="activeTab === 'markdown'" class="raw-markdown">
<button class="copy-btn copy-btn-block" :title="t('results.copy')" @click="copyMarkdown">
<svg v-if="!copiedMarkdown" viewBox="0 0 20 20" fill="currentColor" class="copy-icon">
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"/>
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z"/>
</svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="copy-icon copied">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</button>
<pre class="raw-content">{{ pageMarkdown }}</pre>
</div>
@ -74,7 +97,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, reactive } from 'vue'
import { useAnalysisStore } from '../store.js'
import MarkdownViewer from './MarkdownViewer.vue'
import ImageGallery from './ImageGallery.vue'
@ -158,6 +181,26 @@ function formatElement(el) {
return `${indent}${el.content}`
}
}
// --- Copy to clipboard ---
const copiedMarkdown = ref(false)
const copiedElements = reactive({})
async function copyMarkdown() {
try {
await navigator.clipboard.writeText(pageMarkdown.value)
copiedMarkdown.value = true
setTimeout(() => { copiedMarkdown.value = false }, 1500)
} catch { /* clipboard not available */ }
}
async function copyElement(idx, content) {
try {
await navigator.clipboard.writeText(content)
copiedElements[idx] = true
setTimeout(() => { copiedElements[idx] = false }, 1500)
} catch { /* clipboard not available */ }
}
</script>
<style scoped>
@ -304,9 +347,53 @@ function formatElement(el) {
color: var(--text-muted);
}
/* --- Copy button --- */
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-muted);
transition: all var(--transition);
padding: 4px;
}
.copy-btn:hover {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-muted);
}
.copy-icon { width: 14px; height: 14px; }
.copy-icon.copied { color: var(--success); }
.copy-btn-element {
margin-left: auto;
opacity: 0;
transition: opacity var(--transition);
}
.element-card:hover .copy-btn-element { opacity: 1; }
.copy-btn-block {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
background: var(--bg-surface);
opacity: 0;
transition: opacity var(--transition);
}
.raw-markdown:hover .copy-btn-block { opacity: 1; }
/* --- Raw markdown --- */
.raw-markdown {
height: 100%;
position: relative;
}
.raw-content {

View file

@ -6,23 +6,30 @@ export const useDocumentStore = defineStore('document', () => {
const documents = ref([])
const selectedId = ref(null)
const uploading = ref(false)
const error = ref(null)
function clearError() { error.value = null }
async function load() {
try {
error.value = null
documents.value = await api.fetchDocuments()
} catch (e) {
error.value = e.message || 'Failed to load documents'
console.error('Failed to load documents', e)
}
}
async function upload(file) {
uploading.value = true
error.value = null
try {
const doc = await api.uploadDocument(file)
documents.value.unshift(doc)
selectedId.value = doc.id
return doc
} catch (e) {
error.value = e.message || 'Failed to upload document'
console.error('Failed to upload document', e)
throw e
} finally {
@ -36,6 +43,7 @@ export const useDocumentStore = defineStore('document', () => {
documents.value = documents.value.filter(d => d.id !== id)
if (selectedId.value === id) selectedId.value = null
} catch (e) {
error.value = e.message || 'Failed to delete document'
console.error('Failed to delete document', e)
}
}
@ -44,5 +52,5 @@ export const useDocumentStore = defineStore('document', () => {
selectedId.value = id
}
return { documents, selectedId, uploading, load, upload, remove, select }
return { documents, selectedId, uploading, error, clearError, load, upload, remove, select }
})

View file

@ -1,9 +0,0 @@
import { apiFetch } from '../../shared/api/http.js'
export function fetchAnalyses() {
return apiFetch('/api/analyses')
}
export function deleteAnalysis(id) {
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
}

View file

@ -1,32 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchAnalyses, deleteAnalysis } from './api.js'
vi.mock('../../shared/api/http.js', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '../../shared/api/http.js'
describe('history API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetchAnalyses calls GET /api/analyses', async () => {
const data = [{ id: '1' }, { id: '2' }]
apiFetch.mockResolvedValue(data)
const result = await fetchAnalyses()
expect(apiFetch).toHaveBeenCalledWith('/api/analyses')
expect(result).toEqual(data)
})
it('deleteAnalysis calls DELETE /api/analyses/:id', async () => {
apiFetch.mockResolvedValue(null)
await deleteAnalysis('5')
expect(apiFetch).toHaveBeenCalledWith('/api/analyses/5', { method: 'DELETE' })
})
})

View file

@ -7,11 +7,6 @@ vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('./api.js', () => ({
fetchAnalyses: vi.fn(),
deleteAnalysis: vi.fn(),
}))
vi.mock('../analysis/api.js', () => ({
fetchAnalyses: vi.fn(),
fetchAnalysis: vi.fn(),
@ -38,7 +33,7 @@ describe('History → Studio navigation', () => {
describe('History store provides data for navigation', () => {
it('analyses contain documentId for document selection', async () => {
const { fetchAnalyses } = await import('./api.js')
const { fetchAnalyses } = await import('../analysis/api.js')
fetchAnalyses.mockResolvedValue([
{ id: 'a1', documentId: 'd1', documentFilename: 'test.pdf', status: 'COMPLETED' },
{ id: 'a2', documentId: 'd2', documentFilename: 'other.pdf', status: 'FAILED' },

View file

@ -1,26 +1 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from './api.js'
export const useHistoryStore = defineStore('history', () => {
const analyses = ref([])
async function load() {
try {
analyses.value = await api.fetchAnalyses()
} catch (e) {
console.error('Failed to load history', e)
}
}
async function remove(id) {
try {
await api.deleteAnalysis(id)
analyses.value = analyses.value.filter(a => a.id !== id)
} catch (e) {
console.error('Failed to delete analysis', e)
}
}
return { analyses, load, remove }
})
export { useAnalysisStore as useHistoryStore } from '../analysis/store.js'

View file

@ -1,66 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect } from 'vitest'
import { useHistoryStore } from './store.js'
vi.mock('./api.js', () => ({
fetchAnalyses: vi.fn(),
deleteAnalysis: vi.fn(),
}))
import * as api from './api.js'
import { useAnalysisStore } from '../analysis/store.js'
describe('useHistoryStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('starts with empty analyses', () => {
const store = useHistoryStore()
expect(store.analyses).toEqual([])
})
it('load() fetches analyses', async () => {
const data = [{ id: '1' }, { id: '2' }]
api.fetchAnalyses.mockResolvedValue(data)
const store = useHistoryStore()
await store.load()
expect(store.analyses).toEqual(data)
})
it('load() handles errors gracefully', async () => {
api.fetchAnalyses.mockRejectedValue(new Error('fail'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useHistoryStore()
await store.load()
expect(store.analyses).toEqual([])
})
it('remove() deletes and removes from list', async () => {
api.deleteAnalysis.mockResolvedValue(null)
const store = useHistoryStore()
store.analyses = [{ id: '1' }, { id: '2' }, { id: '3' }]
await store.remove('2')
expect(store.analyses).toEqual([{ id: '1' }, { id: '3' }])
})
it('remove() handles errors gracefully', async () => {
api.deleteAnalysis.mockRejectedValue(new Error('fail'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useHistoryStore()
store.analyses = [{ id: '1' }]
await store.remove('1')
// Should not remove on error
expect(store.analyses).toEqual([{ id: '1' }])
it('is a re-export of useAnalysisStore', () => {
expect(useHistoryStore).toBe(useAnalysisStore)
})
})

View file

@ -32,10 +32,10 @@
<script setup>
import { useRouter } from 'vue-router'
import { useHistoryStore } from '../store.js'
import { useAnalysisStore } from '../../analysis/store.js'
import { useI18n } from '../../../shared/i18n.js'
const store = useHistoryStore()
const store = useAnalysisStore()
const router = useRouter()
const { t } = useI18n()

View file

@ -74,6 +74,8 @@ const messages = {
'results.noMarkdown': 'Pas de contenu markdown',
'results.runAnalysis': 'Lancez une analyse pour voir les résultats',
'results.analysisFailed': "L'analyse a échoué",
'results.copy': 'Copier',
'results.copied': 'Copié !',
'results.page': 'Page',
// Upload
@ -164,6 +166,8 @@ const messages = {
'results.noMarkdown': 'No markdown content',
'results.runAnalysis': 'Run an analysis to see results',
'results.analysisFailed': 'Analysis failed',
'results.copy': 'Copy',
'results.copied': 'Copied!',
'results.page': 'Page',
'upload.drop': 'Drop a PDF here or click to upload',

View file

@ -4,6 +4,12 @@ server {
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
try_files $uri $uri/ /index.html;
}