mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-07-16 03:13:36 +00:00
Compare commits
5 Commits
claude/pap
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e57ec7642 | ||
|
|
52eab5b6e4 | ||
|
|
f625dead76 | ||
|
|
97ee914b1b | ||
| a21f14ea87 |
11
.env.example
11
.env.example
@@ -57,17 +57,6 @@ AUTHENTIK_CLIENT_SECRET=
|
|||||||
# Leave blank to use the built-in rule-based tailoring instead of Claude.
|
# Leave blank to use the built-in rule-based tailoring instead of Claude.
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
# ── Paperless-ngx integration (optional) ─────────────────────────────────────
|
|
||||||
# When enabled, published CVs are uploaded to your paperless-ngx instance and
|
|
||||||
# shared via paperless share links (with optional expiry). MinIO is still used
|
|
||||||
# for DOCX artifact storage; paperless handles the published PDF + sharing.
|
|
||||||
PAPERLESS_ENABLED=false
|
|
||||||
PAPERLESS_BASE_URL=http://localhost:8000
|
|
||||||
# API token — obtain via POST /api/token/ with your paperless credentials.
|
|
||||||
PAPERLESS_TOKEN=
|
|
||||||
# Comma-separated tag IDs to apply to uploaded CV documents (optional).
|
|
||||||
PAPERLESS_TAG_IDS=
|
|
||||||
|
|
||||||
# ── Demo mode ─────────────────────────────────────────────────────────────────
|
# ── Demo mode ─────────────────────────────────────────────────────────────────
|
||||||
# Set to true to enable standalone demo mode in the webapp.
|
# Set to true to enable standalone demo mode in the webapp.
|
||||||
# Demo mode uses hardcoded dummy data — no backend or DB required.
|
# Demo mode uses hardcoded dummy data — no backend or DB required.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_current_user, get_db
|
from app.api.deps import get_current_user, get_db
|
||||||
from app.core.config import get_settings
|
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 (
|
from app.services.documents import (
|
||||||
create_document,
|
create_document,
|
||||||
delete_document,
|
delete_document,
|
||||||
@@ -14,8 +14,9 @@ from app.services.documents import (
|
|||||||
list_documents,
|
list_documents,
|
||||||
)
|
)
|
||||||
from app.services.storage import storage_client
|
from app.services.storage import storage_client
|
||||||
|
from app.services.versions import upload_docx_to_version
|
||||||
from dlib.auth import AuthenticatedUser
|
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"])
|
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)
|
@router.post("", response_model=DocumentResponse)
|
||||||
async def upload_document(
|
async def upload_document(
|
||||||
title: str = Form(...),
|
title: str = Form(...),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -17,13 +16,11 @@ from app.schemas import (
|
|||||||
PublicAssetLookupResponse,
|
PublicAssetLookupResponse,
|
||||||
PublicAssetResponse,
|
PublicAssetResponse,
|
||||||
PublishRequest,
|
PublishRequest,
|
||||||
ShareLinkRequest,
|
|
||||||
)
|
)
|
||||||
from app.services.publication import publish_version
|
from app.services.publication import publish_version
|
||||||
from app.services.storage import storage_client
|
from app.services.storage import storage_client
|
||||||
from dlib.auth import AuthenticatedUser
|
from dlib.auth import AuthenticatedUser
|
||||||
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
|
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
|
||||||
from dlib.integrations.paperless import get_paperless_client
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/public", tags=["public"])
|
router = APIRouter(prefix="/public", tags=["public"])
|
||||||
@@ -51,18 +48,6 @@ async def _get_public_asset(session: AsyncSession, slug: str) -> PublicAsset:
|
|||||||
return asset
|
return asset
|
||||||
|
|
||||||
|
|
||||||
async def _assert_owner(session: AsyncSession, asset: PublicAsset, owner_id: str) -> None:
|
|
||||||
if not asset.version_id:
|
|
||||||
raise HTTPException(status_code=403, detail="Not authorized")
|
|
||||||
stmt = (
|
|
||||||
select(CvVersion)
|
|
||||||
.join(CvVersion.document)
|
|
||||||
.where(CvVersion.id == asset.version_id, CvDocument.owner_id == owner_id)
|
|
||||||
)
|
|
||||||
if not (await session.execute(stmt)).scalars().one_or_none():
|
|
||||||
raise HTTPException(status_code=403, detail="Not authorized")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/publish", response_model=PublicAssetResponse)
|
@router.post("/publish", response_model=PublicAssetResponse)
|
||||||
async def publish(
|
async def publish(
|
||||||
payload: PublishRequest,
|
payload: PublishRequest,
|
||||||
@@ -75,40 +60,12 @@ async def publish(
|
|||||||
version_id=payload.version_id,
|
version_id=payload.version_id,
|
||||||
submission_id=payload.submission_id,
|
submission_id=payload.submission_id,
|
||||||
slug=payload.slug,
|
slug=payload.slug,
|
||||||
expires_at=payload.expires_at,
|
|
||||||
)
|
)
|
||||||
if not asset:
|
if not asset:
|
||||||
raise HTTPException(status_code=404, detail="Version or submission not found")
|
raise HTTPException(status_code=404, detail="Version or submission not found")
|
||||||
return _response_from_asset(asset)
|
return _response_from_asset(asset)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{slug}/share-links", response_model=PublicAssetResponse)
|
|
||||||
async def create_share_link(
|
|
||||||
slug: str,
|
|
||||||
payload: ShareLinkRequest,
|
|
||||||
session: AsyncSession = Depends(get_db),
|
|
||||||
user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
asset = await _get_public_asset(session, slug)
|
|
||||||
await _assert_owner(session, asset, user.sub)
|
|
||||||
|
|
||||||
if not asset.paperless_document_id:
|
|
||||||
raise HTTPException(status_code=409, detail="Asset not synced to paperless")
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
client = get_paperless_client(settings)
|
|
||||||
if not client:
|
|
||||||
raise HTTPException(status_code=503, detail="Paperless integration not enabled")
|
|
||||||
|
|
||||||
_, share_url = await asyncio.to_thread(
|
|
||||||
client.create_share_link, asset.paperless_document_id, payload.expiration_date
|
|
||||||
)
|
|
||||||
asset.paperless_share_slug = share_url.split("/share/")[-1]
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(asset)
|
|
||||||
return _response_from_asset(asset)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse)
|
@router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse)
|
||||||
async def get_analytics(
|
async def get_analytics(
|
||||||
slug: str,
|
slug: str,
|
||||||
@@ -116,7 +73,17 @@ async def get_analytics(
|
|||||||
user: AuthenticatedUser = Depends(get_current_user),
|
user: AuthenticatedUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
asset = await _get_public_asset(session, slug)
|
asset = await _get_public_asset(session, slug)
|
||||||
await _assert_owner(session, asset, user.sub)
|
|
||||||
|
if asset.version_id:
|
||||||
|
stmt = (
|
||||||
|
select(CvVersion)
|
||||||
|
.join(CvVersion.document)
|
||||||
|
.where(CvVersion.id == asset.version_id, CvDocument.owner_id == user.sub)
|
||||||
|
)
|
||||||
|
if not (await session.execute(stmt)).scalars().one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
view_count = (
|
view_count = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
@@ -170,11 +137,7 @@ async def get_public_asset(slug: str, request: Request, session: AsyncSession =
|
|||||||
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
|
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
base = settings.public_base_url.rstrip("/")
|
base = settings.public_base_url.rstrip("/")
|
||||||
paperless_share_url = (
|
url = f"{base}/cv/{asset.slug}"
|
||||||
f"{settings.paperless_base_url}/share/{asset.paperless_share_slug}"
|
|
||||||
if settings.paperless_base_url and asset.paperless_share_slug
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return PublicAssetResponse(
|
return PublicAssetResponse(
|
||||||
id=asset.id,
|
id=asset.id,
|
||||||
slug=asset.slug,
|
slug=asset.slug,
|
||||||
@@ -183,6 +146,5 @@ def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
|
|||||||
created_at=asset.created_at,
|
created_at=asset.created_at,
|
||||||
version_id=asset.version_id,
|
version_id=asset.version_id,
|
||||||
submission_id=asset.submission_id,
|
submission_id=asset.submission_id,
|
||||||
url=f"{base}/cv/{asset.slug}",
|
url=url,
|
||||||
paperless_share_url=paperless_share_url,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,11 +47,6 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
publish_domain: str = Field(default="cv.alves.world", alias="CV_PUBLIC_DOMAIN")
|
publish_domain: str = Field(default="cv.alves.world", alias="CV_PUBLIC_DOMAIN")
|
||||||
|
|
||||||
paperless_enabled: bool = Field(default=False, alias="PAPERLESS_ENABLED")
|
|
||||||
paperless_base_url: str | None = Field(default=None, alias="PAPERLESS_BASE_URL")
|
|
||||||
paperless_token: str | None = Field(default=None, alias="PAPERLESS_TOKEN")
|
|
||||||
paperless_tag_ids: list[int] = Field(default_factory=list, alias="PAPERLESS_TAG_IDS")
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
extra = "ignore"
|
extra = "ignore"
|
||||||
@@ -72,20 +67,13 @@ class Settings(BaseSettings):
|
|||||||
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@field_validator("storage_endpoint_url", "paperless_base_url", "paperless_token", mode="before")
|
@field_validator("storage_endpoint_url", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _empty_str_to_none(cls, value):
|
def _empty_endpoint_to_none(cls, value):
|
||||||
if isinstance(value, str) and not value.strip():
|
if isinstance(value, str) and not value.strip():
|
||||||
return None
|
return None
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@field_validator("paperless_tag_ids", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def _parse_tag_ids(cls, value):
|
|
||||||
if isinstance(value, str):
|
|
||||||
return [int(v.strip()) for v in value.split(",") if v.strip()]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import enum
|
import enum
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text
|
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -148,8 +148,6 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
|
|||||||
expires_at: Mapped[str | None] = mapped_column(
|
expires_at: Mapped[str | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
paperless_document_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
paperless_share_slug: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
|
||||||
|
|
||||||
submission: Mapped[Submission | None] = relationship(
|
submission: Mapped[Submission | None] = relationship(
|
||||||
"Submission", back_populates="public_asset"
|
"Submission", back_populates="public_asset"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from .cv import (
|
|||||||
PublicAssetLookupResponse,
|
PublicAssetLookupResponse,
|
||||||
PublicAssetResponse,
|
PublicAssetResponse,
|
||||||
PublishRequest,
|
PublishRequest,
|
||||||
ShareLinkRequest,
|
|
||||||
SubmissionCreateRequest,
|
SubmissionCreateRequest,
|
||||||
SubmissionResponse,
|
SubmissionResponse,
|
||||||
SubmissionStatusUpdateRequest,
|
SubmissionStatusUpdateRequest,
|
||||||
@@ -32,7 +31,6 @@ __all__ = [
|
|||||||
"SuggestionResponse",
|
"SuggestionResponse",
|
||||||
"SuggestionUpdateRequest",
|
"SuggestionUpdateRequest",
|
||||||
"PublishRequest",
|
"PublishRequest",
|
||||||
"ShareLinkRequest",
|
|
||||||
"PublicAssetResponse",
|
"PublicAssetResponse",
|
||||||
"PublicAssetLookupResponse",
|
"PublicAssetLookupResponse",
|
||||||
"PublicAssetAnalyticsResponse",
|
"PublicAssetAnalyticsResponse",
|
||||||
|
|||||||
@@ -121,11 +121,6 @@ class PublishRequest(BaseModel):
|
|||||||
version_id: str | None = None
|
version_id: str | None = None
|
||||||
submission_id: str | None = None
|
submission_id: str | None = None
|
||||||
slug: str | None = None
|
slug: str | None = None
|
||||||
expires_at: datetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ShareLinkRequest(BaseModel):
|
|
||||||
expiration_date: datetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PublicAssetResponse(BaseModel):
|
class PublicAssetResponse(BaseModel):
|
||||||
@@ -139,7 +134,6 @@ class PublicAssetResponse(BaseModel):
|
|||||||
version_id: str | None = None
|
version_id: str | None = None
|
||||||
submission_id: str | None = None
|
submission_id: str | None = None
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
paperless_share_url: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PublicAssetLookupResponse(BaseModel):
|
class PublicAssetLookupResponse(BaseModel):
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -8,11 +7,7 @@ from uuid import uuid4
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.models import CvDocument, CvVersion, PublicAsset, Submission
|
from app.models import CvDocument, CvVersion, PublicAsset, Submission
|
||||||
from app.services.storage import storage_client
|
|
||||||
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
|
|
||||||
from dlib.integrations.paperless import get_paperless_client
|
|
||||||
|
|
||||||
|
|
||||||
async def publish_version(
|
async def publish_version(
|
||||||
@@ -22,7 +17,6 @@ async def publish_version(
|
|||||||
version_id: str | None,
|
version_id: str | None,
|
||||||
submission_id: str | None,
|
submission_id: str | None,
|
||||||
slug: str | None,
|
slug: str | None,
|
||||||
expires_at: datetime | None = None,
|
|
||||||
) -> PublicAsset | None:
|
) -> PublicAsset | None:
|
||||||
target_version: CvVersion | None = None
|
target_version: CvVersion | None = None
|
||||||
target_submission: Submission | None = None
|
target_submission: Submission | None = None
|
||||||
@@ -61,27 +55,11 @@ async def publish_version(
|
|||||||
slug=resolved_slug,
|
slug=resolved_slug,
|
||||||
artifact_key=target_version.artifact_docx_key,
|
artifact_key=target_version.artifact_docx_key,
|
||||||
is_public=True,
|
is_public=True,
|
||||||
expires_at=expires_at,
|
expires_at=None,
|
||||||
)
|
)
|
||||||
session.add(asset)
|
session.add(asset)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(asset)
|
await session.refresh(asset)
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
client = get_paperless_client(settings)
|
|
||||||
if client:
|
|
||||||
docx = storage_client.download_bytes(target_version.artifact_docx_key)
|
|
||||||
blocks = target_version.structured_blocks or []
|
|
||||||
pdf = docx_bytes_to_pdf(generate_patched_docx(docx, blocks))
|
|
||||||
doc_id = await asyncio.to_thread(
|
|
||||||
client.upload_document, pdf, resolved_slug, settings.paperless_tag_ids or []
|
|
||||||
)
|
|
||||||
_, share_url = await asyncio.to_thread(client.create_share_link, doc_id, expires_at)
|
|
||||||
asset.paperless_document_id = doc_id
|
|
||||||
asset.paperless_share_slug = share_url.split("/share/")[-1]
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(asset)
|
|
||||||
|
|
||||||
return asset
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -8,11 +9,34 @@ from dlib.cv import (
|
|||||||
StructuredBlock,
|
StructuredBlock,
|
||||||
StructuredDocument,
|
StructuredDocument,
|
||||||
PatchPayload,
|
PatchPayload,
|
||||||
|
PatchOperation,
|
||||||
apply_patchset,
|
apply_patchset,
|
||||||
validate_patchset,
|
validate_patchset,
|
||||||
|
parse_docx_bytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.models import CvDocument, CvPatch, CvVersion, PublicAsset
|
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(
|
async def create_branch(
|
||||||
@@ -78,7 +102,10 @@ async def create_branch(
|
|||||||
stmt_refresh = (
|
stmt_refresh = (
|
||||||
select(CvVersion)
|
select(CvVersion)
|
||||||
.where(CvVersion.id == new_version.id)
|
.where(CvVersion.id == new_version.id)
|
||||||
.options(selectinload(CvVersion.patches))
|
.options(
|
||||||
|
selectinload(CvVersion.patches),
|
||||||
|
selectinload(CvVersion.public_assets),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt_refresh)
|
result = await session.execute(stmt_refresh)
|
||||||
return result.scalars().one()
|
return result.scalars().one()
|
||||||
@@ -95,7 +122,10 @@ async def append_patches_to_version(
|
|||||||
select(CvVersion)
|
select(CvVersion)
|
||||||
.join(CvVersion.document)
|
.join(CvVersion.document)
|
||||||
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
|
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
|
||||||
.options(selectinload(CvVersion.patches))
|
.options(
|
||||||
|
selectinload(CvVersion.patches),
|
||||||
|
selectinload(CvVersion.public_assets),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
version = result.scalars().one_or_none()
|
version = result.scalars().one_or_none()
|
||||||
@@ -138,12 +168,64 @@ async def append_patches_to_version(
|
|||||||
stmt_refresh = (
|
stmt_refresh = (
|
||||||
select(CvVersion)
|
select(CvVersion)
|
||||||
.where(CvVersion.id == version_id)
|
.where(CvVersion.id == version_id)
|
||||||
.options(selectinload(CvVersion.patches))
|
.options(
|
||||||
|
selectinload(CvVersion.patches),
|
||||||
|
selectinload(CvVersion.public_assets),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt_refresh)
|
result = await session.execute(stmt_refresh)
|
||||||
return result.scalars().one()
|
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(
|
async def delete_version(
|
||||||
session: AsyncSession, owner_id: str, version_id: str
|
session: AsyncSession, owner_id: str, version_id: str
|
||||||
) -> bool | str:
|
) -> bool | str:
|
||||||
|
|||||||
@@ -1,20 +1,59 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createHmac } from 'crypto';
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
|
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
|
||||||
const LOGIN_USER = process.env.LOGIN_USER ?? 'admin';
|
const LOGIN_USER = process.env.LOGIN_USER ?? 'admin';
|
||||||
const LOGIN_PASS = process.env.LOGIN_PASS ?? 'admin';
|
const LOGIN_PASS = process.env.LOGIN_PASS ?? 'admin';
|
||||||
|
|
||||||
|
// in-memory rate limiter: max 10 attempts per IP per 15 min
|
||||||
|
const attempts = new Map<string, { n: number; resetAt: number }>();
|
||||||
|
|
||||||
|
function checkRate(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const e = attempts.get(ip);
|
||||||
|
if (!e || now > e.resetAt) { attempts.set(ip, { n: 1, resetAt: now + 15 * 60_000 }); return true; }
|
||||||
|
if (e.n >= 10) return false;
|
||||||
|
e.n++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEq(a: string, b: string): boolean {
|
||||||
|
try {
|
||||||
|
const ab = Buffer.from(a), bb = Buffer.from(b);
|
||||||
|
if (ab.length !== bb.length) {
|
||||||
|
// still run comparison on equal-length buffers to avoid timing leak
|
||||||
|
timingSafeEqual(ab, ab);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return timingSafeEqual(ab, bb);
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
function sign(value: string) {
|
function sign(value: string) {
|
||||||
return createHmac('sha256', SECRET).update(value).digest('hex');
|
return createHmac('sha256', SECRET).update(value).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const body = await req.json().catch(() => ({}));
|
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown';
|
||||||
const { username, password } = body as Record<string, string>;
|
if (!checkRate(ip)) {
|
||||||
if (!username || !password || username !== LOGIN_USER || password !== LOGIN_PASS) {
|
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { username, password } = body as Record<string, unknown>;
|
||||||
|
if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) {
|
||||||
|
return NextResponse.json({ error: 'Username and password required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validUser = safeEq(username, LOGIN_USER);
|
||||||
|
const validPass = safeEq(password, LOGIN_PASS);
|
||||||
|
if (!validUser || !validPass) {
|
||||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = `${username}:${Date.now()}`;
|
const payload = `${username}:${Date.now()}`;
|
||||||
const token = `${payload}.${sign(payload)}`;
|
const token = `${payload}.${sign(payload)}`;
|
||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export async function POST() {
|
|||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.cookies.delete('session');
|
res.cookies.delete('session');
|
||||||
res.cookies.delete('oidc_token');
|
res.cookies.delete('oidc_token');
|
||||||
|
res.cookies.delete('oidc_token_pub');
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
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 DiffViewer from '@/components/cv/DiffViewer';
|
||||||
import InsightsPanel from '@/components/cv/InsightsPanel';
|
import InsightsPanel from '@/components/cv/InsightsPanel';
|
||||||
|
import PDFPreview from '@/components/cv/PDFPreview';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
appendPatches,
|
appendPatches,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
fetchDocuments, fetchInsights, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
fetchDocuments, fetchInsights, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
||||||
InsightsResult,
|
InsightsResult,
|
||||||
IS_DEMO,
|
IS_DEMO,
|
||||||
|
Patch,
|
||||||
publishVersion, PublicAsset, PublicAssetAnalytics,
|
publishVersion, PublicAsset, PublicAssetAnalytics,
|
||||||
requestAiSuggestions,
|
requestAiSuggestions,
|
||||||
Submission,
|
Submission,
|
||||||
@@ -21,12 +23,32 @@ import {
|
|||||||
updateSubmissionStatus,
|
updateSubmissionStatus,
|
||||||
updateSuggestion,
|
updateSuggestion,
|
||||||
uploadDocument,
|
uploadDocument,
|
||||||
|
uploadDocxToBranch,
|
||||||
Version,
|
Version,
|
||||||
} from '@/libs/api';
|
} from '@/libs/api';
|
||||||
import {
|
import {
|
||||||
DEMO_DOCUMENTS, DEMO_DOC_ID, DEMO_INSIGHTS, DEMO_SUBMISSIONS,
|
DEMO_DOCUMENTS, DEMO_DOC_ID, DEMO_INSIGHTS, DEMO_SUBMISSIONS,
|
||||||
} from './demo-data';
|
} from './demo-data';
|
||||||
|
|
||||||
|
// ── diff propagation helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getAncestorChain(versions: Version[], versionId: string): Version[] {
|
||||||
|
const map = new Map(versions.map(v => [v.id, v]));
|
||||||
|
const chain: Version[] = [];
|
||||||
|
let cur = map.get(versionId);
|
||||||
|
while (cur) { chain.unshift(cur); cur = cur.parent_version_id ? map.get(cur.parent_version_id) : undefined; }
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeInheritedPatches(versions: Version[], versionId: string): Patch[] {
|
||||||
|
return getAncestorChain(versions, versionId).slice(0, -1).flatMap(v => v.patches);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDescendants(versions: Version[], versionId: string): Version[] {
|
||||||
|
const children = versions.filter(v => v.parent_version_id === versionId);
|
||||||
|
return children.flatMap(c => [c, ...getDescendants(versions, c.id)]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function fmt(iso: string) {
|
function fmt(iso: string) {
|
||||||
@@ -74,8 +96,14 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
|
|||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!title.trim() || !file) { setError('Title and file required.'); return; }
|
if (!title.trim() || !file) { setError('Title and file required.'); return; }
|
||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try { onDone(await uploadDocument(title.trim(), desc.trim() || null, file)); }
|
try {
|
||||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Upload failed'); setLoading(false); }
|
const doc = await uploadDocument(title.trim(), desc.trim() || null, file);
|
||||||
|
await Promise.resolve(onDone(doc));
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Upload failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -123,8 +151,14 @@ function BranchModal({
|
|||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!name.trim()) { setError('Branch name required.'); return; }
|
if (!name.trim()) { setError('Branch name required.'); return; }
|
||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try { onDone(await createBranch(version.id, name.trim(), label.trim() || null, patches as Record<string, unknown>[])); }
|
try {
|
||||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
|
const v = await createBranch(version.id, name.trim(), label.trim() || null, patches as Record<string, unknown>[]);
|
||||||
|
await Promise.resolve(onDone(v));
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -170,8 +204,14 @@ function SubmissionModal({ version, onClose, onDone }: { version: Version; onClo
|
|||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; }
|
if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; }
|
||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try { onDone(await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null, jd.trim() || null)); }
|
try {
|
||||||
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
|
const s = await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null, jd.trim() || null);
|
||||||
|
await Promise.resolve(onDone(s));
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -211,8 +251,12 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
|
|||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try {
|
try {
|
||||||
const asset = await publishVersion(version.id, null, slug.trim() || null);
|
const asset = await publishVersion(version.id, null, slug.trim() || null);
|
||||||
onDone(asset);
|
await Promise.resolve(onDone(asset));
|
||||||
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -554,7 +598,7 @@ function SubmissionsTab({
|
|||||||
// ── main dashboard ────────────────────────────────────────────────────────────
|
// ── main dashboard ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null;
|
type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null;
|
||||||
type Tab = 'content' | 'patches' | 'submissions' | 'insights';
|
type Tab = 'content' | 'patches' | 'submissions' | 'insights' | 'preview';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [docs, setDocs] = useState<Document[]>([]);
|
const [docs, setDocs] = useState<Document[]>([]);
|
||||||
@@ -575,6 +619,11 @@ export default function Dashboard() {
|
|||||||
const [applyLoading, setApplyLoading] = useState(false);
|
const [applyLoading, setApplyLoading] = useState(false);
|
||||||
const [applyError, setApplyError] = useState('');
|
const [applyError, setApplyError] = useState('');
|
||||||
const [insights, setInsights] = useState<InsightsResult | null>(null);
|
const [insights, setInsights] = useState<InsightsResult | null>(null);
|
||||||
|
const [uploadBranchLoading, setUploadBranchLoading] = useState(false);
|
||||||
|
const [uploadBranchError, setUploadBranchError] = useState('');
|
||||||
|
const [propagateLoading, setPropagateLoading] = useState(false);
|
||||||
|
const [propagateError, setPropagateError] = useState('');
|
||||||
|
const uploadBranchRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (IS_DEMO) {
|
if (IS_DEMO) {
|
||||||
@@ -606,6 +655,7 @@ export default function Dashboard() {
|
|||||||
setApplyLoading(false);
|
setApplyLoading(false);
|
||||||
setPublishedAnalytics({});
|
setPublishedAnalytics({});
|
||||||
setRecentlyPublishedSlug(null);
|
setRecentlyPublishedSlug(null);
|
||||||
|
setPropagateError('');
|
||||||
}, [selectedVersionId]);
|
}, [selectedVersionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -695,6 +745,40 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const discardEdits = () => setPendingEdits(new Map());
|
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 () => {
|
const applyStagedEdits = async () => {
|
||||||
if (!selectedVersionId || !stagedPatches.length) return;
|
if (!selectedVersionId || !stagedPatches.length) return;
|
||||||
setApplyLoading(true);
|
setApplyLoading(true);
|
||||||
@@ -746,6 +830,31 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePropagate = async (versionId: string) => {
|
||||||
|
if (IS_DEMO || !selectedDoc) return;
|
||||||
|
const version = selectedDoc.versions.find(v => v.id === versionId);
|
||||||
|
if (!version || !version.patches.length) return;
|
||||||
|
const descendants = getDescendants(selectedDoc.versions, versionId);
|
||||||
|
if (!descendants.length) return;
|
||||||
|
const msg = `Propagate ${version.patches.length} patch${version.patches.length !== 1 ? 'es' : ''} from "${version.branch_name}" to ${descendants.length} downstream branch${descendants.length !== 1 ? 'es' : ''}?`;
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
setPropagateLoading(true);
|
||||||
|
setPropagateError('');
|
||||||
|
try {
|
||||||
|
await Promise.all(descendants.map(d =>
|
||||||
|
appendPatches(d.id, version.patches.map(p => ({
|
||||||
|
target_path: p.target_path, operation: p.operation,
|
||||||
|
old_value: p.old_value ?? undefined, new_value: p.new_value ?? undefined,
|
||||||
|
})) as Record<string, unknown>[])
|
||||||
|
));
|
||||||
|
await refreshDocs();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setPropagateError(e instanceof Error ? e.message : 'Propagation failed');
|
||||||
|
} finally {
|
||||||
|
setPropagateLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const selectVersion = (id: string) => {
|
const selectVersion = (id: string) => {
|
||||||
setSelectedVersionId(id);
|
setSelectedVersionId(id);
|
||||||
setActiveTab('content');
|
setActiveTab('content');
|
||||||
@@ -877,6 +986,8 @@ export default function Dashboard() {
|
|||||||
selectedVersionId={selectedVersionId}
|
selectedVersionId={selectedVersionId}
|
||||||
onSelect={selectVersion}
|
onSelect={selectVersion}
|
||||||
onDeleteVersion={handleDeleteVersion}
|
onDeleteVersion={handleDeleteVersion}
|
||||||
|
onCopyMarkdown={handleCopyBranchMarkdown}
|
||||||
|
onPropagate={handlePropagate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -929,6 +1040,8 @@ export default function Dashboard() {
|
|||||||
versions={selectedDoc.versions}
|
versions={selectedDoc.versions}
|
||||||
selectedVersionId={selectedVersionId}
|
selectedVersionId={selectedVersionId}
|
||||||
onSelect={selectVersion}
|
onSelect={selectVersion}
|
||||||
|
onCopyMarkdown={handleCopyBranchMarkdown}
|
||||||
|
onPropagate={handlePropagate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -979,6 +1092,17 @@ export default function Dashboard() {
|
|||||||
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button>}
|
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button>}
|
||||||
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => { setModal('submission'); }}>Submit</button>}
|
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => { setModal('submission'); }}>Submit</button>}
|
||||||
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>}
|
{!IS_DEMO && <button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>}
|
||||||
|
{!IS_DEMO && selectedVersion.patches.length > 0 && selectedDoc && getDescendants(selectedDoc.versions, selectedVersion.id).length > 0 && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ color: '#7c3aed', borderColor: '#c4b5fd' }}
|
||||||
|
onClick={() => handlePropagate(selectedVersion.id)}
|
||||||
|
disabled={propagateLoading}
|
||||||
|
title="Apply this version's patches to all descendant branches"
|
||||||
|
>
|
||||||
|
{propagateLoading ? 'Propagating…' : 'Propagate ↓'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{IS_DEMO && (
|
{IS_DEMO && (
|
||||||
<a href="/demo-cv.docx" download="alex-rivera-cv.docx" className="btn btn-ghost">
|
<a href="/demo-cv.docx" download="alex-rivera-cv.docx" className="btn btn-ghost">
|
||||||
↓ DOCX
|
↓ DOCX
|
||||||
@@ -989,6 +1113,29 @@ export default function Dashboard() {
|
|||||||
↓ DOCX
|
↓ DOCX
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{!IS_DEMO && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => uploadBranchRef.current?.click()}
|
||||||
|
disabled={uploadBranchLoading}
|
||||||
|
title="Upload a new DOCX to this branch — diff is computed automatically"
|
||||||
|
>
|
||||||
|
{uploadBranchLoading ? 'Uploading…' : '↑ DOCX'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={uploadBranchRef}
|
||||||
|
type="file"
|
||||||
|
accept=".docx"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={e => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) handleUploadToBranch(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1085,9 +1232,31 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{uploadBranchError && (
|
||||||
|
<div style={{
|
||||||
|
padding: '6px 12px', background: '#fef2f2', border: '1px solid #fca5a5',
|
||||||
|
borderRadius: 5, marginBottom: 12, fontSize: 12, color: '#b91c1c',
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<span>{uploadBranchError}</span>
|
||||||
|
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={() => setUploadBranchError('')}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{propagateError && (
|
||||||
|
<div style={{
|
||||||
|
padding: '6px 12px', background: '#fef2f2', border: '1px solid #fca5a5',
|
||||||
|
borderRadius: 5, marginBottom: 12, fontSize: 12, color: '#b91c1c',
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<span>{propagateError}</span>
|
||||||
|
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={() => setPropagateError('')}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* tabs */}
|
{/* tabs */}
|
||||||
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', overflowX: 'auto' }}>
|
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', overflowX: 'auto' }}>
|
||||||
{(['content', 'patches', 'submissions', 'insights'] as Tab[]).map(t => (
|
{(['content', 'patches', 'submissions', 'insights', 'preview'] as Tab[]).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setActiveTab(t)}
|
onClick={() => setActiveTab(t)}
|
||||||
@@ -1106,7 +1275,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* tab content */}
|
{/* tab content */}
|
||||||
<div style={{ padding: '16px 20px', flex: 1, overflow: 'auto' }}>
|
<div style={{ padding: activeTab === 'preview' ? 0 : '16px 20px', flex: 1, overflow: activeTab === 'preview' ? 'hidden' : 'auto' }}>
|
||||||
{activeTab === 'content' && (
|
{activeTab === 'content' && (
|
||||||
<ContentTab
|
<ContentTab
|
||||||
blocks={selectedVersion.structured_blocks ?? []}
|
blocks={selectedVersion.structured_blocks ?? []}
|
||||||
@@ -1114,9 +1283,17 @@ export default function Dashboard() {
|
|||||||
onEdit={stageEdit}
|
onEdit={stageEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'patches' && (
|
{activeTab === 'patches' && (() => {
|
||||||
<DiffViewer patches={selectedVersion.patches} />
|
const inherited = selectedDoc ? computeInheritedPatches(selectedDoc.versions, selectedVersion.id) : [];
|
||||||
)}
|
const parentBranch = selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name;
|
||||||
|
return (
|
||||||
|
<DiffViewer
|
||||||
|
patches={selectedVersion.patches}
|
||||||
|
inheritedPatches={inherited}
|
||||||
|
ancestorLabel={parentBranch}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{activeTab === 'submissions' && (
|
{activeTab === 'submissions' && (
|
||||||
<SubmissionsTab
|
<SubmissionsTab
|
||||||
submissions={IS_DEMO
|
submissions={IS_DEMO
|
||||||
@@ -1134,6 +1311,11 @@ export default function Dashboard() {
|
|||||||
{activeTab === 'insights' && (
|
{activeTab === 'insights' && (
|
||||||
<InsightsPanel data={insights} />
|
<InsightsPanel data={insights} />
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'preview' && selectedDoc && (
|
||||||
|
IS_DEMO
|
||||||
|
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-faint)', fontSize: 13 }}>Preview not available in demo mode.</div>
|
||||||
|
: <PDFPreview documentId={selectedDoc.id} versionId={selectedVersion.id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
const OIDC_ERROR_LABELS: Record<string, string> = {
|
||||||
|
no_code: 'No authorization code received from provider.',
|
||||||
|
oidc_not_configured: 'OIDC provider not configured.',
|
||||||
|
token_exchange: 'Failed to exchange token with provider.',
|
||||||
|
};
|
||||||
|
|
||||||
function authentikBase(url?: string | null) {
|
function authentikBase(url?: string | null) {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
try {
|
try { return new URL(url).origin.replace(/\/$/, ''); } catch { return null; }
|
||||||
const parsed = new URL(url);
|
|
||||||
return parsed.origin.replace(/\/$/, '');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function authentikUrl() {
|
function authentikUrl() {
|
||||||
@@ -18,27 +19,31 @@ function authentikUrl() {
|
|||||||
const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
|
const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
|
||||||
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : '');
|
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
if (!baseHost || !clientId) return null;
|
if (!baseHost || !clientId) return null;
|
||||||
const params = new URLSearchParams({
|
return `${baseHost}/application/o/authorize/?${new URLSearchParams({
|
||||||
response_type: 'code',
|
response_type: 'code', client_id: clientId,
|
||||||
client_id: clientId,
|
redirect_uri: `${base}/api/auth/callback`, scope: 'openid email profile',
|
||||||
redirect_uri: `${base}/api/auth/callback`,
|
})}`;
|
||||||
scope: 'openid email profile',
|
|
||||||
});
|
|
||||||
return `${baseHost}/application/o/authorize/?${params}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const params = useSearchParams();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const oidcUrl = typeof window !== 'undefined' ? authentikUrl() : null;
|
const oidcUrl = typeof window !== 'undefined' ? authentikUrl() : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const e = params.get('error');
|
||||||
|
if (e) setError(OIDC_ERROR_LABELS[e] ?? `Auth error: ${e}`);
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!username || !password) return;
|
if (!username || !password || loading) return;
|
||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
|
try {
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await fetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -48,7 +53,11 @@ export default function LoginPage() {
|
|||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} else {
|
} else {
|
||||||
const j = await res.json().catch(() => ({}));
|
const j = await res.json().catch(() => ({}));
|
||||||
setError(j.error ?? 'Login failed');
|
setError(j.error ?? `Login failed (${res.status})`);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Network error — check your connection and try again.');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -59,17 +68,11 @@ export default function LoginPage() {
|
|||||||
justifyContent: 'center', background: 'var(--bg)',
|
justifyContent: 'center', background: 'var(--bg)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ width: '100%', maxWidth: 360, padding: '0 20px' }}>
|
<div style={{ width: '100%', maxWidth: 360, padding: '0 20px' }}>
|
||||||
{/* brand */}
|
|
||||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>
|
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>cvfs</div>
|
||||||
cvfs
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Sign in to your account</div>
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
|
||||||
Sign in to your account
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* form card */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
borderRadius: 8, padding: '24px 24px 20px',
|
borderRadius: 8, padding: '24px 24px 20px',
|
||||||
@@ -82,7 +85,7 @@ export default function LoginPage() {
|
|||||||
<input
|
<input
|
||||||
type="text" autoComplete="username" autoFocus
|
type="text" autoComplete="username" autoFocus
|
||||||
value={username} onChange={e => setUsername(e.target.value)}
|
value={username} onChange={e => setUsername(e.target.value)}
|
||||||
placeholder="admin"
|
placeholder="admin" disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -92,7 +95,7 @@ export default function LoginPage() {
|
|||||||
<input
|
<input
|
||||||
type="password" autoComplete="current-password"
|
type="password" autoComplete="current-password"
|
||||||
value={password} onChange={e => setPassword(e.target.value)}
|
value={password} onChange={e => setPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••" disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -142,3 +145,11 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,34 +1,60 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { MouseEvent, useState } from 'react';
|
||||||
import { Version } from '@/libs/api';
|
import { Version } from '@/libs/api';
|
||||||
|
|
||||||
type TreeNode = { version: Version; children: TreeNode[] };
|
type TreeNode = { version: Version; children: TreeNode[]; ancestorPatchCount: number };
|
||||||
|
|
||||||
function buildTree(versions: Version[]): TreeNode | null {
|
function buildTree(versions: Version[]): TreeNode | null {
|
||||||
const map = new Map(versions.map(v => [v.id, { version: v, children: [] as TreeNode[] }]));
|
const map = new Map(versions.map(v => [v.id, { version: v, children: [] as TreeNode[], ancestorPatchCount: 0 }]));
|
||||||
let root: TreeNode | null = null;
|
let root: TreeNode | null = null;
|
||||||
for (const node of map.values()) {
|
for (const node of map.values()) {
|
||||||
const pid = node.version.parent_version_id;
|
const pid = node.version.parent_version_id;
|
||||||
if (!pid) { root = node; } else { map.get(pid)?.children.push(node); }
|
if (!pid) { root = node; } else { map.get(pid)?.children.push(node); }
|
||||||
}
|
}
|
||||||
|
// annotate each node with total ancestor patch count
|
||||||
|
function annotate(node: TreeNode, inherited: number) {
|
||||||
|
node.ancestorPatchCount = inherited;
|
||||||
|
for (const child of node.children) annotate(child, inherited + node.version.patches.length);
|
||||||
|
}
|
||||||
|
if (root) annotate(root, 0);
|
||||||
return root;
|
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'];
|
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, onPropagate, colorIndex = 0 }: {
|
||||||
node: TreeNode; depth: number; selectedId: string | null;
|
node: TreeNode; depth: number; selectedId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
|
onCopyMarkdown?: (id: string) => void;
|
||||||
|
onPropagate?: (id: string) => void;
|
||||||
colorIndex?: number;
|
colorIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
const v = node.version;
|
const v = node.version;
|
||||||
const isRoot = !v.parent_version_id;
|
const isRoot = !v.parent_version_id;
|
||||||
const isSelected = v.id === selectedId;
|
const isSelected = v.id === selectedId;
|
||||||
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
|
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
|
||||||
|
const hasChildren = node.children.length > 0;
|
||||||
|
const canPropagate = hasChildren && v.patches.length > 0;
|
||||||
|
|
||||||
|
const handleCopy = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCopyMarkdown?.(v.id);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
@@ -57,7 +83,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
|||||||
width: 12, height: 12, display: 'flex', alignItems: 'center',
|
width: 12, height: 12, display: 'flex', alignItems: 'center',
|
||||||
justifyContent: 'center', cursor: 'pointer', background: 'none',
|
justifyContent: 'center', cursor: 'pointer', background: 'none',
|
||||||
border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0,
|
border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0,
|
||||||
opacity: node.children.length > 0 ? 1 : 0, pointerEvents: node.children.length > 0 ? 'auto' : 'none',
|
opacity: hasChildren ? 1 : 0, pointerEvents: hasChildren ? 'auto' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor"
|
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor"
|
||||||
@@ -83,6 +109,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
|||||||
{v.version_label || v.branch_name}
|
{v.version_label || v.branch_name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* own patch count */}
|
||||||
{v.patches.length > 0 && (
|
{v.patches.length > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 10, color: 'var(--text-faint)',
|
fontSize: 10, color: 'var(--text-faint)',
|
||||||
@@ -93,6 +120,53 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ancestor patch indicator on non-root branches */}
|
||||||
|
{!isRoot && node.ancestorPatchCount > 0 && (
|
||||||
|
<span
|
||||||
|
title={`${node.ancestorPatchCount} patch${node.ancestorPatchCount !== 1 ? 'es' : ''} inherited from ancestors`}
|
||||||
|
style={{
|
||||||
|
fontSize: 10, color: '#2563eb',
|
||||||
|
background: '#eff6ff', padding: '1px 4px',
|
||||||
|
borderRadius: 3, flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↑{node.ancestorPatchCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hovered && onCopyMarkdown && (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
title="Copy Markdown"
|
||||||
|
aria-label="Copy Markdown"
|
||||||
|
style={{
|
||||||
|
width: 18, height: 18, display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'center', cursor: 'pointer', background: 'none',
|
||||||
|
border: 'none', padding: 0, color: copied ? '#059669' : 'var(--text-faint)',
|
||||||
|
flexShrink: 0, borderRadius: 3, fontSize: 11, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? '✓' : '⎘'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* propagate button: version has own patches and has children */}
|
||||||
|
{hovered && canPropagate && onPropagate && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onPropagate(v.id); }}
|
||||||
|
title={`Propagate ${v.patches.length} patch${v.patches.length !== 1 ? 'es' : ''} to children`}
|
||||||
|
aria-label="Propagate patches to children"
|
||||||
|
style={{
|
||||||
|
width: 18, height: 18, display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'center', cursor: 'pointer', background: 'none',
|
||||||
|
border: 'none', padding: 0, color: '#7c3aed',
|
||||||
|
flexShrink: 0, borderRadius: 3, fontSize: 12, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isRoot && onDelete && hovered && (
|
{!isRoot && onDelete && hovered && (
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
|
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
|
||||||
@@ -110,7 +184,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && node.children.length > 0 && (
|
{open && hasChildren && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginLeft: depth > 0 ? 22 : 14,
|
marginLeft: depth > 0 ? 22 : 14,
|
||||||
borderLeft: `1px solid var(--border)`,
|
borderLeft: `1px solid var(--border)`,
|
||||||
@@ -124,6 +198,8 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
onCopyMarkdown={onCopyMarkdown}
|
||||||
|
onPropagate={onPropagate}
|
||||||
colorIndex={depth === 0 ? i + 1 : colorIndex}
|
colorIndex={depth === 0 ? i + 1 : colorIndex}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -133,16 +209,18 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion }: {
|
export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion, onCopyMarkdown, onPropagate }: {
|
||||||
versions: Version[]; selectedVersionId: string | null;
|
versions: Version[]; selectedVersionId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onDeleteVersion?: (id: string) => void;
|
onDeleteVersion?: (id: string) => void;
|
||||||
|
onCopyMarkdown?: (id: string) => void;
|
||||||
|
onPropagate?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const tree = buildTree(versions);
|
const tree = buildTree(versions);
|
||||||
if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
|
if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
|
||||||
return (
|
return (
|
||||||
<div style={{ paddingBottom: 8 }}>
|
<div style={{ paddingBottom: 8 }}>
|
||||||
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} />
|
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} onCopyMarkdown={onCopyMarkdown} onPropagate={onPropagate} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { Patch } from '@/libs/api';
|
import { Patch } from '@/libs/api';
|
||||||
|
|
||||||
const OP_SYMBOL: Record<string, string> = {
|
const OP_SYMBOL: Record<string, string> = {
|
||||||
replace_text: '±', remove_block: '−', reorder_section: '↕', boost_keyword: '+',
|
replace_text: '±', remove_block: '−', reorder_section: '↕', boost_keyword: '+',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DiffViewer({ patches }: { patches: Patch[] }) {
|
function PatchRow({ p, dim }: { p: Patch; dim?: boolean }) {
|
||||||
if (!patches.length) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
|
<div style={{ borderLeft: '2px solid var(--border-strong)', paddingLeft: 12, opacity: dim ? 0.6 : 1 }}>
|
||||||
No patches — identical to parent.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
{patches.map(p => (
|
|
||||||
<div key={p.id} style={{ borderLeft: '2px solid var(--border-strong)', paddingLeft: 12 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}>
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
{OP_SYMBOL[p.operation] ?? '·'} {p.target_path}
|
{OP_SYMBOL[p.operation] ?? '·'} {p.target_path}
|
||||||
@@ -36,7 +27,59 @@ export default function DiffViewer({ patches }: { patches: Patch[] }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiffViewer({
|
||||||
|
patches, inheritedPatches, ancestorLabel,
|
||||||
|
}: {
|
||||||
|
patches: Patch[];
|
||||||
|
inheritedPatches?: Patch[];
|
||||||
|
ancestorLabel?: string;
|
||||||
|
}) {
|
||||||
|
const [showInherited, setShowInherited] = useState(false);
|
||||||
|
const hasInherited = (inheritedPatches?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
if (!patches.length && !hasInherited) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
|
||||||
|
No patches — identical to parent.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{hasInherited && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 11, padding: '2px 8px' }}
|
||||||
|
onClick={() => setShowInherited(v => !v)}
|
||||||
|
>
|
||||||
|
{showInherited ? '▾' : '▸'} Inherited from ancestors ({inheritedPatches!.length})
|
||||||
|
</button>
|
||||||
|
{ancestorLabel && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>via {ancestorLabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showInherited && hasInherited && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 18, paddingBottom: 14, borderBottom: '1px dashed var(--border)' }}>
|
||||||
|
{inheritedPatches!.map(p => <PatchRow key={p.id} p={p} dim />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{patches.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{patches.map(p => <PatchRow key={p.id} p={p} />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '8px 0', color: 'var(--text-faint)', fontSize: 13 }}>
|
||||||
|
No direct patches on this branch.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
64
apps/webapp/src/components/cv/PDFPreview.tsx
Normal file
64
apps/webapp/src/components/cv/PDFPreview.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { previewVersionPdfUrl } from '@/libs/api';
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_token_pub='))?.split('=').slice(1).join('=') ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PDFPreview({ documentId, versionId }: { documentId: string; versionId: string }) {
|
||||||
|
const [src, setSrc] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const prevUrl = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const token = getToken();
|
||||||
|
fetch(previewVersionPdfUrl(documentId, versionId), {
|
||||||
|
headers: token ? { authorization: `Bearer ${decodeURIComponent(token)}` } : {},
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return r.blob();
|
||||||
|
})
|
||||||
|
.then(blob => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (prevUrl.current) URL.revokeObjectURL(prevUrl.current);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
prevUrl.current = url;
|
||||||
|
setSrc(url);
|
||||||
|
})
|
||||||
|
.catch(e => { if (!cancelled) setError(e.message); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [documentId, versionId]);
|
||||||
|
|
||||||
|
useEffect(() => () => { if (prevUrl.current) URL.revokeObjectURL(prevUrl.current); }, []);
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-faint)', fontSize: 13 }}>
|
||||||
|
Rendering PDF…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (error) return (
|
||||||
|
<div style={{ padding: 16, fontSize: 12, color: '#dc2626' }}>
|
||||||
|
Preview unavailable: {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!src) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={src}
|
||||||
|
style={{ width: '100%', height: '100%', border: 'none', borderRadius: 4 }}
|
||||||
|
title="CV Preview"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,7 +80,6 @@ export type PublicAsset = {
|
|||||||
version_id?: string | null;
|
version_id?: string | null;
|
||||||
submission_id?: string | null;
|
submission_id?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
paperless_share_url?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PublicAssetAnalytics = {
|
export type PublicAssetAnalytics = {
|
||||||
@@ -145,6 +144,15 @@ export async function uploadDocument(title: string, description: string | null,
|
|||||||
export const downloadVersionUrl = (documentId: string, versionId: string): string =>
|
export const downloadVersionUrl = (documentId: string, versionId: string): string =>
|
||||||
`${API}/api/v1/documents/${documentId}/versions/${versionId}/download`;
|
`${API}/api/v1/documents/${documentId}/versions/${versionId}/download`;
|
||||||
|
|
||||||
|
export const previewVersionPdfUrl = (documentId: string, versionId: string): string =>
|
||||||
|
`${API}/api/v1/documents/${documentId}/versions/${versionId}/preview`;
|
||||||
|
|
||||||
|
export async function uploadDocxToBranch(documentId: string, versionId: string, file: File): Promise<Version> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return req<Version>(`/api/v1/documents/${documentId}/versions/${versionId}/upload`, { method: 'POST', body: form });
|
||||||
|
}
|
||||||
|
|
||||||
export async function createBranch(
|
export async function createBranch(
|
||||||
parentVersionId: string,
|
parentVersionId: string,
|
||||||
branchName: string,
|
branchName: string,
|
||||||
@@ -230,23 +238,11 @@ export async function publishVersion(
|
|||||||
versionId?: string | null,
|
versionId?: string | null,
|
||||||
submissionId?: string | null,
|
submissionId?: string | null,
|
||||||
slug?: string | null,
|
slug?: string | null,
|
||||||
expiresAt?: string | null,
|
|
||||||
): Promise<PublicAsset> {
|
): Promise<PublicAsset> {
|
||||||
return req<PublicAsset>('/api/v1/public/publish', {
|
return req<PublicAsset>('/api/v1/public/publish', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null, expires_at: expiresAt ?? null }),
|
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }),
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createShareLink(
|
|
||||||
slug: string,
|
|
||||||
expirationDate?: string | null,
|
|
||||||
): Promise<PublicAsset> {
|
|
||||||
return req<PublicAsset>(`/api/v1/public/${encodeURIComponent(slug)}/share-links`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ expiration_date: expirationDate ?? null }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
|
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
|
||||||
|
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
|
||||||
|
|
||||||
async function verifySession(token: string): Promise<boolean> {
|
async function verifySession(token: string): Promise<boolean> {
|
||||||
const lastDot = token.lastIndexOf('.');
|
const lastDot = token.lastIndexOf('.');
|
||||||
@@ -13,7 +14,11 @@ async function verifySession(token: string): Promise<boolean> {
|
|||||||
{ name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
|
{ name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
|
||||||
);
|
);
|
||||||
const sigBytes = new Uint8Array((sigHex.match(/.{1,2}/g) ?? []).map(b => parseInt(b, 16)));
|
const sigBytes = new Uint8Array((sigHex.match(/.{1,2}/g) ?? []).map(b => parseInt(b, 16)));
|
||||||
return await globalThis.crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload));
|
const valid = await globalThis.crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload));
|
||||||
|
if (!valid) return false;
|
||||||
|
// check expiry from embedded timestamp (payload = "username:timestamp")
|
||||||
|
const ts = parseInt(payload.split(':').pop() ?? '0', 10);
|
||||||
|
return !isNaN(ts) && Date.now() - ts < SESSION_MAX_AGE;
|
||||||
} catch { return false; }
|
} catch { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ export interface PublicAsset {
|
|||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
viewCount: number;
|
viewCount: number;
|
||||||
url?: string | null;
|
|
||||||
paperlessShareUrl?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AISuggestion {
|
export interface AISuggestion {
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.core.config import Settings
|
|
||||||
|
|
||||||
|
|
||||||
class PaperlessClient:
|
|
||||||
def __init__(self, base_url: str, token: str) -> None:
|
|
||||||
self._base = base_url.rstrip("/")
|
|
||||||
self._headers = {"Authorization": f"Token {token}"}
|
|
||||||
|
|
||||||
def _get(self, path: str, **params) -> dict:
|
|
||||||
r = httpx.get(f"{self._base}{path}", headers=self._headers, params=params, timeout=30)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def _post(self, path: str, **kwargs) -> dict:
|
|
||||||
r = httpx.post(f"{self._base}{path}", headers=self._headers, timeout=30, **kwargs)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def _delete(self, path: str) -> None:
|
|
||||||
r = httpx.delete(f"{self._base}{path}", headers=self._headers, timeout=30)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
def upload_document(self, pdf_bytes: bytes, title: str, tags: list[int] | None = None) -> int:
|
|
||||||
"""Upload PDF to paperless and return the created document_id (polls until task completes)."""
|
|
||||||
files = {"document": (f"{title}.pdf", pdf_bytes, "application/pdf")}
|
|
||||||
data: dict = {"title": title}
|
|
||||||
if tags:
|
|
||||||
data["tags"] = tags
|
|
||||||
resp = self._post("/api/documents/post_document/", files=files, data=data)
|
|
||||||
task_id = resp if isinstance(resp, str) else resp.get("task_id", resp)
|
|
||||||
return self._poll_task(str(task_id))
|
|
||||||
|
|
||||||
def _poll_task(self, task_id: str, max_wait: int = 60) -> int:
|
|
||||||
delay = 2
|
|
||||||
elapsed = 0
|
|
||||||
while elapsed < max_wait:
|
|
||||||
time.sleep(delay)
|
|
||||||
elapsed += delay
|
|
||||||
result = self._get("/api/tasks/", task_id=task_id)
|
|
||||||
tasks = result if isinstance(result, list) else result.get("results", [])
|
|
||||||
if not tasks:
|
|
||||||
delay = min(delay * 2, 10)
|
|
||||||
continue
|
|
||||||
task = tasks[0]
|
|
||||||
if task.get("status") == "SUCCESS":
|
|
||||||
return int(task["related_document"])
|
|
||||||
if task.get("status") in ("FAILURE", "REVOKED"):
|
|
||||||
raise RuntimeError(f"Paperless task {task_id} failed: {task.get('result')}")
|
|
||||||
delay = min(delay * 2, 10)
|
|
||||||
raise TimeoutError(f"Paperless task {task_id} did not complete within {max_wait}s")
|
|
||||||
|
|
||||||
def create_share_link(
|
|
||||||
self, document_id: int, expiration: datetime | None = None
|
|
||||||
) -> tuple[int, str]:
|
|
||||||
"""Create a share link for document_id. Returns (share_link_id, full share URL)."""
|
|
||||||
payload: dict = {"document": document_id}
|
|
||||||
if expiration:
|
|
||||||
payload["expiration_date"] = expiration.isoformat()
|
|
||||||
resp = self._post("/api/share_links/", json=payload)
|
|
||||||
slug = resp["slug"]
|
|
||||||
link_id = int(resp["id"])
|
|
||||||
return link_id, f"{self._base}/share/{slug}"
|
|
||||||
|
|
||||||
def get_share_links(self, document_id: int) -> list[dict]:
|
|
||||||
return self._get(f"/api/documents/{document_id}/share_links/").get("results", [])
|
|
||||||
|
|
||||||
def delete_share_link(self, share_link_id: int) -> None:
|
|
||||||
self._delete(f"/api/share_links/{share_link_id}/")
|
|
||||||
|
|
||||||
def delete_document(self, document_id: int) -> None:
|
|
||||||
self._delete(f"/api/documents/{document_id}/")
|
|
||||||
|
|
||||||
|
|
||||||
def get_paperless_client(settings: "Settings") -> PaperlessClient | None:
|
|
||||||
if not settings.paperless_enabled:
|
|
||||||
return None
|
|
||||||
if not settings.paperless_base_url or not settings.paperless_token:
|
|
||||||
return None
|
|
||||||
return PaperlessClient(settings.paperless_base_url, settings.paperless_token)
|
|
||||||
Reference in New Issue
Block a user