mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-05-31 08:43:37 +00:00
feat: live PDF preview, upload-to-branch diff, and copy markdown per branch
- Backend: authenticated GET preview endpoint generates PDF on-demand from any version without requiring a public asset publish - Backend: POST upload endpoint on a version accepts a .docx, parses it to structured blocks, diffs against current blocks, and records patches - Frontend: new Preview tab shows live PDF rendered from the authenticated endpoint (blob URL via fetch with auth header) - Frontend: Upload DOCX (arrow-up) button in action bar sends the file to the branch, backend computes diff automatically - Frontend: Copy Markdown button (clipboard icon) appears on branch hover in CVTree; copies block path/type/text as structured markdown to clipboard https://claude.ai/code/session_01BTNfDfgFvcnehkve6r66nk
This commit is contained in:
@@ -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(...),
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user