diff --git a/app/api/__init__.py b/app/api/__init__.py index de5c41e8..325b514c 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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) diff --git a/app/api/comments.py b/app/api/comments.py new file mode 100644 index 00000000..cb4451eb --- /dev/null +++ b/app/api/comments.py @@ -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 + ] diff --git a/app/models.py b/app/models.py index 94697fc4..7fcc72a2 100644 --- a/app/models.py +++ b/app/models.py @@ -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()) diff --git a/docs/API.md b/docs/API.md index cfc1a844..6ee5f41f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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" } +] +``` diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 8f791cfb..8ea9486f 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -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: diff --git a/frontend/static/js/annotations.js b/frontend/static/js/annotations.js new file mode 100644 index 00000000..35d0ede2 --- /dev/null +++ b/frontend/static/js/annotations.js @@ -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 = '
' + (_i18n.empty || 'No annotations yet') + '
'; + }); + } + + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + function _renderAnnotations(annotations, container) { + container.innerHTML = ''; + if (!annotations.length) { + container.innerHTML = '' + + (_i18n.empty || 'No annotations yet') + '
'; + 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 = ' ' + + (_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 = ''; + 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 = ''; + 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; +})(); diff --git a/frontend/static/js/comments.js b/frontend/static/js/comments.js new file mode 100644 index 00000000..a6add2e0 --- /dev/null +++ b/frontend/static/js/comments.js @@ -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 = '' + (_i18n.empty || 'No comments yet') + '
'; + }); + } + + 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 = '' + + (_i18n.empty || 'No comments yet') + '
'; + 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 = ' ' + (_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 = ' ' + (_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 = ' ' + (_i18n.unresolve || 'Reopen'); + resolveBtn.setAttribute('aria-label', _i18n.unresolve || 'Reopen'); + } else { + resolveBtn.innerHTML = ' ' + (_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 = ' ' + (_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 = ''; + 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, '>'); + // Highlight @mentions + return escaped.replace(/@([\w.\-]+)/g, '@$1'); + } + + 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 = '' + _escapeHtml(user.user_id) + '' + + (user.display_name ? '' + _escapeHtml(user.display_name) + '' : ''); + 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, '''); + } + + // Expose + window.initComments = initComments; +})(); diff --git a/frontend/templates/file_detail.html b/frontend/templates/file_detail.html index 2e9d3ae1..2c1242bc 100644 --- a/frontend/templates/file_detail.html +++ b/frontend/templates/file_detail.html @@ -1091,6 +1091,513 @@ } {% endif %} + + + {% endblock %} {% block content %} @@ -1777,6 +2284,137 @@ {% endif %} + +