From 5d815cd24d3d0ccc3b35fde7aed60413063634d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:45:54 +0000 Subject: [PATCH] feat: add mobile support, delete CV/branch, and fix DOCX export with patches Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/4d754ed6-7f63-44e0-8689-123d7a70595f Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com> --- .../fastapi/app/api/routes/documents.py | 17 ++- .../fastapi/app/api/routes/versions.py | 17 ++- .../backend/fastapi/app/services/documents.py | 11 ++ apps/backend/fastapi/app/services/versions.py | 25 ++++ apps/webapp/src/app/dashboard/page.tsx | 128 ++++++++++++++---- apps/webapp/src/app/globals.css | 108 +++++++++++++++ apps/webapp/src/components/cv/CVTree.tsx | 48 +++++-- apps/webapp/src/libs/api.ts | 22 +++ dlib/cv/__init__.py | 2 + dlib/cv/docx_export.py | 76 +++++++++++ 10 files changed, 408 insertions(+), 46 deletions(-) create mode 100644 dlib/cv/docx_export.py diff --git a/apps/backend/fastapi/app/api/routes/documents.py b/apps/backend/fastapi/app/api/routes/documents.py index d3c2ec5..3916621 100644 --- a/apps/backend/fastapi/app/api/routes/documents.py +++ b/apps/backend/fastapi/app/api/routes/documents.py @@ -6,9 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db from app.schemas import DocumentListResponse, DocumentResponse -from app.services.documents import create_document, get_document, list_documents +from app.services.documents import create_document, delete_document, get_document, list_documents from app.services.storage import storage_client from dlib.auth import AuthenticatedUser +from dlib.cv import generate_patched_docx router = APIRouter(prefix="/documents", tags=["documents"]) @@ -49,7 +50,8 @@ async def download_version_docx( 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") - data = storage_client.download_bytes(key=version.artifact_docx_key) + original = storage_client.download_bytes(key=version.artifact_docx_key) + data = generate_patched_docx(original, version.structured_blocks or []) slug = f"{document.title.replace(' ', '-')}-{version.branch_name}.docx" return Response( content=data, @@ -74,3 +76,14 @@ async def upload_document( upload=file, ) return DocumentResponse.model_validate(document) + + +@router.delete("/{document_id}", status_code=204) +async def delete_user_document( + document_id: str, + session: AsyncSession = Depends(get_db), + user: AuthenticatedUser = Depends(get_current_user), +): + deleted = await delete_document(session, owner_id=user.sub, document_id=document_id) + if not deleted: + raise HTTPException(status_code=404, detail="Document not found") diff --git a/apps/backend/fastapi/app/api/routes/versions.py b/apps/backend/fastapi/app/api/routes/versions.py index 824c9f6..9699781 100644 --- a/apps/backend/fastapi/app/api/routes/versions.py +++ b/apps/backend/fastapi/app/api/routes/versions.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db from app.schemas import BranchCreateRequest, VersionResponse -from app.services.versions import create_branch +from app.services.versions import create_branch, delete_version from dlib.auth import AuthenticatedUser @@ -29,3 +29,18 @@ async def create_version_branch( if not version: raise HTTPException(status_code=404, detail="Parent version not found") return VersionResponse.model_validate(version) + + +@router.delete("/{version_id}", status_code=204) +async def delete_version_branch( + version_id: str, + session: AsyncSession = Depends(get_db), + user: AuthenticatedUser = Depends(get_current_user), +): + result = await delete_version(session, owner_id=user.sub, version_id=version_id) + if result is False: + raise HTTPException(status_code=404, detail="Version not found") + if result == "root": + raise HTTPException(status_code=400, detail="Cannot delete root version") + if result == "has_children": + raise HTTPException(status_code=409, detail="Delete child branches first") diff --git a/apps/backend/fastapi/app/services/documents.py b/apps/backend/fastapi/app/services/documents.py index ab897d9..6bc0022 100644 --- a/apps/backend/fastapi/app/services/documents.py +++ b/apps/backend/fastapi/app/services/documents.py @@ -68,3 +68,14 @@ async def get_document( ) result = await session.execute(stmt) return result.scalars().unique().one_or_none() + + +async def delete_document( + session: AsyncSession, owner_id: str, document_id: str +) -> bool: + doc = await get_document(session, owner_id, document_id) + if not doc: + return False + await session.delete(doc) + await session.commit() + return True diff --git a/apps/backend/fastapi/app/services/versions.py b/apps/backend/fastapi/app/services/versions.py index 56b188b..36b3cce 100644 --- a/apps/backend/fastapi/app/services/versions.py +++ b/apps/backend/fastapi/app/services/versions.py @@ -82,3 +82,28 @@ async def create_branch( ) result = await session.execute(stmt_refresh) return result.scalars().one() + + +async def delete_version( + session: AsyncSession, owner_id: str, version_id: str +) -> bool | str: + """Delete a non-root branch. Returns False if not found, 'root' if root, True on success.""" + stmt = ( + select(CvVersion) + .join(CvVersion.document) + .where(CvVersion.id == version_id, CvDocument.owner_id == owner_id) + ) + result = await session.execute(stmt) + version = result.scalars().one_or_none() + if not version: + return False + if not version.parent_version_id: + return "root" + # Refuse if child branches exist + child_stmt = select(CvVersion.id).where(CvVersion.parent_version_id == version_id).limit(1) + child_result = await session.execute(child_stmt) + if child_result.scalar_one_or_none(): + return "has_children" + await session.delete(version) + await session.commit() + return True diff --git a/apps/webapp/src/app/dashboard/page.tsx b/apps/webapp/src/app/dashboard/page.tsx index 8a48337..e8aeb20 100644 --- a/apps/webapp/src/app/dashboard/page.tsx +++ b/apps/webapp/src/app/dashboard/page.tsx @@ -5,7 +5,8 @@ import CVTree from '@/components/cv/CVTree'; import DiffViewer from '@/components/cv/DiffViewer'; import Link from 'next/link'; import { - createBranch, createSubmission, Document, downloadVersionUrl, + createBranch, createSubmission, deleteDocument, deleteVersion, + Document, downloadVersionUrl, fetchDocuments, fetchSubmissions, publishVersion, requestAiSuggestions, Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version, } from '@/libs/api'; @@ -482,6 +483,8 @@ export default function Dashboard() { const [submissions, setSubmissions] = useState([]); const [subsLoading, setSubsLoading] = useState(false); const [pendingEdits, setPendingEdits] = useState>(new Map()); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [docHovered, setDocHovered] = useState(null); useEffect(() => { fetchDocuments() @@ -525,6 +528,7 @@ export default function Dashboard() { setSelectedDocId(doc.id); setSelectedVersionId(doc.root_version_id ?? null); setModal(null); + setSidebarOpen(false); }; const onBranchDone = async (v: Version) => { @@ -547,6 +551,41 @@ export default function Dashboard() { const discardEdits = () => setPendingEdits(new Map()); + const handleDeleteDoc = async (docId: string) => { + if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return; + try { + await deleteDocument(docId); + const updated = docs.filter(d => d.id !== docId); + setDocs(updated); + if (selectedDocId === docId) { + setSelectedDocId(updated[0]?.id ?? null); + setSelectedVersionId(updated[0]?.root_version_id ?? null); + } + } catch (e: unknown) { + alert(e instanceof Error ? e.message : 'Delete failed'); + } + }; + + const handleDeleteVersion = async (versionId: string) => { + if (!confirm('Delete this branch? This cannot be undone.')) return; + try { + await deleteVersion(versionId); + const fresh = await refreshDocs(); + if (selectedVersionId === versionId) { + const doc = fresh.find(d => d.id === selectedDocId); + setSelectedVersionId(doc?.root_version_id ?? null); + } + } catch (e: unknown) { + alert(e instanceof Error ? e.message : 'Delete failed'); + } + }; + + const selectVersion = (id: string) => { + setSelectedVersionId(id); + setActiveTab('content'); + setSidebarOpen(false); + }; + const pendingCount = pendingEdits.size; const stagedPatches = [...pendingEdits.entries()].map(([path, { old_value, new_value }]) => ({ target_path: path, operation: 'replace_text', old_value, new_value, @@ -558,15 +597,22 @@ export default function Dashboard() { }; return ( -
+
{/* top bar */} -
- - Resume Branches - +
+
+ + + Resume Branches + +
-
+
+ {/* sidebar overlay on mobile */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + {/* left panel */} -
+
{loading &&
Loading…
} {error &&
{error}
} @@ -602,21 +653,41 @@ export default function Dashboard() { {docs.map(d => (
setDocHovered(d.id)} + onMouseLeave={() => setDocHovered(null)} onClick={() => { setSelectedDocId(d.id); setSelectedVersionId(d.root_version_id ?? null); setActiveTab('content'); + setSidebarOpen(false); }} style={{ padding: '5px 8px', borderRadius: 4, cursor: 'pointer', fontSize: 13, fontWeight: d.id === selectedDocId ? 600 : 400, background: d.id === selectedDocId ? 'var(--selected-bg)' : 'transparent', + display: 'flex', alignItems: 'flex-start', gap: 4, }} > -
{d.title}
-
- {d.versions.length} version{d.versions.length !== 1 ? 's' : ''} +
+
{d.title}
+
+ {d.versions.length} version{d.versions.length !== 1 ? 's' : ''} +
+ {docHovered === d.id && ( + + )}
))}
@@ -629,7 +700,8 @@ export default function Dashboard() { { setSelectedVersionId(id); setActiveTab('content'); }} + onSelect={selectVersion} + onDeleteVersion={handleDeleteVersion} />
)} @@ -638,7 +710,7 @@ export default function Dashboard() {
{/* main panel */} -
+
{!selectedVersion && !loading && (
Select a branch to view details. @@ -648,10 +720,10 @@ export default function Dashboard() { {selectedVersion && ( <> {/* version header */} -
-
-
-

+
+
+
+

{selectedVersion.version_label || selectedVersion.branch_name}

@@ -673,7 +745,7 @@ export default function Dashboard() {
{/* action buttons */} -
+
@@ -700,7 +772,7 @@ export default function Dashboard() { {pendingCount > 0 && (
{pendingCount} staged edit{pendingCount !== 1 ? 's' : ''} @@ -719,7 +791,7 @@ export default function Dashboard() { )} {/* tabs */} -
+
{(['content', 'patches', 'submissions'] as Tab[]).map(t => (
{/* tab content */} -
+
{activeTab === 'content' && ( void; colorIndex?: number; + onSelect: (id: string) => void; + onDelete?: (id: string) => void; + colorIndex?: number; }) { const [open, setOpen] = useState(true); + const [hovered, setHovered] = useState(false); const v = node.version; const isRoot = !v.parent_version_id; const isSelected = v.id === selectedId; + const isLeaf = node.children.length === 0; const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length]; return (
- {/* horizontal connector from parent's vertical line */} {depth > 0 && (
onSelect(v.id)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} style={{ display: 'flex', alignItems: 'center', gap: 6, - paddingLeft: depth > 0 ? 18 : 8, paddingRight: 8, + paddingLeft: depth > 0 ? 18 : 8, paddingRight: 4, height: 30, cursor: 'pointer', borderRadius: 4, userSelect: 'none', - background: isSelected ? 'var(--selected-bg)' : 'transparent', + background: isSelected ? 'var(--selected-bg)' : hovered ? 'var(--hover)' : 'transparent', borderLeft: isSelected && depth === 0 ? '2px solid var(--selected-border)' : '2px solid transparent', }} - onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }} - onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }} > - {/* expand toggle */} - {/* dot indicator */} - {/* label */} - {/* patch count */} {v.patches.length > 0 && ( )} + + {!isRoot && isLeaf && onDelete && hovered && ( + + )}
- {/* children with vertical line */} {open && node.children.length > 0 && (
0 ? 22 : 14, @@ -110,6 +124,7 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: { depth={depth + 1} selectedId={selectedId} onSelect={onSelect} + onDelete={onDelete} colorIndex={depth === 0 ? i + 1 : colorIndex} /> ))} @@ -119,14 +134,17 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: { ); } -export default function CVTree({ versions, selectedVersionId, onSelect }: { - versions: Version[]; selectedVersionId: string | null; onSelect: (id: string) => void; +export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion }: { + versions: Version[]; selectedVersionId: string | null; + onSelect: (id: string) => void; + onDeleteVersion?: (id: string) => void; }) { const tree = buildTree(versions); if (!tree) return
No versions
; return (
- +
); } + diff --git a/apps/webapp/src/libs/api.ts b/apps/webapp/src/libs/api.ts index 84f414d..99ae46b 100644 --- a/apps/webapp/src/libs/api.ts +++ b/apps/webapp/src/libs/api.ts @@ -177,3 +177,25 @@ export async function publishVersion( body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }), }); } + +export async function deleteDocument(documentId: string): Promise { + const res = await fetch(`${API}/api/v1/documents/${documentId}`, { + method: 'DELETE', + headers: { accept: 'application/json', ...getAuthHeader() }, + }); + if (!res.ok) { + const detail = await res.text().catch(() => res.statusText); + throw new Error(detail || `HTTP ${res.status}`); + } +} + +export async function deleteVersion(versionId: string): Promise { + const res = await fetch(`${API}/api/v1/versions/${versionId}`, { + method: 'DELETE', + headers: { accept: 'application/json', ...getAuthHeader() }, + }); + if (!res.ok) { + const detail = await res.text().catch(() => res.statusText); + throw new Error(detail || `HTTP ${res.status}`); + } +} diff --git a/dlib/cv/__init__.py b/dlib/cv/__init__.py index 57289c9..0a15cbc 100644 --- a/dlib/cv/__init__.py +++ b/dlib/cv/__init__.py @@ -8,6 +8,7 @@ from .schema import ( from .parser import parse_docx_bytes, summarize_keywords from .patcher import apply_patchset from .ats_guard import validate_patchset +from .docx_export import generate_patched_docx __all__ = [ "StructuredBlock", @@ -19,4 +20,5 @@ __all__ = [ "summarize_keywords", "apply_patchset", "validate_patchset", + "generate_patched_docx", ] diff --git a/dlib/cv/docx_export.py b/dlib/cv/docx_export.py new file mode 100644 index 0000000..8a30a92 --- /dev/null +++ b/dlib/cv/docx_export.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections import defaultdict +from io import BytesIO + +from docx import Document + +from .parser import _detect_block_type + + +def _path_to_para_map(doc: Document) -> dict[str, int]: + counters: defaultdict[str, int] = defaultdict(int) + result: dict[str, int] = {} + for idx, para in enumerate(doc.paragraphs): + if not para.text.strip(): + continue + block_type = _detect_block_type(getattr(para.style, "name", None), para) + counters[block_type] += 1 + result[f"{block_type}[{counters[block_type]}]"] = idx + return result + + +def _replace_para_text(para, new_text: str) -> None: + """Replace paragraph text preserving the first run's character formatting.""" + if not para.runs: + para.add_run(new_text) + return + first = para.runs[0] + for run in para.runs[1:]: + run.text = "" + first.text = new_text + + +def _remove_paragraph(paragraph) -> None: + p = paragraph._element + p.getparent().remove(p) + + +def generate_patched_docx( + original_bytes: bytes, structured_blocks: list[dict] +) -> bytes: + """Return DOCX bytes with text patches from structured_blocks applied. + + Compares each block's text against the original paragraph and replaces it + when different. Blocks absent from structured_blocks are removed. + """ + if not structured_blocks: + return original_bytes + + doc = Document(BytesIO(original_bytes)) + path_map = _path_to_para_map(doc) + + original_paths = set(path_map.keys()) + patched = {b["path"]: b["text"] for b in structured_blocks} + patched_paths = set(patched.keys()) + + # Apply text replacements first (indices stay stable) + for path, new_text in patched.items(): + idx = path_map.get(path) + if idx is None: + continue + para = doc.paragraphs[idx] + if para.text.strip() != new_text: + _replace_para_text(para, new_text) + + # Remove blocks no longer present; process in reverse index order + removed = sorted( + [path_map[p] for p in (original_paths - patched_paths) if p in path_map], + reverse=True, + ) + for idx in removed: + _remove_paragraph(doc.paragraphs[idx]) + + out = BytesIO() + doc.save(out) + return out.getvalue()