diff --git a/apps/backend/fastapi/app/api/routes/documents.py b/apps/backend/fastapi/app/api/routes/documents.py index 0423a44..5db4c2e 100644 --- a/apps/backend/fastapi/app/api/routes/documents.py +++ b/apps/backend/fastapi/app/api/routes/documents.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db from app.core.config import get_settings -from app.schemas import DocumentListResponse, DocumentResponse +from app.schemas import DocumentListResponse, DocumentResponse, VersionResponse from app.services.documents import ( create_document, delete_document, @@ -14,8 +14,9 @@ from app.services.documents import ( list_documents, ) from app.services.storage import storage_client +from app.services.versions import upload_docx_to_version from dlib.auth import AuthenticatedUser -from dlib.cv import generate_patched_docx +from dlib.cv import docx_bytes_to_pdf, generate_patched_docx router = APIRouter(prefix="/documents", tags=["documents"]) @@ -66,6 +67,50 @@ async def download_version_docx( ) +@router.get("/{document_id}/versions/{version_id}/preview") +async def preview_version_pdf( + document_id: str, + version_id: str, + session: AsyncSession = Depends(get_db), + user: AuthenticatedUser = Depends(get_current_user), +): + document = await get_document(session, owner_id=user.sub, document_id=document_id) + if not document: + raise HTTPException(status_code=404, detail="Document not found") + version = next((v for v in document.versions if v.id == version_id), None) + if not version or not version.artifact_docx_key: + raise HTTPException(status_code=404, detail="Version artifact not found") + original = storage_client.download_bytes(key=version.artifact_docx_key) + patched = generate_patched_docx(original, version.structured_blocks or []) + pdf = docx_bytes_to_pdf(patched) + slug = f"{document.title.replace(' ', '-')}-{version.branch_name}" + return Response( + content=pdf, + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="{slug}.pdf"'}, + ) + + +@router.post("/{document_id}/versions/{version_id}/upload", response_model=VersionResponse) +async def upload_docx_to_branch( + document_id: str, + version_id: str, + file: UploadFile = File(...), + session: AsyncSession = Depends(get_db), + user: AuthenticatedUser = Depends(get_current_user), +): + document = await get_document(session, owner_id=user.sub, document_id=document_id) + if not document: + raise HTTPException(status_code=404, detail="Document not found") + version = next((v for v in document.versions if v.id == version_id), None) + if not version: + raise HTTPException(status_code=404, detail="Version not found") + updated = await upload_docx_to_version(session, owner_id=user.sub, version_id=version_id, upload=file) + if not updated: + raise HTTPException(status_code=404, detail="Version not found") + return VersionResponse.model_validate(updated) + + @router.post("", response_model=DocumentResponse) async def upload_document( title: str = Form(...), diff --git a/apps/backend/fastapi/app/services/versions.py b/apps/backend/fastapi/app/services/versions.py index 50709c0..9ae9e93 100644 --- a/apps/backend/fastapi/app/services/versions.py +++ b/apps/backend/fastapi/app/services/versions.py @@ -1,5 +1,6 @@ from __future__ import annotations +from fastapi import UploadFile from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -8,11 +9,34 @@ from dlib.cv import ( StructuredBlock, StructuredDocument, PatchPayload, + PatchOperation, apply_patchset, validate_patchset, + parse_docx_bytes, ) from app.models import CvDocument, CvPatch, CvVersion, PublicAsset +from app.services.storage import persist_upload + + +def _diff_blocks(old: list[dict], new: list[dict]) -> list[PatchPayload]: + old_map = {b["path"]: b for b in old} + new_map = {b["path"]: b for b in new} + patches: list[PatchPayload] = [] + for path, nb in new_map.items(): + ob = old_map.get(path) + if ob and ob["text"] != nb["text"]: + patches.append(PatchPayload( + target_path=path, operation=PatchOperation.REPLACE_TEXT, + old_value=ob["text"], new_value=nb["text"], + )) + for path, ob in old_map.items(): + if path not in new_map and ob.get("block_type") != "heading": + patches.append(PatchPayload( + target_path=path, operation=PatchOperation.REMOVE_BLOCK, + old_value=ob["text"], + )) + return patches async def create_branch( @@ -153,6 +177,55 @@ async def append_patches_to_version( return result.scalars().one() +async def upload_docx_to_version( + session: AsyncSession, + *, + owner_id: str, + version_id: str, + upload: UploadFile, +) -> CvVersion | None: + stmt = ( + select(CvVersion) + .join(CvVersion.document) + .where(CvVersion.id == version_id, CvDocument.owner_id == owner_id) + .options(selectinload(CvVersion.patches), selectinload(CvVersion.public_assets)) + ) + version = (await session.execute(stmt)).scalars().one_or_none() + if not version: + return None + + artifact_key, file_bytes = await persist_upload(upload, owner_id) + new_blocks_parsed = parse_docx_bytes(file_bytes) + new_blocks = [b.model_dump() for b in new_blocks_parsed.blocks] + + diff_patches = _diff_blocks(version.structured_blocks or [], new_blocks) + + version.artifact_docx_key = artifact_key + version.structured_blocks = new_blocks + metadata = version.metadata_json or {} + metadata["patch_count"] = int(metadata.get("patch_count") or 0) + len(diff_patches) + version.metadata_json = metadata + + for patch in diff_patches: + session.add(CvPatch( + version_id=version.id, + target_path=patch.target_path, + operation=patch.operation.value, + old_value=patch.old_value, + new_value=patch.new_value, + metadata_json=patch.metadata, + )) + + await session.commit() + + stmt_refresh = ( + select(CvVersion) + .where(CvVersion.id == version_id) + .options(selectinload(CvVersion.patches), selectinload(CvVersion.public_assets)) + ) + return (await session.execute(stmt_refresh)).scalars().one() + + async def delete_version( session: AsyncSession, owner_id: str, version_id: str ) -> bool | str: diff --git a/apps/webapp/src/app/dashboard/page.tsx b/apps/webapp/src/app/dashboard/page.tsx index 3681b21..c92b0cb 100644 --- a/apps/webapp/src/app/dashboard/page.tsx +++ b/apps/webapp/src/app/dashboard/page.tsx @@ -1,9 +1,10 @@ 'use client'; import { useEffect, useRef, useState } from 'react'; -import CVTree from '@/components/cv/CVTree'; +import CVTree, { versionToMarkdown } from '@/components/cv/CVTree'; import DiffViewer from '@/components/cv/DiffViewer'; import InsightsPanel from '@/components/cv/InsightsPanel'; +import PDFPreview from '@/components/cv/PDFPreview'; import Link from 'next/link'; import { appendPatches, @@ -21,6 +22,7 @@ import { updateSubmissionStatus, updateSuggestion, uploadDocument, + uploadDocxToBranch, Version, } from '@/libs/api'; import { @@ -576,7 +578,7 @@ function SubmissionsTab({ // ── main dashboard ──────────────────────────────────────────────────────────── type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null; -type Tab = 'content' | 'patches' | 'submissions' | 'insights'; +type Tab = 'content' | 'patches' | 'submissions' | 'insights' | 'preview'; export default function Dashboard() { const [docs, setDocs] = useState([]); @@ -597,6 +599,9 @@ export default function Dashboard() { const [applyLoading, setApplyLoading] = useState(false); const [applyError, setApplyError] = useState(''); const [insights, setInsights] = useState(null); + const [uploadBranchLoading, setUploadBranchLoading] = useState(false); + const [uploadBranchError, setUploadBranchError] = useState(''); + const uploadBranchRef = useRef(null); useEffect(() => { if (IS_DEMO) { @@ -717,6 +722,40 @@ export default function Dashboard() { const discardEdits = () => setPendingEdits(new Map()); + const handleUploadToBranch = async (file: File) => { + if (!selectedDocId || !selectedVersionId) return; + setUploadBranchLoading(true); + setUploadBranchError(''); + try { + await uploadDocxToBranch(selectedDocId, selectedVersionId, file); + await refreshDocs(); + } catch (e: unknown) { + setUploadBranchError(e instanceof Error ? e.message : 'Upload failed'); + } finally { + setUploadBranchLoading(false); + } + }; + + const handleCopyMarkdown = (versionId: string) => { + if (!selectedDoc) return; + const versionMap = new Map(selectedDoc.versions.map(v => [v.id, v])); + const sections = selectedDoc.versions + .filter(v => v.id === versionId || selectedDoc.versions.some(x => x.id === versionId)) + .map(v => versionToMarkdown(v, v.parent_version_id ? (versionMap.get(v.parent_version_id ?? '')?.branch_name) : undefined)); + const md = `# ${selectedDoc.title}\n\n${sections.join('\n\n---\n\n')}`; + navigator.clipboard.writeText(md).catch(() => {}); + }; + + const handleCopyBranchMarkdown = (versionId: string) => { + if (!selectedDoc) return; + const version = selectedDoc.versions.find(v => v.id === versionId); + if (!version) return; + const versionMap = new Map(selectedDoc.versions.map(v => [v.id, v])); + const parentName = version.parent_version_id ? versionMap.get(version.parent_version_id)?.branch_name : undefined; + const md = `# ${selectedDoc.title}\n\n${versionToMarkdown(version, parentName)}`; + navigator.clipboard.writeText(md).catch(() => {}); + }; + const applyStagedEdits = async () => { if (!selectedVersionId || !stagedPatches.length) return; setApplyLoading(true); @@ -899,6 +938,7 @@ export default function Dashboard() { selectedVersionId={selectedVersionId} onSelect={selectVersion} onDeleteVersion={handleDeleteVersion} + onCopyMarkdown={handleCopyBranchMarkdown} /> )} @@ -951,6 +991,7 @@ export default function Dashboard() { versions={selectedDoc.versions} selectedVersionId={selectedVersionId} onSelect={selectVersion} + onCopyMarkdown={handleCopyBranchMarkdown} /> @@ -1011,6 +1052,29 @@ export default function Dashboard() { ↓ DOCX )} + {!IS_DEMO && ( + <> + + { + const f = e.target.files?.[0]; + if (f) handleUploadToBranch(f); + e.target.value = ''; + }} + /> + + )} @@ -1107,9 +1171,20 @@ export default function Dashboard() { )} + {uploadBranchError && ( +
+ {uploadBranchError} + +
+ )} + {/* tabs */}
- {(['content', 'patches', 'submissions', 'insights'] as Tab[]).map(t => ( + {(['content', 'patches', 'submissions', 'insights', 'preview'] as Tab[]).map(t => (
{/* tab content */} -
+
{activeTab === 'content' && ( )} + {activeTab === 'preview' && selectedDoc && ( + IS_DEMO + ?
Preview not available in demo mode.
+ : + )}
)} diff --git a/apps/webapp/src/components/cv/CVTree.tsx b/apps/webapp/src/components/cv/CVTree.tsx index 4091d3e..f5be22e 100644 --- a/apps/webapp/src/components/cv/CVTree.tsx +++ b/apps/webapp/src/components/cv/CVTree.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { MouseEvent, useState } from 'react'; import { Version } from '@/libs/api'; type TreeNode = { version: Version; children: TreeNode[] }; @@ -15,21 +15,38 @@ function buildTree(versions: Version[]): TreeNode | null { return root; } +export function versionToMarkdown(version: Version, parentName?: string): string { + const header = `## ${version.version_label || version.branch_name}${parentName ? ` (from ${parentName})` : ''}`; + const blocks = (version.structured_blocks ?? []).map(b => + `[${b.path}] ${b.block_type}: ${b.text}` + ).join('\n'); + return `${header}\n\n${blocks || '(no blocks)'}`; +} + const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2']; -function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: { +function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, colorIndex = 0 }: { node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void; onDelete?: (id: string) => void; + onCopyMarkdown?: (id: string) => void; colorIndex?: number; }) { const [open, setOpen] = useState(true); const [hovered, setHovered] = useState(false); + const [copied, setCopied] = useState(false); const v = node.version; const isRoot = !v.parent_version_id; const isSelected = v.id === selectedId; const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length]; + const handleCopy = (e: MouseEvent) => { + e.stopPropagation(); + onCopyMarkdown?.(v.id); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + return (
{depth > 0 && ( @@ -93,6 +110,22 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: { )} + {hovered && onCopyMarkdown && ( + + )} + {!isRoot && onDelete && hovered && (