Merge pull request #787 from christianlouis/copilot/add-document-comments-annotations
feat: add document comments and annotations
This commit is contained in:
@@ -14,6 +14,7 @@ from app.api.azure import router as azure_router
|
||||
from app.api.backup import router as backup_router
|
||||
from app.api.billing import router as billing_router
|
||||
from app.api.classification_rules import router as classification_rules_router
|
||||
from app.api.comments import router as comments_router
|
||||
from app.api.compliance import router as compliance_router
|
||||
from app.api.database import router as database_router
|
||||
from app.api.diagnostic import router as diagnostic_router
|
||||
@@ -108,3 +109,4 @@ router.include_router(system_reset_router)
|
||||
router.include_router(translation_router)
|
||||
router.include_router(classification_rules_router)
|
||||
router.include_router(automation_router)
|
||||
router.include_router(comments_router)
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
"""Document comments and annotations API endpoints.
|
||||
|
||||
Provides CRUD operations for threaded comments on documents,
|
||||
text annotations on PDF pages, and a list of mentionable users
|
||||
for the @mention feature.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_current_user_id, require_login
|
||||
from app.database import get_db
|
||||
from app.models import DocumentAnnotation, DocumentComment, FileRecord, UserProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["comments"])
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
# Constraints
|
||||
MAX_COMMENT_BODY_LENGTH = 10_000
|
||||
MAX_ANNOTATION_CONTENT_LENGTH = 5_000
|
||||
|
||||
# Allowed annotation types
|
||||
ALLOWED_ANNOTATION_TYPES = frozenset({"note", "highlight", "underline", "strikethrough"})
|
||||
|
||||
# Simple pattern for @mentions – matches @username tokens inside comment body
|
||||
_MENTION_PATTERN = re.compile(r"@([\w.\-]+)")
|
||||
|
||||
|
||||
def _extract_mentions(body: str) -> list[str]:
|
||||
"""Extract unique @mentioned usernames from a comment body.
|
||||
|
||||
Args:
|
||||
body: The raw comment text.
|
||||
|
||||
Returns:
|
||||
A deduplicated list of mentioned usernames (without the ``@`` prefix).
|
||||
"""
|
||||
return list(dict.fromkeys(_MENTION_PATTERN.findall(body)))
|
||||
|
||||
|
||||
def _serialize_comment(c: DocumentComment) -> dict[str, Any]:
|
||||
"""Serialize a DocumentComment to a JSON-friendly dict.
|
||||
|
||||
Args:
|
||||
c: The comment model instance.
|
||||
|
||||
Returns:
|
||||
A dictionary representation of the comment.
|
||||
"""
|
||||
mentions: list[str] = []
|
||||
if c.mentions:
|
||||
try:
|
||||
mentions = json.loads(c.mentions)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return {
|
||||
"id": c.id,
|
||||
"file_id": c.file_id,
|
||||
"user_id": c.user_id,
|
||||
"parent_id": c.parent_id,
|
||||
"body": c.body,
|
||||
"mentions": mentions,
|
||||
"is_resolved": c.is_resolved,
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_annotation(a: DocumentAnnotation) -> dict[str, Any]:
|
||||
"""Serialize a DocumentAnnotation to a JSON-friendly dict.
|
||||
|
||||
Args:
|
||||
a: The annotation model instance.
|
||||
|
||||
Returns:
|
||||
A dictionary representation of the annotation.
|
||||
"""
|
||||
return {
|
||||
"id": a.id,
|
||||
"file_id": a.file_id,
|
||||
"user_id": a.user_id,
|
||||
"page": a.page,
|
||||
"x": a.x,
|
||||
"y": a.y,
|
||||
"width": a.width,
|
||||
"height": a.height,
|
||||
"content": a.content,
|
||||
"annotation_type": a.annotation_type,
|
||||
"color": a.color,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _build_thread_tree(comments: list[DocumentComment]) -> list[dict[str, Any]]:
|
||||
"""Organize a flat list of comments into a threaded tree structure.
|
||||
|
||||
Top-level comments (``parent_id is None``) appear as root nodes.
|
||||
Replies are nested inside their parent's ``replies`` list.
|
||||
|
||||
Args:
|
||||
comments: All comments for a given document, ordered by ``created_at``.
|
||||
|
||||
Returns:
|
||||
A list of root-level comment dicts, each with a ``replies`` key.
|
||||
"""
|
||||
by_id: dict[int, dict[str, Any]] = {}
|
||||
roots: list[dict[str, Any]] = []
|
||||
|
||||
for c in comments:
|
||||
node = _serialize_comment(c)
|
||||
node["replies"] = []
|
||||
by_id[c.id] = node
|
||||
|
||||
for c in comments:
|
||||
node = by_id[c.id]
|
||||
if c.parent_id and c.parent_id in by_id:
|
||||
by_id[c.parent_id]["replies"].append(node)
|
||||
else:
|
||||
roots.append(node)
|
||||
|
||||
return roots
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comments endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/comments")
|
||||
@require_login
|
||||
def list_comments(request: Request, file_id: int, db: DbSession):
|
||||
"""List all comments for a document, organized into threads.
|
||||
|
||||
Returns a threaded tree where top-level comments contain nested
|
||||
``replies``.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
|
||||
Returns:
|
||||
A dict with ``file_id``, ``comments`` (threaded), and ``total``.
|
||||
"""
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
comments = (
|
||||
db.query(DocumentComment).filter(DocumentComment.file_id == file_id).order_by(DocumentComment.created_at).all()
|
||||
)
|
||||
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"comments": _build_thread_tree(comments),
|
||||
"total": len(comments),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/comments", status_code=status.HTTP_201_CREATED)
|
||||
@require_login
|
||||
def create_comment(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
db: DbSession,
|
||||
body: str = Body(..., embed=True),
|
||||
parent_id: int | None = Body(None, embed=True),
|
||||
):
|
||||
"""Create a new comment on a document.
|
||||
|
||||
Automatically extracts @mentions from the comment body and stores
|
||||
them for later notification or UI highlighting.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document to comment on.
|
||||
|
||||
Request body (JSON):
|
||||
body: Comment text (required, max 10 000 characters).
|
||||
parent_id: ID of the parent comment for threaded replies (optional).
|
||||
|
||||
Returns:
|
||||
The created comment object.
|
||||
"""
|
||||
user_id = get_current_user_id(request)
|
||||
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
if not isinstance(body, str) or not body.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="body is required and must be non-empty",
|
||||
)
|
||||
body = body.strip()
|
||||
if len(body) > MAX_COMMENT_BODY_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"body must be at most {MAX_COMMENT_BODY_LENGTH} characters",
|
||||
)
|
||||
|
||||
if parent_id is not None:
|
||||
parent = (
|
||||
db.query(DocumentComment)
|
||||
.filter(DocumentComment.id == parent_id, DocumentComment.file_id == file_id)
|
||||
.first()
|
||||
)
|
||||
if not parent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent comment not found",
|
||||
)
|
||||
|
||||
mentions = _extract_mentions(body)
|
||||
|
||||
comment = DocumentComment(
|
||||
file_id=file_id,
|
||||
user_id=user_id,
|
||||
parent_id=parent_id,
|
||||
body=body,
|
||||
mentions=json.dumps(mentions) if mentions else None,
|
||||
)
|
||||
|
||||
try:
|
||||
db.add(comment)
|
||||
db.commit()
|
||||
db.refresh(comment)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to create comment on file_id=%s", file_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create comment",
|
||||
)
|
||||
|
||||
logger.info("Comment created: id=%s, file_id=%s, user=%s", comment.id, file_id, user_id)
|
||||
return _serialize_comment(comment)
|
||||
|
||||
|
||||
@router.put("/files/{file_id}/comments/{comment_id}")
|
||||
@require_login
|
||||
def update_comment(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
comment_id: int,
|
||||
db: DbSession,
|
||||
body: str = Body(..., embed=True),
|
||||
):
|
||||
"""Update the body of an existing comment.
|
||||
|
||||
Only the comment author may update the comment. Mentions are
|
||||
re-extracted from the updated body.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
comment_id: The ID of the comment to update.
|
||||
|
||||
Request body (JSON):
|
||||
body: New comment text (required).
|
||||
|
||||
Returns:
|
||||
The updated comment object.
|
||||
"""
|
||||
user_id = get_current_user_id(request)
|
||||
|
||||
comment = (
|
||||
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
|
||||
)
|
||||
if not comment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||
|
||||
if comment.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own comments")
|
||||
|
||||
if not isinstance(body, str) or not body.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="body is required and must be non-empty",
|
||||
)
|
||||
body = body.strip()
|
||||
if len(body) > MAX_COMMENT_BODY_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"body must be at most {MAX_COMMENT_BODY_LENGTH} characters",
|
||||
)
|
||||
|
||||
mentions = _extract_mentions(body)
|
||||
comment.body = body
|
||||
comment.mentions = json.dumps(mentions) if mentions else None
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(comment)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to update comment id=%s", comment_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update comment",
|
||||
)
|
||||
|
||||
logger.info("Comment updated: id=%s, user=%s", comment_id, user_id)
|
||||
return _serialize_comment(comment)
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@require_login
|
||||
def delete_comment(request: Request, file_id: int, comment_id: int, db: DbSession):
|
||||
"""Delete a comment.
|
||||
|
||||
Only the comment author may delete the comment. Replies to the
|
||||
deleted comment are **not** removed — they become orphaned root
|
||||
comments so that conversation context is preserved.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
comment_id: The ID of the comment to delete.
|
||||
"""
|
||||
user_id = get_current_user_id(request)
|
||||
|
||||
comment = (
|
||||
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
|
||||
)
|
||||
if not comment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||
|
||||
if comment.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own comments")
|
||||
|
||||
try:
|
||||
db.delete(comment)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to delete comment id=%s", comment_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete comment",
|
||||
)
|
||||
|
||||
logger.info("Comment deleted: id=%s, user=%s", comment_id, user_id)
|
||||
|
||||
|
||||
@router.patch("/files/{file_id}/comments/{comment_id}/resolve")
|
||||
@require_login
|
||||
def resolve_comment(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
comment_id: int,
|
||||
db: DbSession,
|
||||
is_resolved: bool = Body(..., embed=True),
|
||||
):
|
||||
"""Mark a top-level comment thread as resolved or unresolved.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
comment_id: The ID of the comment to resolve / unresolve.
|
||||
|
||||
Request body (JSON):
|
||||
is_resolved: ``true`` to resolve, ``false`` to unresolve.
|
||||
|
||||
Returns:
|
||||
The updated comment object.
|
||||
"""
|
||||
comment = (
|
||||
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
|
||||
)
|
||||
if not comment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||
|
||||
comment.is_resolved = is_resolved
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(comment)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to resolve comment id=%s", comment_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update comment",
|
||||
)
|
||||
|
||||
logger.info("Comment %s: id=%s", "resolved" if is_resolved else "unresolved", comment_id)
|
||||
return _serialize_comment(comment)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Annotations endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/annotations")
|
||||
@require_login
|
||||
def list_annotations(request: Request, file_id: int, db: DbSession):
|
||||
"""List all annotations for a document.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
|
||||
Returns:
|
||||
A dict with ``file_id``, ``annotations``, and ``total``.
|
||||
"""
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
annotations = (
|
||||
db.query(DocumentAnnotation)
|
||||
.filter(DocumentAnnotation.file_id == file_id)
|
||||
.order_by(DocumentAnnotation.page, DocumentAnnotation.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"annotations": [_serialize_annotation(a) for a in annotations],
|
||||
"total": len(annotations),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/annotations", status_code=status.HTTP_201_CREATED)
|
||||
@require_login
|
||||
def create_annotation(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
db: DbSession,
|
||||
page: int = Body(..., embed=True),
|
||||
x: float = Body(..., embed=True),
|
||||
y: float = Body(..., embed=True),
|
||||
content: str = Body(..., embed=True),
|
||||
width: float = Body(0, embed=True),
|
||||
height: float = Body(0, embed=True),
|
||||
annotation_type: str = Body("note", embed=True),
|
||||
color: str | None = Body(None, embed=True),
|
||||
):
|
||||
"""Create a new annotation on a PDF page.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
|
||||
Request body (JSON):
|
||||
page: Page number (1-based, required).
|
||||
x: Horizontal position on the page (required).
|
||||
y: Vertical position on the page (required).
|
||||
content: Annotation text (required, max 5 000 characters).
|
||||
width: Width of the annotation bounding box (default 0).
|
||||
height: Height of the annotation bounding box (default 0).
|
||||
annotation_type: One of ``note``, ``highlight``, ``underline``,
|
||||
``strikethrough`` (default ``note``).
|
||||
color: Optional CSS colour string (e.g. ``#ff0000``).
|
||||
|
||||
Returns:
|
||||
The created annotation object.
|
||||
"""
|
||||
user_id = get_current_user_id(request)
|
||||
|
||||
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="content is required and must be non-empty",
|
||||
)
|
||||
content = content.strip()
|
||||
if len(content) > MAX_ANNOTATION_CONTENT_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"content must be at most {MAX_ANNOTATION_CONTENT_LENGTH} characters",
|
||||
)
|
||||
|
||||
if page < 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="page must be >= 1",
|
||||
)
|
||||
|
||||
if annotation_type not in ALLOWED_ANNOTATION_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"annotation_type must be one of: {', '.join(sorted(ALLOWED_ANNOTATION_TYPES))}",
|
||||
)
|
||||
|
||||
annotation = DocumentAnnotation(
|
||||
file_id=file_id,
|
||||
user_id=user_id,
|
||||
page=page,
|
||||
x=x,
|
||||
y=y,
|
||||
width=width,
|
||||
height=height,
|
||||
content=content,
|
||||
annotation_type=annotation_type,
|
||||
color=color,
|
||||
)
|
||||
|
||||
try:
|
||||
db.add(annotation)
|
||||
db.commit()
|
||||
db.refresh(annotation)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to create annotation on file_id=%s", file_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create annotation",
|
||||
)
|
||||
|
||||
logger.info("Annotation created: id=%s, file_id=%s, user=%s", annotation.id, file_id, user_id)
|
||||
return _serialize_annotation(annotation)
|
||||
|
||||
|
||||
@router.put("/files/{file_id}/annotations/{annotation_id}")
|
||||
@require_login
|
||||
def update_annotation(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
annotation_id: int,
|
||||
db: DbSession,
|
||||
content: str | None = Body(None, embed=True),
|
||||
x: float | None = Body(None, embed=True),
|
||||
y: float | None = Body(None, embed=True),
|
||||
width: float | None = Body(None, embed=True),
|
||||
height: float | None = Body(None, embed=True),
|
||||
annotation_type: str | None = Body(None, embed=True),
|
||||
color: str | None = Body(None, embed=True),
|
||||
):
|
||||
"""Update an existing annotation.
|
||||
|
||||
Only the annotation author may update the annotation.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
annotation_id: The ID of the annotation to update.
|
||||
|
||||
Request body (JSON):
|
||||
Any subset of ``content``, ``x``, ``y``, ``width``, ``height``,
|
||||
``annotation_type``, and ``color``.
|
||||
|
||||
Returns:
|
||||
The updated annotation object.
|
||||
"""
|
||||
user_id = get_current_user_id(request)
|
||||
|
||||
annotation = (
|
||||
db.query(DocumentAnnotation)
|
||||
.filter(DocumentAnnotation.id == annotation_id, DocumentAnnotation.file_id == file_id)
|
||||
.first()
|
||||
)
|
||||
if not annotation:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotation not found")
|
||||
|
||||
if annotation.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own annotations")
|
||||
|
||||
if content is not None:
|
||||
content = content.strip() if isinstance(content, str) else ""
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="content must be non-empty",
|
||||
)
|
||||
if len(content) > MAX_ANNOTATION_CONTENT_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"content must be at most {MAX_ANNOTATION_CONTENT_LENGTH} characters",
|
||||
)
|
||||
annotation.content = content
|
||||
|
||||
if x is not None:
|
||||
annotation.x = x
|
||||
if y is not None:
|
||||
annotation.y = y
|
||||
if width is not None:
|
||||
annotation.width = width
|
||||
if height is not None:
|
||||
annotation.height = height
|
||||
if annotation_type is not None:
|
||||
if annotation_type not in ALLOWED_ANNOTATION_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"annotation_type must be one of: {', '.join(sorted(ALLOWED_ANNOTATION_TYPES))}",
|
||||
)
|
||||
annotation.annotation_type = annotation_type
|
||||
if color is not None:
|
||||
annotation.color = color
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(annotation)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to update annotation id=%s", annotation_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update annotation",
|
||||
)
|
||||
|
||||
logger.info("Annotation updated: id=%s, user=%s", annotation_id, user_id)
|
||||
return _serialize_annotation(annotation)
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}/annotations/{annotation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@require_login
|
||||
def delete_annotation(request: Request, file_id: int, annotation_id: int, db: DbSession):
|
||||
"""Delete an annotation.
|
||||
|
||||
Only the annotation author may delete the annotation.
|
||||
|
||||
Path Parameters:
|
||||
file_id: The ID of the document.
|
||||
annotation_id: The ID of the annotation to delete.
|
||||
"""
|
||||
user_id = get_current_user_id(request)
|
||||
|
||||
annotation = (
|
||||
db.query(DocumentAnnotation)
|
||||
.filter(DocumentAnnotation.id == annotation_id, DocumentAnnotation.file_id == file_id)
|
||||
.first()
|
||||
)
|
||||
if not annotation:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotation not found")
|
||||
|
||||
if annotation.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own annotations")
|
||||
|
||||
try:
|
||||
db.delete(annotation)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to delete annotation id=%s", annotation_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete annotation",
|
||||
)
|
||||
|
||||
logger.info("Annotation deleted: id=%s, user=%s", annotation_id, user_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mentionable users endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/users/mentionable")
|
||||
@require_login
|
||||
def list_mentionable_users(request: Request, db: DbSession):
|
||||
"""List users that can be @mentioned in comments.
|
||||
|
||||
Returns all user profiles that are not blocked, sorted by
|
||||
``display_name``.
|
||||
|
||||
Returns:
|
||||
A list of ``{user_id, display_name}`` objects.
|
||||
"""
|
||||
profiles = db.query(UserProfile).filter(UserProfile.is_blocked.is_(False)).order_by(UserProfile.display_name).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"user_id": p.user_id,
|
||||
"display_name": p.display_name or p.user_id,
|
||||
}
|
||||
for p in profiles
|
||||
]
|
||||
@@ -1192,3 +1192,48 @@ class PipelineRoutingRule(Base):
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class DocumentComment(Base):
|
||||
"""Threaded comment on a document.
|
||||
|
||||
Supports threaded replies via ``parent_id`` and @mentions via the
|
||||
``mentions`` column (comma-separated user identifiers).
|
||||
"""
|
||||
|
||||
__tablename__ = "document_comments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
|
||||
user_id = Column(String, nullable=False, index=True)
|
||||
parent_id = Column(Integer, ForeignKey("document_comments.id"), nullable=True, index=True)
|
||||
body = Column(Text, nullable=False)
|
||||
mentions = Column(Text, nullable=True)
|
||||
is_resolved = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class DocumentAnnotation(Base):
|
||||
"""Text annotation on a specific page and position of a PDF document.
|
||||
|
||||
Stores the bounding-box coordinates (``x``, ``y``, ``width``,
|
||||
``height``) relative to the page dimensions so that the annotation
|
||||
can be rendered on top of the PDF viewer.
|
||||
"""
|
||||
|
||||
__tablename__ = "document_annotations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
|
||||
user_id = Column(String, nullable=False, index=True)
|
||||
page = Column(Integer, nullable=False)
|
||||
x = Column(Float, nullable=False)
|
||||
y = Column(Float, nullable=False)
|
||||
width = Column(Float, nullable=False, default=0)
|
||||
height = Column(Float, nullable=False, default=0)
|
||||
content = Column(Text, nullable=False)
|
||||
annotation_type = Column(String(50), nullable=False, default="note", server_default="note")
|
||||
color = Column(String(20), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
+199
@@ -2909,3 +2909,202 @@ Move original files to a reimport folder, wipe everything, and configure the rei
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comments & Annotations
|
||||
|
||||
Threaded comments and PDF annotations for document collaboration.
|
||||
|
||||
### List Comments
|
||||
|
||||
**GET** `/api/files/{file_id}/comments`
|
||||
|
||||
Returns all comments for a document, organized into a threaded tree.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"file_id": 1,
|
||||
"comments": [
|
||||
{
|
||||
"id": 1,
|
||||
"file_id": 1,
|
||||
"user_id": "alice",
|
||||
"parent_id": null,
|
||||
"body": "Please review section 3.",
|
||||
"mentions": ["bob"],
|
||||
"is_resolved": false,
|
||||
"created_at": "2026-03-21T12:00:00+00:00",
|
||||
"updated_at": "2026-03-21T12:00:00+00:00",
|
||||
"replies": [
|
||||
{
|
||||
"id": 2,
|
||||
"file_id": 1,
|
||||
"user_id": "bob",
|
||||
"parent_id": 1,
|
||||
"body": "Done!",
|
||||
"mentions": [],
|
||||
"is_resolved": false,
|
||||
"created_at": "2026-03-21T12:05:00+00:00",
|
||||
"updated_at": "2026-03-21T12:05:00+00:00",
|
||||
"replies": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"total": 2
|
||||
}
|
||||
```
|
||||
|
||||
### Create Comment
|
||||
|
||||
**POST** `/api/files/{file_id}/comments`
|
||||
|
||||
Create a new comment on a document. @mentions are automatically extracted from the body.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"body": "Hey @bob, please review this section.",
|
||||
"parent_id": null
|
||||
}
|
||||
```
|
||||
|
||||
**Response (201):**
|
||||
```json
|
||||
{
|
||||
"id": 3,
|
||||
"file_id": 1,
|
||||
"user_id": "alice",
|
||||
"parent_id": null,
|
||||
"body": "Hey @bob, please review this section.",
|
||||
"mentions": ["bob"],
|
||||
"is_resolved": false,
|
||||
"created_at": "2026-03-21T12:10:00+00:00",
|
||||
"updated_at": "2026-03-21T12:10:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### Update Comment
|
||||
|
||||
**PUT** `/api/files/{file_id}/comments/{comment_id}`
|
||||
|
||||
Update the body of an existing comment. Only the comment author may update it.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"body": "Updated comment text @charlie"
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Comment
|
||||
|
||||
**DELETE** `/api/files/{file_id}/comments/{comment_id}`
|
||||
|
||||
Delete a comment. Only the comment author may delete it.
|
||||
|
||||
**Response:** `204 No Content`
|
||||
|
||||
### Resolve / Unresolve Comment
|
||||
|
||||
**PATCH** `/api/files/{file_id}/comments/{comment_id}/resolve`
|
||||
|
||||
Mark a comment thread as resolved or unresolved.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"is_resolved": true
|
||||
}
|
||||
```
|
||||
|
||||
### List Annotations
|
||||
|
||||
**GET** `/api/files/{file_id}/annotations`
|
||||
|
||||
Returns all PDF page annotations for a document, ordered by page then creation time.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"file_id": 1,
|
||||
"annotations": [
|
||||
{
|
||||
"id": 1,
|
||||
"file_id": 1,
|
||||
"user_id": "alice",
|
||||
"page": 1,
|
||||
"x": 100.0,
|
||||
"y": 200.0,
|
||||
"width": 150.0,
|
||||
"height": 20.0,
|
||||
"content": "Important paragraph",
|
||||
"annotation_type": "highlight",
|
||||
"color": "#ffff00",
|
||||
"created_at": "2026-03-21T12:00:00+00:00",
|
||||
"updated_at": "2026-03-21T12:00:00+00:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Create Annotation
|
||||
|
||||
**POST** `/api/files/{file_id}/annotations`
|
||||
|
||||
Create a new annotation on a PDF page.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"x": 100.0,
|
||||
"y": 200.0,
|
||||
"width": 150.0,
|
||||
"height": 20.0,
|
||||
"content": "Important paragraph",
|
||||
"annotation_type": "highlight",
|
||||
"color": "#ffff00"
|
||||
}
|
||||
```
|
||||
|
||||
Allowed `annotation_type` values: `note`, `highlight`, `underline`, `strikethrough`.
|
||||
|
||||
### Update Annotation
|
||||
|
||||
**PUT** `/api/files/{file_id}/annotations/{annotation_id}`
|
||||
|
||||
Update an existing annotation. Only the annotation author may update it.
|
||||
|
||||
**Request** (all fields optional):
|
||||
```json
|
||||
{
|
||||
"content": "Updated note",
|
||||
"color": "#00ff00"
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Annotation
|
||||
|
||||
**DELETE** `/api/files/{file_id}/annotations/{annotation_id}`
|
||||
|
||||
Delete an annotation. Only the annotation author may delete it.
|
||||
|
||||
**Response:** `204 No Content`
|
||||
|
||||
### List Mentionable Users
|
||||
|
||||
**GET** `/api/users/mentionable`
|
||||
|
||||
Returns all non-blocked user profiles for the @mention autocomplete.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
[
|
||||
{ "user_id": "alice", "display_name": "Alice Anderson" },
|
||||
{ "user_id": "bob", "display_name": "Bob Baker" }
|
||||
]
|
||||
```
|
||||
|
||||
@@ -705,6 +705,64 @@ You can test your rules without actually routing a document using the
|
||||
**evaluate** endpoint (`POST /api/routing-rules/evaluate`). For the full
|
||||
API reference, see [API Documentation](API.md#routing-rules).
|
||||
|
||||
## Comments & Annotations
|
||||
|
||||
The file detail page includes a **collaboration panel** for threaded
|
||||
comments and PDF annotations, allowing team members to discuss documents
|
||||
directly within DocuElevate.
|
||||
|
||||
### Comments
|
||||
|
||||
The **Comments** panel is on the left side of the collaboration section at
|
||||
the bottom of the file detail page.
|
||||
|
||||
#### Viewing Comments
|
||||
Open any file's detail page (`/files/{id}/detail`). Existing comments load
|
||||
automatically, displayed in a threaded tree — replies are nested under their
|
||||
parent.
|
||||
|
||||
#### Adding a Comment
|
||||
1. Type your comment in the text area at the bottom of the Comments panel.
|
||||
2. Use `@username` to mention another user — an autocomplete dropdown
|
||||
appears as you type after the `@` symbol. Use arrow keys and Enter to
|
||||
select a user.
|
||||
3. Click **Add comment** to post.
|
||||
|
||||
#### Replying to a Comment
|
||||
Click the **Reply** button on any top-level comment. A reply text area
|
||||
appears inline; type your response and click **Reply** to post.
|
||||
|
||||
#### Editing & Deleting
|
||||
You can edit or delete your own comments using the **Edit** and trash
|
||||
buttons. Edits re-extract @mentions automatically.
|
||||
|
||||
#### Resolving Threads
|
||||
Click **Resolve** on a top-level comment to mark the thread as resolved
|
||||
(shown with a green badge). Click **Reopen** to re-open it.
|
||||
|
||||
### Annotations
|
||||
|
||||
The **Annotations** panel is on the right side of the collaboration
|
||||
section.
|
||||
|
||||
#### Adding an Annotation
|
||||
1. Type the annotation content in the text area.
|
||||
2. Set the **Page** number the annotation refers to.
|
||||
3. Choose a **Type**: Note, Highlight, Underline, or Strikethrough.
|
||||
4. Pick a **Color** using the color picker.
|
||||
5. Click **Add annotation** to save.
|
||||
|
||||
#### Editing & Deleting
|
||||
You can edit or delete your own annotations using the pencil and trash
|
||||
buttons. When editing, you can also change the annotation type.
|
||||
|
||||
### @Mention Autocomplete
|
||||
|
||||
When typing `@` followed by characters in the comment input, an
|
||||
autocomplete dropdown shows matching users (sourced from the
|
||||
`/api/users/mentionable` endpoint). Navigate with arrow keys and press
|
||||
Enter or click to insert the mention.
|
||||
|
||||
## API Access
|
||||
|
||||
For programmatic access, DocuElevate provides a comprehensive REST API:
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// frontend/static/js/annotations.js
|
||||
// Annotations panel — CRUD for PDF page annotations
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var _fileId = null;
|
||||
var _currentUserId = null;
|
||||
var _i18n = {};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialisation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bootstrap the annotations panel.
|
||||
* @param {number} fileId
|
||||
* @param {string} currentUserId
|
||||
* @param {object} i18n
|
||||
*/
|
||||
function initAnnotations(fileId, currentUserId, i18n) {
|
||||
_fileId = fileId;
|
||||
_currentUserId = currentUserId;
|
||||
_i18n = i18n || {};
|
||||
_loadAnnotations();
|
||||
|
||||
var form = document.getElementById('annotation-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
_createAnnotation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function _loadAnnotations() {
|
||||
var container = document.getElementById('annotations-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="annotations-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></div>';
|
||||
|
||||
fetch('/api/files/' + _fileId + '/annotations')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
_renderAnnotations(data.annotations || [], container);
|
||||
})
|
||||
.catch(function () {
|
||||
container.innerHTML = '<p class="annotations-empty">' + (_i18n.empty || 'No annotations yet') + '</p>';
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function _renderAnnotations(annotations, container) {
|
||||
container.innerHTML = '';
|
||||
if (!annotations.length) {
|
||||
container.innerHTML = '<p class="annotations-empty"><i class="fas fa-sticky-note" aria-hidden="true"></i> ' +
|
||||
(_i18n.empty || 'No annotations yet') + '</p>';
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < annotations.length; i++) {
|
||||
container.appendChild(_buildAnnotationNode(annotations[i]));
|
||||
}
|
||||
}
|
||||
|
||||
function _buildAnnotationNode(ann) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'annotation-item';
|
||||
div.setAttribute('data-annotation-id', ann.id);
|
||||
|
||||
// Type badge + color indicator
|
||||
var header = document.createElement('div');
|
||||
header.className = 'annotation-header';
|
||||
|
||||
var typeBadge = document.createElement('span');
|
||||
typeBadge.className = 'annotation-type annotation-type--' + ann.annotation_type;
|
||||
typeBadge.textContent = _i18n['type_' + ann.annotation_type] || ann.annotation_type;
|
||||
|
||||
var pageInfo = document.createElement('span');
|
||||
pageInfo.className = 'annotation-page';
|
||||
pageInfo.innerHTML = '<i class="fas fa-file-alt" aria-hidden="true"></i> ' +
|
||||
(_i18n.page || 'Page') + ' ' + ann.page;
|
||||
|
||||
header.appendChild(typeBadge);
|
||||
if (ann.color) {
|
||||
var colorDot = document.createElement('span');
|
||||
colorDot.className = 'annotation-color-dot';
|
||||
colorDot.style.backgroundColor = ann.color;
|
||||
colorDot.setAttribute('aria-label', (_i18n.color || 'Color') + ': ' + ann.color);
|
||||
header.appendChild(colorDot);
|
||||
}
|
||||
header.appendChild(pageInfo);
|
||||
|
||||
div.appendChild(header);
|
||||
|
||||
// Content
|
||||
var content = document.createElement('div');
|
||||
content.className = 'annotation-content';
|
||||
content.id = 'annotation-content-' + ann.id;
|
||||
content.textContent = ann.content;
|
||||
div.appendChild(content);
|
||||
|
||||
// Meta
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'annotation-meta';
|
||||
|
||||
var author = document.createElement('span');
|
||||
author.className = 'annotation-author';
|
||||
author.textContent = ann.user_id;
|
||||
|
||||
var time = document.createElement('time');
|
||||
time.className = 'annotation-time';
|
||||
time.setAttribute('datetime', ann.created_at);
|
||||
time.textContent = _formatDate(ann.created_at);
|
||||
|
||||
meta.appendChild(author);
|
||||
meta.appendChild(time);
|
||||
div.appendChild(meta);
|
||||
|
||||
// Actions (author only)
|
||||
if (ann.user_id === _currentUserId) {
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'annotation-actions';
|
||||
|
||||
var editBtn = document.createElement('button');
|
||||
editBtn.type = 'button';
|
||||
editBtn.className = 'annotation-action-btn';
|
||||
editBtn.innerHTML = '<i class="fas fa-edit" aria-hidden="true"></i>';
|
||||
editBtn.setAttribute('aria-label', 'Edit annotation');
|
||||
editBtn.addEventListener('click', function () { _showEditForm(ann); });
|
||||
actions.appendChild(editBtn);
|
||||
|
||||
var deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'annotation-action-btn annotation-action-btn--danger';
|
||||
deleteBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
|
||||
deleteBtn.setAttribute('aria-label', 'Delete annotation');
|
||||
deleteBtn.addEventListener('click', function () { _deleteAnnotation(ann.id); });
|
||||
actions.appendChild(deleteBtn);
|
||||
|
||||
div.appendChild(actions);
|
||||
}
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
function _formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
var d = new Date(iso);
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
|
||||
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
} catch (_e) {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function _createAnnotation() {
|
||||
var content = document.getElementById('annotation-content-input');
|
||||
var page = document.getElementById('annotation-page-input');
|
||||
var type = document.getElementById('annotation-type-input');
|
||||
var color = document.getElementById('annotation-color-input');
|
||||
|
||||
if (!content || !content.value.trim()) return;
|
||||
|
||||
var payload = {
|
||||
content: content.value.trim(),
|
||||
page: parseInt(page ? page.value : '1', 10) || 1,
|
||||
annotation_type: type ? type.value : 'note',
|
||||
color: color ? color.value : null,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
fetch('/api/files/' + _fileId + '/annotations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('Failed');
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
content.value = '';
|
||||
if (page) page.value = '1';
|
||||
_loadAnnotations();
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _deleteAnnotation(annotationId) {
|
||||
if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this annotation?')) return;
|
||||
|
||||
fetch('/api/files/' + _fileId + '/annotations/' + annotationId, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('Failed');
|
||||
_loadAnnotations();
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _showEditForm(ann) {
|
||||
var contentDiv = document.getElementById('annotation-content-' + ann.id);
|
||||
if (!contentDiv) return;
|
||||
if (contentDiv.querySelector('.annotation-edit-form')) return;
|
||||
|
||||
var originalText = contentDiv.textContent;
|
||||
contentDiv.textContent = '';
|
||||
|
||||
var form = document.createElement('div');
|
||||
form.className = 'annotation-edit-form';
|
||||
|
||||
var textarea = document.createElement('textarea');
|
||||
textarea.className = 'annotation-textarea';
|
||||
textarea.value = ann.content;
|
||||
textarea.rows = 3;
|
||||
textarea.setAttribute('aria-label', 'Edit annotation');
|
||||
|
||||
var typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'annotation-select';
|
||||
typeSelect.setAttribute('aria-label', 'Annotation type');
|
||||
var types = ['note', 'highlight', 'underline', 'strikethrough'];
|
||||
for (var i = 0; i < types.length; i++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = types[i];
|
||||
opt.textContent = _i18n['type_' + types[i]] || types[i];
|
||||
if (types[i] === ann.annotation_type) opt.selected = true;
|
||||
typeSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
var btns = document.createElement('div');
|
||||
btns.className = 'annotation-edit-btns';
|
||||
|
||||
var saveBtn = document.createElement('button');
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.className = 'annotation-submit-btn';
|
||||
saveBtn.textContent = _i18n.save || 'Save';
|
||||
saveBtn.addEventListener('click', function () {
|
||||
var newContent = textarea.value.trim();
|
||||
if (!newContent) return;
|
||||
fetch('/api/files/' + _fileId + '/annotations/' + ann.id, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: newContent,
|
||||
annotation_type: typeSelect.value,
|
||||
page: ann.page,
|
||||
x: ann.x,
|
||||
y: ann.y,
|
||||
}),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('Failed');
|
||||
_loadAnnotations();
|
||||
})
|
||||
.catch(function () {
|
||||
contentDiv.textContent = originalText;
|
||||
});
|
||||
});
|
||||
|
||||
var cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'annotation-cancel-btn';
|
||||
cancelBtn.textContent = _i18n.cancel || 'Cancel';
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
contentDiv.textContent = originalText;
|
||||
});
|
||||
|
||||
btns.appendChild(saveBtn);
|
||||
btns.appendChild(cancelBtn);
|
||||
|
||||
form.appendChild(textarea);
|
||||
form.appendChild(typeSelect);
|
||||
form.appendChild(btns);
|
||||
contentDiv.appendChild(form);
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
// Expose
|
||||
window.initAnnotations = initAnnotations;
|
||||
})();
|
||||
@@ -0,0 +1,462 @@
|
||||
// frontend/static/js/comments.js
|
||||
// Comments panel — threaded comments with @mention autocomplete
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var _fileId = null;
|
||||
var _currentUserId = null;
|
||||
var _i18n = {};
|
||||
var _mentionableUsers = [];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialisation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bootstrap the comments panel.
|
||||
* @param {number} fileId
|
||||
* @param {string} currentUserId
|
||||
* @param {object} i18n
|
||||
*/
|
||||
function initComments(fileId, currentUserId, i18n) {
|
||||
_fileId = fileId;
|
||||
_currentUserId = currentUserId;
|
||||
_i18n = i18n || {};
|
||||
_loadComments();
|
||||
_loadMentionableUsers();
|
||||
|
||||
var form = document.getElementById('comment-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
_submitComment(null);
|
||||
});
|
||||
}
|
||||
|
||||
var input = document.getElementById('comment-input');
|
||||
if (input) {
|
||||
input.addEventListener('input', function () {
|
||||
_handleMentionInput(this);
|
||||
});
|
||||
input.addEventListener('keydown', function (e) {
|
||||
_handleMentionKeydown(e);
|
||||
});
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function (e) {
|
||||
var dropdown = document.getElementById('mention-dropdown');
|
||||
if (dropdown && !dropdown.contains(e.target) && e.target !== input) {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function _loadComments() {
|
||||
var container = document.getElementById('comments-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="comments-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></div>';
|
||||
|
||||
fetch('/api/files/' + _fileId + '/comments')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
_renderComments(data.comments || [], container);
|
||||
})
|
||||
.catch(function () {
|
||||
container.innerHTML = '<p class="comments-error">' + (_i18n.empty || 'No comments yet') + '</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function _loadMentionableUsers() {
|
||||
fetch('/api/users/mentionable')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (users) {
|
||||
_mentionableUsers = users || [];
|
||||
})
|
||||
.catch(function () {
|
||||
_mentionableUsers = [];
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function _renderComments(comments, container) {
|
||||
container.innerHTML = '';
|
||||
if (!comments.length) {
|
||||
container.innerHTML = '<p class="comments-empty"><i class="fas fa-comments" aria-hidden="true"></i> ' +
|
||||
(_i18n.empty || 'No comments yet') + '</p>';
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < comments.length; i++) {
|
||||
container.appendChild(_buildCommentNode(comments[i], false));
|
||||
}
|
||||
}
|
||||
|
||||
function _buildCommentNode(comment, isReply) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'comment-item' + (isReply ? ' comment-reply' : '') +
|
||||
(comment.is_resolved ? ' comment-resolved' : '');
|
||||
div.setAttribute('data-comment-id', comment.id);
|
||||
|
||||
// Header
|
||||
var header = document.createElement('div');
|
||||
header.className = 'comment-header';
|
||||
|
||||
var author = document.createElement('span');
|
||||
author.className = 'comment-author';
|
||||
author.textContent = comment.user_id;
|
||||
|
||||
var time = document.createElement('time');
|
||||
time.className = 'comment-time';
|
||||
time.setAttribute('datetime', comment.created_at);
|
||||
time.textContent = _formatDate(comment.created_at);
|
||||
|
||||
header.appendChild(author);
|
||||
header.appendChild(time);
|
||||
|
||||
if (comment.is_resolved) {
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'comment-resolved-badge';
|
||||
badge.innerHTML = '<i class="fas fa-check-circle" aria-hidden="true"></i> ' + (_i18n.resolved || 'Resolved');
|
||||
header.appendChild(badge);
|
||||
}
|
||||
|
||||
div.appendChild(header);
|
||||
|
||||
// Body
|
||||
var bodyDiv = document.createElement('div');
|
||||
bodyDiv.className = 'comment-body';
|
||||
bodyDiv.id = 'comment-body-' + comment.id;
|
||||
bodyDiv.innerHTML = _renderMentions(comment.body);
|
||||
div.appendChild(bodyDiv);
|
||||
|
||||
// Actions
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'comment-actions';
|
||||
|
||||
// Reply button (only for top-level)
|
||||
if (!isReply) {
|
||||
var replyBtn = document.createElement('button');
|
||||
replyBtn.type = 'button';
|
||||
replyBtn.className = 'comment-action-btn';
|
||||
replyBtn.innerHTML = '<i class="fas fa-reply" aria-hidden="true"></i> ' + (_i18n.add_reply || 'Reply');
|
||||
replyBtn.setAttribute('aria-label', _i18n.add_reply || 'Reply');
|
||||
replyBtn.addEventListener('click', function () { _showReplyForm(comment.id, div); });
|
||||
actions.appendChild(replyBtn);
|
||||
|
||||
// Resolve / Unresolve
|
||||
var resolveBtn = document.createElement('button');
|
||||
resolveBtn.type = 'button';
|
||||
resolveBtn.className = 'comment-action-btn';
|
||||
if (comment.is_resolved) {
|
||||
resolveBtn.innerHTML = '<i class="fas fa-undo" aria-hidden="true"></i> ' + (_i18n.unresolve || 'Reopen');
|
||||
resolveBtn.setAttribute('aria-label', _i18n.unresolve || 'Reopen');
|
||||
} else {
|
||||
resolveBtn.innerHTML = '<i class="fas fa-check" aria-hidden="true"></i> ' + (_i18n.resolve || 'Resolve');
|
||||
resolveBtn.setAttribute('aria-label', _i18n.resolve || 'Resolve');
|
||||
}
|
||||
resolveBtn.addEventListener('click', function () { _toggleResolve(comment.id, !comment.is_resolved); });
|
||||
actions.appendChild(resolveBtn);
|
||||
}
|
||||
|
||||
// Edit (author only)
|
||||
if (comment.user_id === _currentUserId) {
|
||||
var editBtn = document.createElement('button');
|
||||
editBtn.type = 'button';
|
||||
editBtn.className = 'comment-action-btn';
|
||||
editBtn.innerHTML = '<i class="fas fa-edit" aria-hidden="true"></i> ' + (_i18n.edit || 'Edit');
|
||||
editBtn.setAttribute('aria-label', _i18n.edit || 'Edit');
|
||||
editBtn.addEventListener('click', function () { _showEditForm(comment.id, comment.body, div); });
|
||||
actions.appendChild(editBtn);
|
||||
|
||||
// Delete
|
||||
var deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'comment-action-btn comment-action-btn--danger';
|
||||
deleteBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
|
||||
deleteBtn.setAttribute('aria-label', 'Delete comment');
|
||||
deleteBtn.addEventListener('click', function () { _deleteComment(comment.id); });
|
||||
actions.appendChild(deleteBtn);
|
||||
}
|
||||
|
||||
div.appendChild(actions);
|
||||
|
||||
// Replies
|
||||
if (comment.replies && comment.replies.length) {
|
||||
var repliesDiv = document.createElement('div');
|
||||
repliesDiv.className = 'comment-replies';
|
||||
for (var j = 0; j < comment.replies.length; j++) {
|
||||
repliesDiv.appendChild(_buildCommentNode(comment.replies[j], true));
|
||||
}
|
||||
div.appendChild(repliesDiv);
|
||||
}
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
function _renderMentions(text) {
|
||||
if (!text) return '';
|
||||
// Escape HTML first
|
||||
var escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
// Highlight @mentions
|
||||
return escaped.replace(/@([\w.\-]+)/g, '<span class="comment-mention">@$1</span>');
|
||||
}
|
||||
|
||||
function _formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
var d = new Date(iso);
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
|
||||
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
} catch (_e) {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function _submitComment(parentId) {
|
||||
var inputId = parentId ? 'reply-input-' + parentId : 'comment-input';
|
||||
var input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
var body = input.value.trim();
|
||||
if (!body) return;
|
||||
|
||||
var payload = { body: body };
|
||||
if (parentId) payload.parent_id = parentId;
|
||||
|
||||
fetch('/api/files/' + _fileId + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('Failed');
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
input.value = '';
|
||||
_loadComments();
|
||||
})
|
||||
.catch(function () {
|
||||
// Silently fail — the CSRF wrapper in common.js handles token injection
|
||||
});
|
||||
}
|
||||
|
||||
function _toggleResolve(commentId, resolve) {
|
||||
fetch('/api/files/' + _fileId + '/comments/' + commentId + '/resolve', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_resolved: resolve }),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('Failed');
|
||||
_loadComments();
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _deleteComment(commentId) {
|
||||
if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this comment?')) return;
|
||||
|
||||
fetch('/api/files/' + _fileId + '/comments/' + commentId, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('Failed');
|
||||
_loadComments();
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _showReplyForm(commentId, containerNode) {
|
||||
// Remove existing reply forms
|
||||
var existing = containerNode.querySelector('.comment-reply-form');
|
||||
if (existing) { existing.remove(); return; }
|
||||
|
||||
var form = document.createElement('div');
|
||||
form.className = 'comment-reply-form';
|
||||
|
||||
var textarea = document.createElement('textarea');
|
||||
textarea.id = 'reply-input-' + commentId;
|
||||
textarea.className = 'comment-textarea';
|
||||
textarea.placeholder = _i18n.reply_placeholder || 'Write a reply...';
|
||||
textarea.rows = 2;
|
||||
textarea.setAttribute('aria-label', _i18n.reply_placeholder || 'Write a reply...');
|
||||
|
||||
var submitBtn = document.createElement('button');
|
||||
submitBtn.type = 'button';
|
||||
submitBtn.className = 'comment-submit-btn';
|
||||
submitBtn.textContent = _i18n.add_reply || 'Reply';
|
||||
submitBtn.addEventListener('click', function () { _submitComment(commentId); });
|
||||
|
||||
form.appendChild(textarea);
|
||||
form.appendChild(submitBtn);
|
||||
|
||||
// Insert before the replies section or at end
|
||||
var repliesDiv = containerNode.querySelector('.comment-replies');
|
||||
if (repliesDiv) {
|
||||
containerNode.insertBefore(form, repliesDiv);
|
||||
} else {
|
||||
containerNode.appendChild(form);
|
||||
}
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function _showEditForm(commentId, currentBody, containerNode) {
|
||||
var bodyDiv = document.getElementById('comment-body-' + commentId);
|
||||
if (!bodyDiv) return;
|
||||
|
||||
// Already editing?
|
||||
if (bodyDiv.querySelector('.comment-edit-form')) return;
|
||||
|
||||
var originalHTML = bodyDiv.innerHTML;
|
||||
bodyDiv.innerHTML = '';
|
||||
|
||||
var form = document.createElement('div');
|
||||
form.className = 'comment-edit-form';
|
||||
|
||||
var textarea = document.createElement('textarea');
|
||||
textarea.className = 'comment-textarea';
|
||||
textarea.value = currentBody;
|
||||
textarea.rows = 3;
|
||||
textarea.setAttribute('aria-label', _i18n.edit || 'Edit');
|
||||
|
||||
var btns = document.createElement('div');
|
||||
btns.className = 'comment-edit-btns';
|
||||
|
||||
var saveBtn = document.createElement('button');
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.className = 'comment-submit-btn';
|
||||
saveBtn.textContent = _i18n.save || 'Save';
|
||||
saveBtn.addEventListener('click', function () {
|
||||
var newBody = textarea.value.trim();
|
||||
if (!newBody) return;
|
||||
fetch('/api/files/' + _fileId + '/comments/' + commentId, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: newBody }),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('Failed');
|
||||
_loadComments();
|
||||
})
|
||||
.catch(function () {
|
||||
bodyDiv.innerHTML = originalHTML;
|
||||
});
|
||||
});
|
||||
|
||||
var cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'comment-cancel-btn';
|
||||
cancelBtn.textContent = _i18n.cancel || 'Cancel';
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
bodyDiv.innerHTML = originalHTML;
|
||||
});
|
||||
btns.appendChild(cancelBtn);
|
||||
form.appendChild(textarea);
|
||||
form.appendChild(btns);
|
||||
bodyDiv.appendChild(form);
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// @mention autocomplete
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function _handleMentionInput(input) {
|
||||
var val = input.value;
|
||||
var cursorPos = input.selectionStart;
|
||||
var textBefore = val.substring(0, cursorPos);
|
||||
var match = textBefore.match(/@([\w.\-]*)$/);
|
||||
|
||||
var dropdown = document.getElementById('mention-dropdown');
|
||||
if (!dropdown) return;
|
||||
|
||||
if (!match) {
|
||||
dropdown.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
var query = match[1].toLowerCase();
|
||||
var filtered = _mentionableUsers.filter(function (u) {
|
||||
return u.user_id.toLowerCase().indexOf(query) !== -1 ||
|
||||
(u.display_name && u.display_name.toLowerCase().indexOf(query) !== -1);
|
||||
}).slice(0, 8);
|
||||
|
||||
if (!filtered.length) {
|
||||
dropdown.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.innerHTML = '';
|
||||
for (var i = 0; i < filtered.length; i++) {
|
||||
(function (user) {
|
||||
var item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'mention-item';
|
||||
item.setAttribute('role', 'option');
|
||||
item.innerHTML = '<span class="mention-user-id">' + _escapeHtml(user.user_id) + '</span>' +
|
||||
(user.display_name ? '<span class="mention-display-name">' + _escapeHtml(user.display_name) + '</span>' : '');
|
||||
item.addEventListener('click', function () {
|
||||
_insertMention(input, match.index, cursorPos, user.user_id);
|
||||
dropdown.classList.add('hidden');
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
})(filtered[i]);
|
||||
}
|
||||
dropdown.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function _handleMentionKeydown(e) {
|
||||
var dropdown = document.getElementById('mention-dropdown');
|
||||
if (!dropdown || dropdown.classList.contains('hidden')) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
dropdown.classList.add('hidden');
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
var items = dropdown.querySelectorAll('.mention-item');
|
||||
var focused = dropdown.querySelector('.mention-item:focus');
|
||||
var idx = Array.prototype.indexOf.call(items, focused);
|
||||
if (e.key === 'ArrowDown') {
|
||||
idx = (idx + 1) % items.length;
|
||||
} else {
|
||||
idx = idx <= 0 ? items.length - 1 : idx - 1;
|
||||
}
|
||||
items[idx].focus();
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
var active = dropdown.querySelector('.mention-item:focus');
|
||||
if (active) {
|
||||
active.click();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _insertMention(input, matchStart, cursorPos, userId) {
|
||||
var before = input.value.substring(0, matchStart);
|
||||
var after = input.value.substring(cursorPos);
|
||||
input.value = before + '@' + userId + ' ' + after;
|
||||
var newPos = matchStart + userId.length + 2;
|
||||
input.setSelectionRange(newPos, newPos);
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function _escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Expose
|
||||
window.initComments = initComments;
|
||||
})();
|
||||
@@ -1091,6 +1091,513 @@
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<!-- Comments & Annotations panel styles -->
|
||||
<style>
|
||||
/* ── Comments panel ───────────────────────────────────────────────────── */
|
||||
.comments-panel, .annotations-panel {
|
||||
margin-top: 0;
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.panel-header h3 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.comments-empty, .annotations-empty {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #718096;
|
||||
}
|
||||
.comments-loading, .annotations-loading {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: #718096;
|
||||
}
|
||||
.comments-error {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: #991B1B;
|
||||
}
|
||||
|
||||
/* Individual comment */
|
||||
.comment-item {
|
||||
background-color: #f7fafc;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-left: 3px solid #4299e1;
|
||||
}
|
||||
.dark .comment-item {
|
||||
background-color: #2d3748;
|
||||
border-left-color: #63b3ed;
|
||||
}
|
||||
.comment-item.comment-reply {
|
||||
margin-left: 1.5rem;
|
||||
border-left-color: #a0aec0;
|
||||
background-color: #edf2f7;
|
||||
}
|
||||
.dark .comment-item.comment-reply {
|
||||
background-color: #1a202c;
|
||||
border-left-color: #4a5568;
|
||||
}
|
||||
.comment-item.comment-resolved {
|
||||
opacity: 0.75;
|
||||
border-left-color: #48bb78;
|
||||
}
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.comment-author {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.dark .comment-author {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.comment-time {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
}
|
||||
.comment-resolved-badge {
|
||||
font-size: 0.75rem;
|
||||
color: #065F46;
|
||||
background-color: #D1FAE5;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dark .comment-resolved-badge {
|
||||
background-color: #065F46;
|
||||
color: #D1FAE5;
|
||||
}
|
||||
.comment-body {
|
||||
color: #4a5568;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.dark .comment-body {
|
||||
color: #cbd5e0;
|
||||
}
|
||||
.comment-mention {
|
||||
color: #3182ce;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dark .comment-mention {
|
||||
color: #63b3ed;
|
||||
}
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.comment-action-btn {
|
||||
background: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #718096;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-height: 28px;
|
||||
}
|
||||
.comment-action-btn:hover {
|
||||
background-color: #edf2f7;
|
||||
color: #2d3748;
|
||||
}
|
||||
.dark .comment-action-btn {
|
||||
border-color: #4a5568;
|
||||
color: #a0aec0;
|
||||
}
|
||||
.dark .comment-action-btn:hover {
|
||||
background-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.comment-action-btn--danger:hover {
|
||||
background-color: #FEE2E2;
|
||||
color: #991B1B;
|
||||
border-color: #f56565;
|
||||
}
|
||||
.dark .comment-action-btn--danger:hover {
|
||||
background-color: #742a2a;
|
||||
color: #feb2b2;
|
||||
border-color: #f56565;
|
||||
}
|
||||
.comment-replies {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Comment form */
|
||||
.comment-form-wrapper {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.comment-textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dark .comment-textarea {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.comment-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4299e1;
|
||||
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.3);
|
||||
}
|
||||
.comment-submit-btn {
|
||||
background-color: #4299e1;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
.comment-submit-btn:hover {
|
||||
background-color: #3182ce;
|
||||
}
|
||||
.comment-cancel-btn {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 0.5rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
.dark .comment-cancel-btn {
|
||||
background-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.comment-edit-btns, .annotation-edit-btns {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.comment-reply-form {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
.dark .comment-reply-form {
|
||||
border-top-color: #4a5568;
|
||||
}
|
||||
|
||||
/* @mention dropdown */
|
||||
.mention-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
#mention-dropdown {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
width: 280px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.dark #mention-dropdown {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
.mention-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
min-height: 44px;
|
||||
justify-content: center;
|
||||
}
|
||||
.mention-item:hover, .mention-item:focus {
|
||||
background-color: #edf2f7;
|
||||
outline: none;
|
||||
}
|
||||
.dark .mention-item:hover, .dark .mention-item:focus {
|
||||
background-color: #4a5568;
|
||||
}
|
||||
.mention-user-id {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
.dark .mention-user-id {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.mention-display-name {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
/* ── Annotations panel ────────────────────────────────────────────────── */
|
||||
.annotation-item {
|
||||
background-color: #f7fafc;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-left: 3px solid #ecc94b;
|
||||
}
|
||||
.dark .annotation-item {
|
||||
background-color: #2d3748;
|
||||
border-left-color: #d69e2e;
|
||||
}
|
||||
.annotation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.annotation-type {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.annotation-type--note {
|
||||
background-color: #DBEAFE;
|
||||
color: #1E3A8A;
|
||||
}
|
||||
.annotation-type--highlight {
|
||||
background-color: #FEF3C7;
|
||||
color: #92400E;
|
||||
}
|
||||
.annotation-type--underline {
|
||||
background-color: #D1FAE5;
|
||||
color: #065F46;
|
||||
}
|
||||
.annotation-type--strikethrough {
|
||||
background-color: #FEE2E2;
|
||||
color: #991B1B;
|
||||
}
|
||||
.dark .annotation-type--note {
|
||||
background-color: #1E3A8A;
|
||||
color: #DBEAFE;
|
||||
}
|
||||
.dark .annotation-type--highlight {
|
||||
background-color: #92400E;
|
||||
color: #FEF3C7;
|
||||
}
|
||||
.dark .annotation-type--underline {
|
||||
background-color: #065F46;
|
||||
color: #D1FAE5;
|
||||
}
|
||||
.dark .annotation-type--strikethrough {
|
||||
background-color: #991B1B;
|
||||
color: #FEE2E2;
|
||||
}
|
||||
.annotation-color-dot {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.annotation-page {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
}
|
||||
.annotation-content {
|
||||
color: #4a5568;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.dark .annotation-content {
|
||||
color: #cbd5e0;
|
||||
}
|
||||
.annotation-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.annotation-author {
|
||||
font-weight: 600;
|
||||
}
|
||||
.annotation-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.annotation-action-btn {
|
||||
background: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #718096;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
min-height: 28px;
|
||||
}
|
||||
.annotation-action-btn:hover {
|
||||
background-color: #edf2f7;
|
||||
color: #2d3748;
|
||||
}
|
||||
.dark .annotation-action-btn {
|
||||
border-color: #4a5568;
|
||||
color: #a0aec0;
|
||||
}
|
||||
.dark .annotation-action-btn:hover {
|
||||
background-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.annotation-action-btn--danger:hover {
|
||||
background-color: #FEE2E2;
|
||||
color: #991B1B;
|
||||
border-color: #f56565;
|
||||
}
|
||||
.dark .annotation-action-btn--danger:hover {
|
||||
background-color: #742a2a;
|
||||
color: #feb2b2;
|
||||
}
|
||||
.annotation-textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dark .annotation-textarea {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.annotation-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ecc94b;
|
||||
box-shadow: 0 0 0 2px rgba(236, 201, 75, 0.3);
|
||||
}
|
||||
.annotation-select {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.dark .annotation-select {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.annotation-submit-btn {
|
||||
background-color: #ecc94b;
|
||||
color: #744210;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
.annotation-submit-btn:hover {
|
||||
background-color: #d69e2e;
|
||||
}
|
||||
.annotation-cancel-btn {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 0.5rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
.dark .annotation-cancel-btn {
|
||||
background-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Annotation form layout */
|
||||
.annotation-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.annotation-form-grid .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.annotation-form-grid label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.dark .annotation-form-grid label {
|
||||
color: #a0aec0;
|
||||
}
|
||||
.annotation-form-grid input,
|
||||
.annotation-form-grid select {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.dark .annotation-form-grid input,
|
||||
.dark .annotation-form-grid select {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ── Collaboration panels grid ────────────────────────────────────────── */
|
||||
.collab-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.collab-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -1777,6 +2284,137 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Comments & Annotations ──────────────────────────────────────────── -->
|
||||
<div class="detail-card">
|
||||
<div class="collab-grid">
|
||||
<!-- Comments Panel -->
|
||||
<section class="comments-panel" aria-label="{{ _('comments.heading') }}">
|
||||
<div class="panel-header">
|
||||
<h3><i class="fas fa-comments" aria-hidden="true"></i> {{ _("comments.heading") }}</h3>
|
||||
</div>
|
||||
<div id="comments-list" aria-live="polite"></div>
|
||||
|
||||
<!-- New comment form -->
|
||||
<div class="comment-form-wrapper">
|
||||
<form id="comment-form" aria-label="{{ _('comments.add_comment') }}">
|
||||
<div class="mention-dropdown-wrapper">
|
||||
<div id="mention-dropdown" class="hidden" role="listbox" aria-label="{{ _('comments.mention_users') }}"></div>
|
||||
<textarea
|
||||
id="comment-input"
|
||||
class="comment-textarea"
|
||||
placeholder="{{ _('comments.body_placeholder') }}"
|
||||
rows="3"
|
||||
aria-label="{{ _('comments.body_placeholder') }}"
|
||||
maxlength="10000"
|
||||
></textarea>
|
||||
</div>
|
||||
<button type="submit" class="comment-submit-btn">
|
||||
<i class="fas fa-paper-plane" aria-hidden="true"></i> {{ _("comments.add_comment") }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Annotations Panel -->
|
||||
<section class="annotations-panel" aria-label="{{ _('annotations.heading') }}">
|
||||
<div class="panel-header">
|
||||
<h3><i class="fas fa-sticky-note" aria-hidden="true"></i> {{ _("annotations.heading") }}</h3>
|
||||
</div>
|
||||
<div id="annotations-list" aria-live="polite"></div>
|
||||
|
||||
<!-- New annotation form -->
|
||||
<div class="comment-form-wrapper">
|
||||
<form id="annotation-form" aria-label="{{ _('annotations.add') }}">
|
||||
<textarea
|
||||
id="annotation-content-input"
|
||||
class="annotation-textarea"
|
||||
placeholder="{{ _('annotations.content_placeholder') }}"
|
||||
rows="2"
|
||||
aria-label="{{ _('annotations.content_placeholder') }}"
|
||||
maxlength="5000"
|
||||
></textarea>
|
||||
<div class="annotation-form-grid">
|
||||
<div class="form-group">
|
||||
<label for="annotation-page-input">{{ _("annotations.page") }}</label>
|
||||
<input type="number" id="annotation-page-input" min="1" value="1" aria-label="{{ _('annotations.page') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="annotation-type-input">{{ _("annotations.type") }}</label>
|
||||
<select id="annotation-type-input" aria-label="Annotation type">
|
||||
<option value="note">{{ _("annotations.type_note") }}</option>
|
||||
<option value="highlight">{{ _("annotations.type_highlight") }}</option>
|
||||
<option value="underline">{{ _("annotations.type_underline") }}</option>
|
||||
<option value="strikethrough">{{ _("annotations.type_strikethrough") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="annotation-color-input">{{ _("annotations.color") }}</label>
|
||||
<input type="color" id="annotation-color-input" value="#ffff00" aria-label="{{ _('annotations.color') }}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="annotation-submit-btn">
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> {{ _("annotations.add") }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comments & Annotations JS -->
|
||||
<script src="{{ url_for('static', path='js/comments.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='js/annotations.js') }}" defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var fileId = {{ file.id | tojson }};
|
||||
// Detect current user from whoami endpoint
|
||||
fetch('/api/auth/whoami')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var userId = (data && (data.email || data.preferred_username)) || '';
|
||||
var commentsI18n = {
|
||||
empty: {{ _("comments.empty") | tojson }},
|
||||
add_comment: {{ _("comments.add_comment") | tojson }},
|
||||
add_reply: {{ _("comments.add_reply") | tojson }},
|
||||
edit: {{ _("comments.edit") | tojson }},
|
||||
save: {{ _("comments.save") | tojson }},
|
||||
resolve: {{ _("comments.resolve") | tojson }},
|
||||
resolved: {{ _("comments.resolved") | tojson }},
|
||||
unresolve: {{ _("comments.unresolve") | tojson }},
|
||||
delete_confirm: {{ _("comments.delete_confirm") | tojson }},
|
||||
reply_placeholder: {{ _("comments.reply_placeholder") | tojson }},
|
||||
body_placeholder: {{ _("comments.body_placeholder") | tojson }},
|
||||
mention_users: {{ _("comments.mention_users") | tojson }},
|
||||
cancel: {{ _("common.cancel") | tojson }}
|
||||
};
|
||||
var annotationsI18n = {
|
||||
empty: {{ _("annotations.empty") | tojson }},
|
||||
add: {{ _("annotations.add") | tojson }},
|
||||
save: {{ _("annotations.save") | tojson }},
|
||||
delete_confirm: {{ _("annotations.delete_confirm") | tojson }},
|
||||
page: {{ _("annotations.page") | tojson }},
|
||||
color: {{ _("annotations.color") | tojson }},
|
||||
type_note: {{ _("annotations.type_note") | tojson }},
|
||||
type_highlight: {{ _("annotations.type_highlight") | tojson }},
|
||||
type_underline: {{ _("annotations.type_underline") | tojson }},
|
||||
type_strikethrough: {{ _("annotations.type_strikethrough") | tojson }},
|
||||
cancel: {{ _("common.cancel") | tojson }}
|
||||
};
|
||||
if (typeof initComments === 'function') {
|
||||
initComments(fileId, userId, commentsI18n);
|
||||
}
|
||||
if (typeof initAnnotations === 'function') {
|
||||
initAnnotations(fileId, userId, annotationsI18n);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
// Auth disabled — initialise with empty user
|
||||
if (typeof initComments === 'function') initComments(fileId, '', {});
|
||||
if (typeof initAnnotations === 'function') initAnnotations(fileId, '', {});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -315,6 +315,20 @@
|
||||
"admin_users.total_count_users": "{count} users",
|
||||
"admin_users.total_no_users": "No users",
|
||||
"admin_users.total_one_user": "1 user",
|
||||
"annotations.add": "Add annotation",
|
||||
"annotations.color": "Color",
|
||||
"annotations.content_placeholder": "Write an annotation...",
|
||||
"annotations.delete_confirm": "Are you sure you want to delete this annotation?",
|
||||
"annotations.deleted": "Annotation deleted",
|
||||
"annotations.empty": "No annotations yet",
|
||||
"annotations.heading": "Annotations",
|
||||
"annotations.page": "Page",
|
||||
"annotations.save": "Save",
|
||||
"annotations.type_highlight": "Highlight",
|
||||
"annotations.type_note": "Note",
|
||||
"annotations.type_strikethrough": "Strikethrough",
|
||||
"annotations.type_underline": "Underline",
|
||||
"annotations.updated": "Annotation updated",
|
||||
"api_tokens.col_created": "Created",
|
||||
"api_tokens.col_expires": "Expires",
|
||||
"api_tokens.col_last_ip": "Last IP",
|
||||
@@ -484,6 +498,21 @@
|
||||
"billing.success_heading": "You're all set!",
|
||||
"billing.success_message": "Your subscription has been activated. Thank you for choosing DocuElevate!",
|
||||
"billing.success_page_title": "DocuElevate - Subscription Activated",
|
||||
"comments.add_comment": "Add comment",
|
||||
"comments.add_reply": "Reply",
|
||||
"comments.body_placeholder": "Write a comment... Use @username to mention someone",
|
||||
"comments.delete_confirm": "Are you sure you want to delete this comment?",
|
||||
"comments.deleted": "Comment deleted",
|
||||
"comments.edit": "Edit",
|
||||
"comments.empty": "No comments yet",
|
||||
"comments.heading": "Comments",
|
||||
"comments.mention_users": "Mention users",
|
||||
"comments.reply_placeholder": "Write a reply...",
|
||||
"comments.resolve": "Resolve",
|
||||
"comments.resolved": "Resolved",
|
||||
"comments.save": "Save",
|
||||
"comments.unresolve": "Reopen",
|
||||
"comments.updated": "Comment updated",
|
||||
"common.actions": "Actions",
|
||||
"common.active": "Active",
|
||||
"common.all": "All",
|
||||
@@ -1913,5 +1942,6 @@
|
||||
"upload.uploading": "Uploading...",
|
||||
"upload.url_description": "Enter a direct link to a file (PDF, Office documents, or images)",
|
||||
"upload.url_label": "File URL",
|
||||
"upload.url_placeholder": "https://example.com/document.pdf"
|
||||
"upload.url_placeholder": "https://example.com/document.pdf",
|
||||
"annotations.type": "Type"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ from app.models import ( # noqa: F401
|
||||
BackupRecord,
|
||||
ClassificationRuleModel,
|
||||
ComplianceTemplate,
|
||||
DocumentAnnotation,
|
||||
DocumentComment,
|
||||
DocumentMetadata,
|
||||
FileProcessingStep,
|
||||
FileRecord,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Add document_comments and document_annotations tables.
|
||||
|
||||
Revision ID: 041_add_document_comments_and_annotations
|
||||
Revises: 040_add_automation_hooks
|
||||
Create Date: 2026-03-21
|
||||
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "041_add_document_comments_and_annotations"
|
||||
down_revision: Union[str, None] = "040_add_automation_hooks"
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add document_comments and document_annotations tables."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if "document_comments" not in existing_tables:
|
||||
op.create_table(
|
||||
"document_comments",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("file_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.String(), nullable=False),
|
||||
sa.Column("parent_id", sa.Integer(), nullable=True),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("mentions", sa.Text(), nullable=True),
|
||||
sa.Column("is_resolved", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["file_id"], ["files.id"]),
|
||||
sa.ForeignKeyConstraint(["parent_id"], ["document_comments.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_document_comments_id", "document_comments", ["id"])
|
||||
op.create_index("ix_document_comments_file_id", "document_comments", ["file_id"])
|
||||
op.create_index("ix_document_comments_user_id", "document_comments", ["user_id"])
|
||||
op.create_index("ix_document_comments_parent_id", "document_comments", ["parent_id"])
|
||||
|
||||
if "document_annotations" not in existing_tables:
|
||||
op.create_table(
|
||||
"document_annotations",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("file_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.String(), nullable=False),
|
||||
sa.Column("page", sa.Integer(), nullable=False),
|
||||
sa.Column("x", sa.Float(), nullable=False),
|
||||
sa.Column("y", sa.Float(), nullable=False),
|
||||
sa.Column("width", sa.Float(), nullable=False),
|
||||
sa.Column("height", sa.Float(), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("annotation_type", sa.String(50), nullable=False, server_default="note"),
|
||||
sa.Column("color", sa.String(20), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["file_id"], ["files.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_document_annotations_id", "document_annotations", ["id"])
|
||||
op.create_index("ix_document_annotations_file_id", "document_annotations", ["file_id"])
|
||||
op.create_index("ix_document_annotations_user_id", "document_annotations", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop document_comments and document_annotations tables."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
if "document_annotations" in existing_tables:
|
||||
op.drop_index("ix_document_annotations_user_id", "document_annotations")
|
||||
op.drop_index("ix_document_annotations_file_id", "document_annotations")
|
||||
op.drop_index("ix_document_annotations_id", "document_annotations")
|
||||
op.drop_table("document_annotations")
|
||||
|
||||
if "document_comments" in existing_tables:
|
||||
op.drop_index("ix_document_comments_parent_id", "document_comments")
|
||||
op.drop_index("ix_document_comments_user_id", "document_comments")
|
||||
op.drop_index("ix_document_comments_file_id", "document_comments")
|
||||
op.drop_index("ix_document_comments_id", "document_comments")
|
||||
op.drop_table("document_comments")
|
||||
@@ -65,6 +65,8 @@ from app.models import ( # noqa: F401, E402
|
||||
AutomationHook,
|
||||
ClassificationRuleModel,
|
||||
ComplianceTemplate,
|
||||
DocumentAnnotation,
|
||||
DocumentComment,
|
||||
DocumentMetadata,
|
||||
FileRecord,
|
||||
Pipeline,
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
"""Tests for the document comments and annotations API."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import DocumentAnnotation, DocumentComment, FileRecord, UserProfile
|
||||
|
||||
|
||||
def _create_file(db_session, owner_id="testuser") -> FileRecord:
|
||||
"""Helper to create a minimal FileRecord for testing."""
|
||||
f = FileRecord(
|
||||
owner_id=owner_id,
|
||||
filehash="abc123",
|
||||
original_filename="test.pdf",
|
||||
local_filename="test.pdf",
|
||||
file_size=1024,
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
db_session.add(f)
|
||||
db_session.commit()
|
||||
db_session.refresh(f)
|
||||
return f
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comment tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestListComments:
|
||||
"""Tests for GET /api/files/{file_id}/comments."""
|
||||
|
||||
def test_list_comments_empty(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.get(f"/api/files/{f.id}/comments")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["file_id"] == f.id
|
||||
assert data["comments"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_comments_file_not_found(self, client):
|
||||
resp = client.get("/api/files/99999/comments")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_list_comments_threaded(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
# Root comment
|
||||
c1 = DocumentComment(file_id=f.id, user_id="alice", body="Hello")
|
||||
db_session.add(c1)
|
||||
db_session.commit()
|
||||
db_session.refresh(c1)
|
||||
# Reply
|
||||
c2 = DocumentComment(file_id=f.id, user_id="bob", parent_id=c1.id, body="Hi back")
|
||||
db_session.add(c2)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get(f"/api/files/{f.id}/comments")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert len(data["comments"]) == 1 # only root
|
||||
assert len(data["comments"][0]["replies"]) == 1
|
||||
assert data["comments"][0]["replies"][0]["body"] == "Hi back"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCreateComment:
|
||||
"""Tests for POST /api/files/{file_id}/comments."""
|
||||
|
||||
def test_create_comment(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.post(
|
||||
f"/api/files/{f.id}/comments",
|
||||
json={"body": "Great document!"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["body"] == "Great document!"
|
||||
assert data["file_id"] == f.id
|
||||
assert data["parent_id"] is None
|
||||
|
||||
def test_create_comment_with_mention(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.post(
|
||||
f"/api/files/{f.id}/comments",
|
||||
json={"body": "Hey @alice please review"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["mentions"] == ["alice"]
|
||||
|
||||
def test_create_comment_with_parent(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
c = DocumentComment(file_id=f.id, user_id="user1", body="root")
|
||||
db_session.add(c)
|
||||
db_session.commit()
|
||||
db_session.refresh(c)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/files/{f.id}/comments",
|
||||
json={"body": "reply", "parent_id": c.id},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["parent_id"] == c.id
|
||||
|
||||
def test_create_comment_parent_not_found(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.post(
|
||||
f"/api/files/{f.id}/comments",
|
||||
json={"body": "reply", "parent_id": 99999},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_create_comment_empty_body(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.post(
|
||||
f"/api/files/{f.id}/comments",
|
||||
json={"body": " "},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_comment_file_not_found(self, client):
|
||||
resp = client.post(
|
||||
"/api/files/99999/comments",
|
||||
json={"body": "test"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_create_comment_body_too_long(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.post(
|
||||
f"/api/files/{f.id}/comments",
|
||||
json={"body": "x" * 10_001},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUpdateComment:
|
||||
"""Tests for PUT /api/files/{file_id}/comments/{comment_id}."""
|
||||
|
||||
def test_update_comment(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
c = DocumentComment(file_id=f.id, user_id="anonymous", body="old body")
|
||||
db_session.add(c)
|
||||
db_session.commit()
|
||||
db_session.refresh(c)
|
||||
|
||||
resp = client.put(
|
||||
f"/api/files/{f.id}/comments/{c.id}",
|
||||
json={"body": "new body @bob"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["body"] == "new body @bob"
|
||||
assert data["mentions"] == ["bob"]
|
||||
|
||||
def test_update_comment_not_found(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.put(
|
||||
f"/api/files/{f.id}/comments/99999",
|
||||
json={"body": "new"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_comment_forbidden(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
c = DocumentComment(file_id=f.id, user_id="other_user", body="old")
|
||||
db_session.add(c)
|
||||
db_session.commit()
|
||||
db_session.refresh(c)
|
||||
|
||||
resp = client.put(
|
||||
f"/api/files/{f.id}/comments/{c.id}",
|
||||
json={"body": "new"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteComment:
|
||||
"""Tests for DELETE /api/files/{file_id}/comments/{comment_id}."""
|
||||
|
||||
def test_delete_comment(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
c = DocumentComment(file_id=f.id, user_id="anonymous", body="to delete")
|
||||
db_session.add(c)
|
||||
db_session.commit()
|
||||
db_session.refresh(c)
|
||||
|
||||
resp = client.delete(f"/api/files/{f.id}/comments/{c.id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Verify deleted
|
||||
assert db_session.query(DocumentComment).filter(DocumentComment.id == c.id).first() is None
|
||||
|
||||
def test_delete_comment_not_found(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.delete(f"/api/files/{f.id}/comments/99999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_comment_forbidden(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
c = DocumentComment(file_id=f.id, user_id="other_user", body="mine")
|
||||
db_session.add(c)
|
||||
db_session.commit()
|
||||
db_session.refresh(c)
|
||||
|
||||
resp = client.delete(f"/api/files/{f.id}/comments/{c.id}")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestResolveComment:
|
||||
"""Tests for PATCH /api/files/{file_id}/comments/{comment_id}/resolve."""
|
||||
|
||||
def test_resolve_comment(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
c = DocumentComment(file_id=f.id, user_id="anonymous", body="issue")
|
||||
db_session.add(c)
|
||||
db_session.commit()
|
||||
db_session.refresh(c)
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/files/{f.id}/comments/{c.id}/resolve",
|
||||
json={"is_resolved": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_resolved"] is True
|
||||
|
||||
def test_unresolve_comment(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
c = DocumentComment(file_id=f.id, user_id="anonymous", body="issue", is_resolved=True)
|
||||
db_session.add(c)
|
||||
db_session.commit()
|
||||
db_session.refresh(c)
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/files/{f.id}/comments/{c.id}/resolve",
|
||||
json={"is_resolved": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_resolved"] is False
|
||||
|
||||
def test_resolve_not_found(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.patch(
|
||||
f"/api/files/{f.id}/comments/99999/resolve",
|
||||
json={"is_resolved": True},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Annotation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestListAnnotations:
|
||||
"""Tests for GET /api/files/{file_id}/annotations."""
|
||||
|
||||
def test_list_annotations_empty(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.get(f"/api/files/{f.id}/annotations")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["file_id"] == f.id
|
||||
assert data["annotations"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_annotations_file_not_found(self, client):
|
||||
resp = client.get("/api/files/99999/annotations")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCreateAnnotation:
|
||||
"""Tests for POST /api/files/{file_id}/annotations."""
|
||||
|
||||
def test_create_annotation(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.post(
|
||||
f"/api/files/{f.id}/annotations",
|
||||
json={
|
||||
"page": 1,
|
||||
"x": 100.0,
|
||||
"y": 200.0,
|
||||
"content": "Important note",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["page"] == 1
|
||||
assert data["x"] == 100.0
|
||||
assert data["y"] == 200.0
|
||||
assert data["content"] == "Important note"
|
||||
assert data["annotation_type"] == "note"
|
||||
|
||||
def test_create_annotation_with_all_fields(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.post(
|
||||
f"/api/files/{f.id}/annotations",
|
||||
json={
|
||||
"page": 2,
|
||||
"x": 50.0,
|
||||
"y": 100.0,
|
||||
"width": 200.0,
|
||||
"height": 30.0,
|
||||
"content": "Highlighted text",
|
||||
"annotation_type": "highlight",
|
||||
"color": "#ffff00",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["annotation_type"] == "highlight"
|
||||
assert data["color"] == "#ffff00"
|
||||
assert data["width"] == 200.0
|
||||
assert data["height"] == 30.0
|
||||
|
||||
def test_create_annotation_file_not_found(self, client):
|
||||
resp = client.post(
|
||||
"/api/files/99999/annotations",
|
||||
json={"page": 1, "x": 0, "y": 0, "content": "test"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_create_annotation_empty_content(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.post(
|
||||
f"/api/files/{f.id}/annotations",
|
||||
json={"page": 1, "x": 0, "y": 0, "content": " "},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_annotation_invalid_page(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.post(
|
||||
f"/api/files/{f.id}/annotations",
|
||||
json={"page": 0, "x": 0, "y": 0, "content": "test"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_annotation_invalid_type(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.post(
|
||||
f"/api/files/{f.id}/annotations",
|
||||
json={"page": 1, "x": 0, "y": 0, "content": "test", "annotation_type": "invalid"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_annotation_content_too_long(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.post(
|
||||
f"/api/files/{f.id}/annotations",
|
||||
json={"page": 1, "x": 0, "y": 0, "content": "x" * 5_001},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUpdateAnnotation:
|
||||
"""Tests for PUT /api/files/{file_id}/annotations/{annotation_id}."""
|
||||
|
||||
def test_update_annotation(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
a = DocumentAnnotation(file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="old")
|
||||
db_session.add(a)
|
||||
db_session.commit()
|
||||
db_session.refresh(a)
|
||||
|
||||
resp = client.put(
|
||||
f"/api/files/{f.id}/annotations/{a.id}",
|
||||
json={"content": "updated note", "color": "#00ff00"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["content"] == "updated note"
|
||||
assert data["color"] == "#00ff00"
|
||||
|
||||
def test_update_annotation_not_found(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.put(
|
||||
f"/api/files/{f.id}/annotations/99999",
|
||||
json={"content": "new"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_annotation_forbidden(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
a = DocumentAnnotation(file_id=f.id, user_id="other_user", page=1, x=0, y=0, width=0, height=0, content="mine")
|
||||
db_session.add(a)
|
||||
db_session.commit()
|
||||
db_session.refresh(a)
|
||||
|
||||
resp = client.put(
|
||||
f"/api/files/{f.id}/annotations/{a.id}",
|
||||
json={"content": "hijack"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_update_annotation_invalid_type(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
a = DocumentAnnotation(file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="old")
|
||||
db_session.add(a)
|
||||
db_session.commit()
|
||||
db_session.refresh(a)
|
||||
|
||||
resp = client.put(
|
||||
f"/api/files/{f.id}/annotations/{a.id}",
|
||||
json={"annotation_type": "invalid"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteAnnotation:
|
||||
"""Tests for DELETE /api/files/{file_id}/annotations/{annotation_id}."""
|
||||
|
||||
def test_delete_annotation(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
a = DocumentAnnotation(
|
||||
file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="to delete"
|
||||
)
|
||||
db_session.add(a)
|
||||
db_session.commit()
|
||||
db_session.refresh(a)
|
||||
|
||||
resp = client.delete(f"/api/files/{f.id}/annotations/{a.id}")
|
||||
assert resp.status_code == 204
|
||||
assert db_session.query(DocumentAnnotation).filter(DocumentAnnotation.id == a.id).first() is None
|
||||
|
||||
def test_delete_annotation_not_found(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
resp = client.delete(f"/api/files/{f.id}/annotations/99999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_annotation_forbidden(self, client, db_session):
|
||||
f = _create_file(db_session)
|
||||
a = DocumentAnnotation(file_id=f.id, user_id="other_user", page=1, x=0, y=0, width=0, height=0, content="mine")
|
||||
db_session.add(a)
|
||||
db_session.commit()
|
||||
db_session.refresh(a)
|
||||
|
||||
resp = client.delete(f"/api/files/{f.id}/annotations/{a.id}")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mentionable users tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestListMentionableUsers:
|
||||
"""Tests for GET /api/users/mentionable."""
|
||||
|
||||
def test_list_mentionable_empty(self, client, db_session):
|
||||
resp = client.get("/api/users/mentionable")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
def test_list_mentionable_users(self, client, db_session):
|
||||
p1 = UserProfile(user_id="alice", display_name="Alice A")
|
||||
p2 = UserProfile(user_id="bob", display_name="Bob B")
|
||||
db_session.add_all([p1, p2])
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/users/mentionable")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["user_id"] == "alice"
|
||||
assert data[1]["user_id"] == "bob"
|
||||
|
||||
def test_blocked_users_excluded(self, client, db_session):
|
||||
p1 = UserProfile(user_id="alice", display_name="Alice A", is_blocked=False)
|
||||
p2 = UserProfile(user_id="blocked", display_name="Blocked", is_blocked=True)
|
||||
db_session.add_all([p1, p2])
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/users/mentionable")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["user_id"] == "alice"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mention extraction helper tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestExtractMentions:
|
||||
"""Tests for the _extract_mentions helper function."""
|
||||
|
||||
def test_no_mentions(self):
|
||||
from app.api.comments import _extract_mentions
|
||||
|
||||
assert _extract_mentions("Hello world") == []
|
||||
|
||||
def test_single_mention(self):
|
||||
from app.api.comments import _extract_mentions
|
||||
|
||||
assert _extract_mentions("Hey @alice check this") == ["alice"]
|
||||
|
||||
def test_multiple_mentions(self):
|
||||
from app.api.comments import _extract_mentions
|
||||
|
||||
assert _extract_mentions("@alice @bob @charlie") == ["alice", "bob", "charlie"]
|
||||
|
||||
def test_duplicate_mentions(self):
|
||||
from app.api.comments import _extract_mentions
|
||||
|
||||
result = _extract_mentions("@alice and @alice again")
|
||||
assert result == ["alice"]
|
||||
|
||||
def test_mention_with_dots_and_dashes(self):
|
||||
from app.api.comments import _extract_mentions
|
||||
|
||||
result = _extract_mentions("@user.name @user-name")
|
||||
assert result == ["user.name", "user-name"]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests for the comments and annotations UI on the file detail page."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.models import FileRecord
|
||||
|
||||
|
||||
def _create_file(db_session, tmp_path) -> FileRecord:
|
||||
"""Create a minimal FileRecord with a real file path for the detail page."""
|
||||
file_path = tmp_path / "test.pdf"
|
||||
file_path.write_bytes(b"%PDF-1.4")
|
||||
f = FileRecord(
|
||||
filehash="uihash123",
|
||||
original_filename="test.pdf",
|
||||
local_filename=str(file_path),
|
||||
original_file_path=str(file_path),
|
||||
file_size=1024,
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
db_session.add(f)
|
||||
db_session.commit()
|
||||
db_session.refresh(f)
|
||||
return f
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCommentsUIRendering:
|
||||
"""Verify the file detail page includes the comments panel HTML."""
|
||||
|
||||
def test_detail_page_contains_comments_section(self, client: TestClient, db_session, tmp_path):
|
||||
"""The detail page should render the comments panel container."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/detail")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert 'id="comments-list"' in html
|
||||
assert 'id="comment-form"' in html
|
||||
assert 'id="comment-input"' in html
|
||||
|
||||
def test_detail_page_contains_annotations_section(self, client: TestClient, db_session, tmp_path):
|
||||
"""The detail page should render the annotations panel container."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/detail")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert 'id="annotations-list"' in html
|
||||
assert 'id="annotation-form"' in html
|
||||
assert 'id="annotation-content-input"' in html
|
||||
|
||||
def test_detail_page_loads_comments_js(self, client: TestClient, db_session, tmp_path):
|
||||
"""The detail page should include the comments JavaScript file."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/detail")
|
||||
assert resp.status_code == 200
|
||||
assert "js/comments.js" in resp.text
|
||||
|
||||
def test_detail_page_loads_annotations_js(self, client: TestClient, db_session, tmp_path):
|
||||
"""The detail page should include the annotations JavaScript file."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/detail")
|
||||
assert resp.status_code == 200
|
||||
assert "js/annotations.js" in resp.text
|
||||
|
||||
def test_detail_page_has_mention_dropdown(self, client: TestClient, db_session, tmp_path):
|
||||
"""The mention autocomplete dropdown should be present."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/detail")
|
||||
assert resp.status_code == 200
|
||||
assert 'id="mention-dropdown"' in resp.text
|
||||
|
||||
def test_detail_page_has_annotation_form_fields(self, client: TestClient, db_session, tmp_path):
|
||||
"""Annotation form should have page, type, and color inputs."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/detail")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert 'id="annotation-page-input"' in html
|
||||
assert 'id="annotation-type-input"' in html
|
||||
assert 'id="annotation-color-input"' in html
|
||||
|
||||
def test_detail_page_has_collab_grid(self, client: TestClient, db_session, tmp_path):
|
||||
"""Comments and annotations should be in a side-by-side grid layout."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/detail")
|
||||
assert resp.status_code == 200
|
||||
assert "collab-grid" in resp.text
|
||||
|
||||
def test_detail_page_no_comments_for_missing_file(self, client: TestClient):
|
||||
"""When file is not found, no comments section should appear."""
|
||||
resp = client.get("/files/99999/detail")
|
||||
assert resp.status_code == 200
|
||||
# The error block is shown, not the main content
|
||||
assert 'id="comments-list"' not in resp.text
|
||||
|
||||
def test_detail_page_annotation_type_options(self, client: TestClient, db_session, tmp_path):
|
||||
"""Annotation type selector should include all four types."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/detail")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert 'value="note"' in html
|
||||
assert 'value="highlight"' in html
|
||||
assert 'value="underline"' in html
|
||||
assert 'value="strikethrough"' in html
|
||||
|
||||
def test_detail_page_comments_panel_accessibility(self, client: TestClient, db_session, tmp_path):
|
||||
"""Comments panel should have proper ARIA attributes."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/detail")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert 'aria-live="polite"' in html
|
||||
assert 'role="listbox"' in html
|
||||
|
||||
def test_detail_page_init_script(self, client: TestClient, db_session, tmp_path):
|
||||
"""The init script should call initComments and initAnnotations."""
|
||||
f = _create_file(db_session, tmp_path)
|
||||
resp = client.get(f"/files/{f.id}/detail")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert "initComments" in html
|
||||
assert "initAnnotations" in html
|
||||
Reference in New Issue
Block a user