diff --git a/backend/configs/development.yaml b/backend/configs/development.yaml index cf3e32013..7ff3744d2 100644 --- a/backend/configs/development.yaml +++ b/backend/configs/development.yaml @@ -139,6 +139,9 @@ promptembedder: max_seq_length: 2048 batch_size: 8 +chunking: + characters_per_page: 2000 + project_metadata: text_language: key: "language" diff --git a/backend/configs/production.yaml b/backend/configs/production.yaml index 6a1290abf..a917767ae 100644 --- a/backend/configs/production.yaml +++ b/backend/configs/production.yaml @@ -139,6 +139,9 @@ promptembedder: max_seq_length: 2048 batch_size: 8 +chunking: + characters_per_page: 2000 + project_metadata: text_language: key: "language" diff --git a/backend/src/core/memo/memo_endpoint.py b/backend/src/core/memo/memo_endpoint.py index ae7227533..5341bc9ac 100644 --- a/backend/src/core/memo/memo_endpoint.py +++ b/backend/src/core/memo/memo_endpoint.py @@ -186,9 +186,11 @@ def delete_by_id( ) -> MemoRead: authz_user.assert_in_same_project_as(Crud.MEMO, memo_id) - memo = crud_memo.delete(db=db, id=memo_id) + memo = crud_memo.read(db=db, id=memo_id) + memo_read = crud_memo.get_memo_read_dto_from_orm(db, memo) + crud_memo.delete(db=db, id=memo_id) - return crud_memo.get_memo_read_dto_from_orm(db, memo) + return memo_read @router.get( diff --git a/backend/src/modules/doc_processing/entrypoints/doc_chunking_job.py b/backend/src/modules/doc_processing/entrypoints/doc_chunking_job.py index 7b01becd7..3f44edec8 100644 --- a/backend/src/modules/doc_processing/entrypoints/doc_chunking_job.py +++ b/backend/src/modules/doc_processing/entrypoints/doc_chunking_job.py @@ -5,9 +5,14 @@ from loguru import logger from common.job_type import JobType +from config import conf from core.doc.folder_crud import crud_folder from core.doc.folder_dto import FolderCreate, FolderType from modules.doc_processing.doc_processing_dto import ProcessingJobInput +from modules.doc_processing.entrypoints.html_chunking_utils import ( + split_html_into_chunks, +) +from modules.doc_processing.entrypoints.txt_chunking_utils import split_text_into_chunks from repos.db.sql_repo import SQLRepo from repos.filesystem_repo import ( FileAlreadyExistsInFilesystemError, @@ -18,6 +23,8 @@ from systems.job_system.job_dto import Job, JobOutputBase from systems.job_system.job_register_decorator import register_job +CHARACTERS_PER_PAGE = conf.chunking.characters_per_page + sqlr = SQLRepo() fsr = FilesystemRepo() ray = RayRepo() @@ -77,6 +84,33 @@ def handle_pdf_chunking_job( return DocChunkingJobOutput(files=chunks, folder_id=folder_id) +def _prepare_chunk_output_path(project_id: int, output_path: Path) -> None: + """ + Prepare the output path for a chunk file by removing any existing file. + + Args: + project_id: The project ID for filesystem operations. + output_path: The path where the chunk will be saved. + + Raises: + FileAlreadyExistsInFilesystemError: If the file exists and cannot be removed + because a SourceDocument with that filename exists in the DB. + """ + if output_path.exists(): + try: + fsr._safe_remove_file_from_project_dir( + proj_id=project_id, filename=output_path.name + ) + except FileDeletionNotAllowedError: + logger.warning( + f"File {output_path.name} already exists in Project {project_id} " + "and a SourceDocument with that filename exists in the DB. Cannot overwrite it!" + ) + raise FileAlreadyExistsInFilesystemError( + proj_id=project_id, filename=output_path.name + ) + + def chunk_pdf(payload: DocChunkingJobInput) -> list[Path]: try: src = fitz.open(str(payload.filepath)) # type: ignore @@ -117,20 +151,9 @@ def chunk_pdf(payload: DocChunkingJobInput) -> list[Path]: new_pdf = fitz.open() # type: ignore new_pdf.insert_pdf(src, from_page=start_page - 1, to_page=end_page - 1) - # If the output file already exists, we try to remove it from the project filesystem - if output_fn.exists(): - try: - fsr._safe_remove_file_from_project_dir( - proj_id=payload.project_id, filename=output_fn.name - ) - except FileDeletionNotAllowedError: - logger.warning( - f"File {output_fn.name} already exists in Project {payload.project_id} and a SourceDocument with that filename" - " exists in the DB. Cannot overwrite it!" - ) - raise FileAlreadyExistsInFilesystemError( - proj_id=payload.project_id, filename=output_fn.name - ) + # Prepare output path (remove existing file if necessary) + _prepare_chunk_output_path(payload.project_id, output_fn) + # Save the chunk to disk new_pdf.save(str(output_fn)) new_pdf.close() @@ -145,8 +168,82 @@ def chunk_pdf(payload: DocChunkingJobInput) -> list[Path]: def chunk_txt(payload: DocChunkingJobInput) -> list[Path]: - logger.info("txt chunking not implemented") - return [payload.filepath] + """ + Chunk a text file into smaller files based on character limits. + + The function attempts to split text at line breaks to preserve document structure. + If no line breaks exist, it falls back to hard character splitting. + + Args: + payload: The job input containing the filepath and chunking settings. + + Returns: + A list of paths to the chunked files. If no chunking is needed, + returns a list containing only the original file path. + """ + characters_per_chunk = CHARACTERS_PER_PAGE * payload.settings.pages_per_chunk + + # Read the text content + try: + text = payload.filepath.read_text(encoding="utf-8") + except Exception as e: + msg = f"Error reading text file {payload.filepath.name}: {e}" + logger.error(msg) + raise RuntimeError(msg) + + # Check if chunking is needed + if len(text) <= characters_per_chunk: + logger.info( + f"Text file {payload.filepath.name} has {len(text)} characters; " + f"no split needed (limit: {characters_per_chunk})." + ) + return [payload.filepath] + + # Split text into chunks + text_chunks = split_text_into_chunks(text, characters_per_chunk) + + # If splitting resulted in only one chunk, no need to save + if len(text_chunks) == 1: + logger.info( + f"Text file {payload.filepath.name} could not be split further; " + "returning original file." + ) + return [payload.filepath] + + # Calculate total "pages" and digits needed for zero-padding + total_chunks = len(text_chunks) + total_pages = total_chunks * payload.settings.pages_per_chunk + total_digits = len(str(total_pages)) + + # Save chunks to disk + out_dir = payload.filepath.parent + logger.info( + f"Splitting text file {payload.filepath.name} into {total_chunks} chunks of " + f"up to {characters_per_chunk} characters each. Output will be saved in {out_dir}." + ) + + chunks: list[Path] = [] + for i, chunk_text in enumerate(text_chunks): + # Calculate page range for this chunk + start_page = i * payload.settings.pages_per_chunk + 1 + end_page = (i + 1) * payload.settings.pages_per_chunk + page_range_str = f"{start_page:0{total_digits}}-{end_page:0{total_digits}}" + output_fn = out_dir / f"{payload.filepath.stem}_pages_{page_range_str}.txt" + + try: + # Prepare output path (remove existing file if necessary) + _prepare_chunk_output_path(payload.project_id, output_fn) + + # Save the chunk to disk + output_fn.write_text(chunk_text, encoding="utf-8") + chunks.append(output_fn) + logger.debug(f"Stored chunk '{output_fn}'") + + except Exception as e: + msg = f"Skipping due to error creating chunk (pages {page_range_str}) for text file {payload.filepath.name}: {e}" + logger.error(msg) + + return chunks def chunk_word(payload: DocChunkingJobInput) -> list[Path]: @@ -155,5 +252,78 @@ def chunk_word(payload: DocChunkingJobInput) -> list[Path]: def chunk_html(payload: DocChunkingJobInput) -> list[Path]: - logger.info("html chunking not implemented") - return [payload.filepath] + """ + Chunk an HTML file into smaller files based on character limits. + + The function splits HTML at element boundaries to preserve document structure + and ensures each chunk is valid HTML by properly opening/closing tags. + + Args: + payload: The job input containing the filepath and chunking settings. + + Returns: + A list of paths to the chunked files. If no chunking is needed, + returns a list containing only the original file path. + """ + characters_per_chunk = CHARACTERS_PER_PAGE * payload.settings.pages_per_chunk + + # Read the HTML content + try: + html_content = payload.filepath.read_text(encoding="utf-8") + except Exception as e: + msg = f"Error reading HTML file {payload.filepath.name}: {e}" + logger.error(msg) + raise RuntimeError(msg) + + # Check if chunking is needed + if len(html_content) <= characters_per_chunk: + logger.info( + f"HTML file {payload.filepath.name} has {len(html_content)} characters; " + f"no split needed (limit: {characters_per_chunk})." + ) + return [payload.filepath] + + # Split HTML into chunks + html_chunks = split_html_into_chunks(html_content, characters_per_chunk) + + # If splitting resulted in only one chunk, no need to save + if len(html_chunks) == 1: + logger.info( + f"HTML file {payload.filepath.name} could not be split further; " + "returning original file." + ) + return [payload.filepath] + + # Calculate total "pages" and digits needed for zero-padding + total_chunks = len(html_chunks) + total_pages = total_chunks * payload.settings.pages_per_chunk + total_digits = len(str(total_pages)) + + # Save chunks to disk + out_dir = payload.filepath.parent + logger.info( + f"Splitting HTML file {payload.filepath.name} into {total_chunks} chunks of " + f"up to {characters_per_chunk} characters each. Output will be saved in {out_dir}." + ) + + chunks: list[Path] = [] + for i, chunk_html in enumerate(html_chunks): + # Calculate page range for this chunk + start_page = i * payload.settings.pages_per_chunk + 1 + end_page = (i + 1) * payload.settings.pages_per_chunk + page_range_str = f"{start_page:0{total_digits}}-{end_page:0{total_digits}}" + output_fn = out_dir / f"{payload.filepath.stem}_pages_{page_range_str}.html" + + try: + # Prepare output path (remove existing file if necessary) + _prepare_chunk_output_path(payload.project_id, output_fn) + + # Save the chunk to disk + output_fn.write_text(chunk_html, encoding="utf-8") + chunks.append(output_fn) + logger.debug(f"Stored chunk '{output_fn}'") + except Exception as e: + msg = f"Skipping due to error creating chunk (pages {page_range_str}) for HTML file {payload.filepath.name}: {e}" + logger.error(msg) + + return chunks diff --git a/backend/src/modules/doc_processing/entrypoints/html_chunking_utils.py b/backend/src/modules/doc_processing/entrypoints/html_chunking_utils.py new file mode 100644 index 000000000..e4a190464 --- /dev/null +++ b/backend/src/modules/doc_processing/entrypoints/html_chunking_utils.py @@ -0,0 +1,275 @@ +import re +from typing import Any + +from bs4 import BeautifulSoup, NavigableString, Tag + + +def split_html_into_chunks(html_content: str, max_chars: int) -> list[str]: + """ + Split HTML content into chunks at element boundaries. + + Each chunk is valid HTML with properly opened/closed tags. + + Args: + html_content: The HTML content to split. + max_chars: Maximum number of characters per chunk. + + Returns: + A list of valid HTML chunks. + """ + soup = BeautifulSoup(html_content, "html.parser") + + # Extract head content if present (to include in each chunk) + head = soup.find("head") + head_html = str(head) if head else "" + + # Find the body or use the whole document + body = soup.find("body") + if body and isinstance(body, Tag): + elements = list(body.children) + else: + # No body tag - treat all top-level elements as content + elements = list(soup.children) + + # Filter out whitespace-only NavigableStrings + elements = [el for el in elements if not (isinstance(el, str) and el.strip() == "")] + + if not elements: + # No elements to split + return [html_content] + + chunks: list[str] = [] + current_elements: list[Any] = [] + current_length = 0 + + # Detect document structure for wrapping + has_html_tag = soup.find("html") is not None + has_body_tag = body is not None + + # Note: We do NOT subtract wrapper overhead from max_chars. + # The wrapper (html, head, body tags) is structural and should not + # count towards the content character limit. + + for element in elements: + element_html = str(element) + element_length = _get_content_length(element_html) + + # If a single element exceeds max_chars, we need to handle it specially + if element_length > max_chars: + # Save current accumulated content first + if current_elements: + chunk_html = _wrap_html_chunk( + current_elements, head_html, has_html_tag, has_body_tag + ) + chunks.append(chunk_html) + current_elements = [] + current_length = 0 + + # Try to split the large element + if isinstance(element, Tag): + sub_chunks = _split_large_element(element, max_chars) + for sub_chunk_elements in sub_chunks: + chunk_html = _wrap_html_chunk( + sub_chunk_elements, head_html, has_html_tag, has_body_tag + ) + chunks.append(chunk_html) + else: + # It's a text node - split by characters + text = str(element) + for i in range(0, len(text), max_chars): + text_chunk = text[i : i + max_chars] + chunk_html = _wrap_html_chunk( + [text_chunk], head_html, has_html_tag, has_body_tag + ) + chunks.append(chunk_html) + continue + + # Check if adding this element would exceed the limit + if current_length + element_length > max_chars and current_elements: + # Save current chunk and start a new one + chunk_html = _wrap_html_chunk( + current_elements, head_html, has_html_tag, has_body_tag + ) + chunks.append(chunk_html) + current_elements = [element] + current_length = element_length + else: + # Add element to current chunk + current_elements.append(element) + current_length += element_length + + # Don't forget the last chunk + if current_elements: + chunk_html = _wrap_html_chunk( + current_elements, head_html, has_html_tag, has_body_tag + ) + chunks.append(chunk_html) + + return chunks + + +def _get_content_length(element_html: str) -> int: + """ + Calculate the content length of an HTML element, excluding img tags. + + Base64-encoded images can be very large but aren't actual text content, + so we exclude them from the character count. + + Args: + element_html: The HTML string to measure. + + Returns: + The length of the HTML string with img tags removed. + """ + # Pattern to match img tags with their content (including base64 data) + IMG_TAG_PATTERN = re.compile(r"]*>", re.IGNORECASE | re.DOTALL) + + # Remove img tags from the string for length calculation + content_without_images = IMG_TAG_PATTERN.sub("", element_html) + return len(content_without_images) + + +def _wrap_html_chunk( + elements: list[Any], + head_html: str, + has_html_tag: bool, + has_body_tag: bool, +) -> str: + """ + Wrap a list of elements in proper HTML structure. + + Args: + elements: List of HTML elements/strings to wrap. + head_html: The head section HTML to include. + has_html_tag: Whether the original had an html tag. + has_body_tag: Whether the original had a body tag. + + Returns: + A valid HTML string. + """ + content = "".join(str(el) for el in elements) + + if has_body_tag: + content = f"{content}" + + if has_html_tag: + content = f"{head_html}{content}" + elif head_html: + content = f"{head_html}{content}" + + return content + + +def _split_large_element(element: Tag, max_chars: int) -> list[list[Any]]: + """ + Split a large HTML element into smaller chunks by its children. + + Args: + element: The HTML element to split. + max_chars: Maximum characters per chunk. + + Returns: + A list of element lists, each representing a chunk's content. + """ + children = list(element.children) + children = [ + ch + for ch in children + if not (isinstance(ch, NavigableString) and ch.strip() == "") + ] + + if not children: + # Element has no children - can't split further, return as-is + return [[element]] + + # Calculate wrapper overhead for this element's tag + tag_name = element.name + attrs = ( + "".join(f' {k}="{v}"' for k, v in element.attrs.items()) + if element.attrs + else "" + ) + wrapper_overhead = len(f"<{tag_name}{attrs}>") + effective_max = max_chars - wrapper_overhead + + if effective_max <= 0: + # Can't fit wrapper, just return element as-is + return [[element]] + + chunks: list[list[Any]] = [] + current_children: list[Any] = [] + current_length = 0 + + for child in children: + child_html = str(child) + child_length = _get_content_length(child_html) + + # If single child is too large, recursively split it + if child_length > effective_max: + # Save current content first + if current_children: + wrapped = _wrap_element_children(element, current_children) + chunks.append([wrapped]) + current_children = [] + current_length = 0 + + if isinstance(child, Tag): + sub_chunks = _split_large_element(child, effective_max) + for sub_chunk in sub_chunks: + # Wrap each sub-chunk in the parent element + wrapped = _wrap_element_children(element, sub_chunk) + chunks.append([wrapped]) + else: + # Text node - split by characters + text = str(child) + for i in range(0, len(text), effective_max): + text_chunk = text[i : i + effective_max] + wrapped = _wrap_element_children(element, [text_chunk]) + chunks.append([wrapped]) + continue + + # Check if adding this child would exceed the limit + if current_length + child_length > effective_max and current_children: + wrapped = _wrap_element_children(element, current_children) + chunks.append([wrapped]) + current_children = [child] + current_length = child_length + else: + current_children.append(child) + current_length += child_length + + # Don't forget the last chunk + if current_children: + wrapped = _wrap_element_children(element, current_children) + chunks.append([wrapped]) + + return chunks + + +def _wrap_element_children(parent: Tag, children: list[Any]) -> Tag: + """ + Create a new element with the same tag and attributes as parent, containing the given children. + + Args: + parent: The parent element to clone. + children: The children to include in the new element. + + Returns: + A new Tag with the same properties as parent but with specified children. + """ + # Create a new soup to build the element + new_soup = BeautifulSoup("", "html.parser") + new_tag = new_soup.new_tag(parent.name, attrs=parent.attrs) + + for child in children: + if isinstance(child, NavigableString): + new_tag.append(str(child)) + elif isinstance(child, str): + new_tag.append(child) + else: + # Need to copy the child to avoid modifying original + child_copy = BeautifulSoup(str(child), "html.parser") + for item in child_copy.children: + new_tag.append(item) + + return new_tag diff --git a/backend/src/modules/doc_processing/entrypoints/txt_chunking_utils.py b/backend/src/modules/doc_processing/entrypoints/txt_chunking_utils.py new file mode 100644 index 000000000..8978239bd --- /dev/null +++ b/backend/src/modules/doc_processing/entrypoints/txt_chunking_utils.py @@ -0,0 +1,104 @@ +from loguru import logger + + +def split_text_into_chunks(text: str, max_chars: int) -> list[str]: + """ + Split text into chunks of at most max_chars characters. + + Attempts to split at line breaks to preserve document structure. + If no line breaks exist, falls back to hard character splitting. + + Args: + text: The text content to split. + max_chars: Maximum number of characters per chunk. + + Returns: + A list of text chunks. + """ + # Check if text contains line breaks + has_line_breaks = "\n" in text + + if has_line_breaks: + return _split_text_by_lines(text, max_chars) + else: + # No line breaks - fall back to hard character splitting + logger.warning( + "Text has no line breaks. Falling back to hard character splitting, " + "which may split words." + ) + return _split_text_by_chars(text, max_chars) + + +def _split_text_by_lines(text: str, max_chars: int) -> list[str]: + """ + Split text into chunks at line breaks, respecting the character limit. + + Args: + text: The text content to split. + max_chars: Maximum number of characters per chunk. + + Returns: + A list of text chunks split at line boundaries. + """ + lines = text.split("\n") + chunks: list[str] = [] + current_chunk_lines: list[str] = [] + current_chunk_length = 0 + + for line in lines: + # Calculate length including the newline character we'll add back + line_length = len(line) + 1 # +1 for the newline + + # If a single line exceeds max_chars, we need to split it + if line_length > max_chars: + # First, save any accumulated content + if current_chunk_lines: + chunks.append("\n".join(current_chunk_lines)) + current_chunk_lines = [] + current_chunk_length = 0 + + # Split the long line by characters + line_chunks = _split_text_by_chars(line, max_chars) + chunks.extend(line_chunks) + continue + + # Check if adding this line would exceed the limit + new_length = current_chunk_length + line_length + if current_chunk_lines: + # Account for the newline between existing content and new line + new_length = current_chunk_length + line_length + + if new_length > max_chars and current_chunk_lines: + # Save current chunk and start a new one + chunks.append("\n".join(current_chunk_lines)) + current_chunk_lines = [line] + current_chunk_length = line_length + else: + # Add line to current chunk + current_chunk_lines.append(line) + current_chunk_length = new_length + + # Don't forget the last chunk + if current_chunk_lines: + chunks.append("\n".join(current_chunk_lines)) + + return chunks + + +def _split_text_by_chars(text: str, max_chars: int) -> list[str]: + """ + Split text into chunks of exactly max_chars characters (hard split). + + This is a fallback when no line breaks are available. + + Args: + text: The text content to split. + max_chars: Maximum number of characters per chunk. + + Returns: + A list of text chunks. + """ + chunks: list[str] = [] + for i in range(0, len(text), max_chars): + chunks.append(text[i : i + max_chars]) + return chunks diff --git a/backend/src/modules/doc_processing/html/html_cleaning_utils.py b/backend/src/modules/doc_processing/html/html_cleaning_utils.py index 8b46df533..c1138fc12 100644 --- a/backend/src/modules/doc_processing/html/html_cleaning_utils.py +++ b/backend/src/modules/doc_processing/html/html_cleaning_utils.py @@ -113,7 +113,6 @@ def x(html_content: str) -> str: "width", "height", "target", - "pagenum", ], ), string_replace(replace={"\n": "", "<": "❮", ">": "❯"}), @@ -136,7 +135,6 @@ def x(html_content: str) -> str: "width", "height", "target", - "pagenum", ], ), string_replace(replace={"\n": "", "<": "❮", ">": "❯"}), diff --git a/backend/src/modules/doc_processing/text/html_extraction_job.py b/backend/src/modules/doc_processing/text/html_extraction_job.py index ea9d212e2..8ad4efc16 100644 --- a/backend/src/modules/doc_processing/text/html_extraction_job.py +++ b/backend/src/modules/doc_processing/text/html_extraction_job.py @@ -166,10 +166,20 @@ def extract_html_from_pdf(payload: ExtractHTMLJobInput) -> tuple[str, list[Path] def extract_html_from_text(payload: ExtractHTMLJobInput) -> tuple[str, list[Path]]: + """ + Convert a text file to HTML, preserving line breaks as paragraphs. + + Each non-empty line becomes a

element to maintain the original formatting. + """ logger.debug(f"Extracting content as HTML from TEXT {payload.filepath.name} ...") content = payload.filepath.read_text(encoding="utf-8") - html_content = f"

{content}

" + + # Split by line breaks and wrap each non-empty line in

tags + lines = content.split("\n") + paragraphs = [f"

{line}

" for line in lines if line.strip()] + + html_content = f"{''.join(paragraphs)}" return html_content, [] diff --git a/backend/src/modules/whiteboard/whiteboard_crud.py b/backend/src/modules/whiteboard/whiteboard_crud.py index 410a615d5..f5f7edd11 100644 --- a/backend/src/modules/whiteboard/whiteboard_crud.py +++ b/backend/src/modules/whiteboard/whiteboard_crud.py @@ -10,7 +10,6 @@ from core.doc.source_document_crud import crud_sdoc from core.doc.source_document_dto import SourceDocumentRead from core.memo.memo_crud import crud_memo -from core.memo.memo_dto import MemoRead from core.project.project_crud import crud_project from core.tag.tag_dto import TagRead from modules.whiteboard.whiteboard_dto import ( @@ -105,7 +104,7 @@ def read_data(self, db: Session, *, id: int) -> WhiteboardData: MemoNodeData.model_validate(node.data).memoId for node in nodes ] result.memos = [ - MemoRead.model_validate(m) + crud_memo.get_memo_read_dto_from_orm(db=db, db_obj=m) for m in crud_memo.read_by_ids(db=db, ids=memo_ids) ] case WhiteboardNodeType.SDOC: diff --git a/backend/src/systems/search_system/search_builder.py b/backend/src/systems/search_system/search_builder.py index 302b684a9..acaa0ad4d 100644 --- a/backend/src/systems/search_system/search_builder.py +++ b/backend/src/systems/search_system/search_builder.py @@ -33,6 +33,16 @@ class SearchBuilder: + """ + To debug a query, you should set a breakpoint in the `execute_query` method, e.g. after befor running the query with `query.all()`. + Then, inspect the `self.query` and `self.subquery` attributes to see the built SQLAlchemy queries. + You can convert them to raw SQL strings using the `str()` function, e.g. + ```python + str(query) + ``` + This will give you the raw SQL that SQLAlchemy has generated, which you can then analyze or run directly against your database for debugging purposes. + """ + def __init__(self, db: Session, filter: Filter[T], sorts: list[Sort[T]]) -> None: self.db = db self.filter = filter @@ -147,7 +157,7 @@ def _add_subquery_metadata_filter_statements(self, project_metadata_id: int): self.subquery.add_column( metadata_value_column.label(f"METADATA-{project_metadata_id}") ) - .join( + .outerjoin( metadata, (SourceDocumentORM.id == metadata.source_document_id) & (metadata.project_metadata_id == project_metadata_id), diff --git a/backend/test/conftest.py b/backend/test/conftest.py index e012bc8ac..7719db5f2 100755 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -336,7 +336,7 @@ def upload_files(self, upload_list: list, user: dict, project: dict): files = [] settings = { "extract_images": True, - "pages_per_chunk": 10, + "pages_per_chunk": 100, # we do not want chunking in tests "keyword_number": 5, "keyword_deduplication_threshold": 0.5, "keyword_max_ngram_size": 2, diff --git a/frontend/src/api/CodeHooks.ts b/frontend/src/api/CodeHooks.ts index 74999167f..1ca74fdfc 100644 --- a/frontend/src/api/CodeHooks.ts +++ b/frontend/src/api/CodeHooks.ts @@ -1,8 +1,9 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { useCallback } from "react"; import queryClient from "../plugins/ReactQueryClient.ts"; -import { useAppSelector } from "../plugins/ReduxHooks.ts"; +import { useAppDispatch, useAppSelector } from "../plugins/ReduxHooks.ts"; import { RootState } from "../store/store.ts"; +import { AnnoActions } from "../views/annotation/annoSlice.ts"; import { QueryKey } from "./QueryKey.ts"; import { CodeRead } from "./openapi/models/CodeRead.ts"; import { CodeService } from "./openapi/services/CodeService.ts"; @@ -54,8 +55,8 @@ const useGetEnabledCodes = () => { }; // CODE MUTATIONS -const useCreateCode = () => - useMutation({ +const useCreateCode = () => { + return useMutation({ mutationFn: CodeService.createNewCode, onSuccess: (data, variables) => { queryClient.setQueryData([QueryKey.PROJECT_CODES, variables.requestBody.project_id], (oldData) => @@ -66,6 +67,7 @@ const useCreateCode = () => successMessage: (data: CodeRead) => `Created code ${data.name}`, }, }); +}; const useUpdateCode = () => useMutation({ @@ -84,8 +86,9 @@ const useUpdateCode = () => }, }); -const useDeleteCode = () => - useMutation({ +const useDeleteCode = () => { + const dispatch = useAppDispatch(); + return useMutation({ mutationFn: CodeService.deleteById, onSuccess: (data) => { queryClient.setQueryData([QueryKey.PROJECT_CODES, data.project_id], (oldData) => { @@ -94,11 +97,20 @@ const useDeleteCode = () => delete newData[data.id]; return newData; }); + // reset global server state: invalidate everything that could be affected by a code + queryClient.invalidateQueries({ queryKey: [QueryKey.SDOC_SPAN_ANNOTATIONS] }); + queryClient.invalidateQueries({ queryKey: [QueryKey.SDOC_BBOX_ANNOTATIONS] }); + queryClient.invalidateQueries({ queryKey: [QueryKey.SDOC_SENTENCE_ANNOTATOR] }); + queryClient.invalidateQueries({ queryKey: [QueryKey.PROJECT_WHITEBOARDS, data.project_id] }); + queryClient.invalidateQueries({ queryKey: [QueryKey.FILTER_ENTITY_STATISTICS, data.id] }); + // reset global client state + dispatch(AnnoActions.onDeleteCode(data.id)); }, meta: { successMessage: (data: CodeRead) => `Deleted code ${data.name}`, }, }); +}; const CodeHooks = { // codes diff --git a/frontend/src/api/StatisticsHooks.ts b/frontend/src/api/StatisticsHooks.ts index ae0df391b..475d4dccc 100644 --- a/frontend/src/api/StatisticsHooks.ts +++ b/frontend/src/api/StatisticsHooks.ts @@ -12,7 +12,7 @@ const useFilterCodeStats = (codeId: number, sdocIds: number[] | null | undefined const sortStatsByGlobal = useAppSelector((state) => state.search.sortStatsByGlobal); return useQuery({ - queryKey: [QueryKey.FILTER_ENTITY_STATISTICS, sdocIds, codeId, sortStatsByGlobal], + queryKey: [QueryKey.FILTER_ENTITY_STATISTICS, codeId, sdocIds, sortStatsByGlobal], queryFn: () => StatisticsService.filterCodeStats({ codeId, diff --git a/frontend/src/components/FilterDialog/FilterExpressionRenderer/components/FilterOperatorSelector.tsx b/frontend/src/components/FilterDialog/FilterExpressionRenderer/components/FilterOperatorSelector.tsx index 6d664fb89..c3771a9d6 100644 --- a/frontend/src/components/FilterDialog/FilterExpressionRenderer/components/FilterOperatorSelector.tsx +++ b/frontend/src/components/FilterDialog/FilterExpressionRenderer/components/FilterOperatorSelector.tsx @@ -36,7 +36,7 @@ const operator2HumanReadable: Record = { [DateOperator.DATE_GT]: ">", [DateOperator.DATE_LT]: "<", [DateOperator.DATE_GTE]: ">=", - [DateOperator.DATE_LTE]: "<+", + [DateOperator.DATE_LTE]: "<=", [BooleanOperator.BOOLEAN_EQUALS]: "is", [BooleanOperator.BOOLEAN_NOT_EQUALS]: "is not", }; diff --git a/frontend/src/components/LLMDialog/steps/AnnotationResultStep/TextAnnotationValidationMenu.tsx b/frontend/src/components/LLMDialog/steps/AnnotationResultStep/TextAnnotationValidationMenu.tsx index e6cf21051..16a9a1807 100644 --- a/frontend/src/components/LLMDialog/steps/AnnotationResultStep/TextAnnotationValidationMenu.tsx +++ b/frontend/src/components/LLMDialog/steps/AnnotationResultStep/TextAnnotationValidationMenu.tsx @@ -224,7 +224,7 @@ function AnnotationMenuListItem({ annotation, handleEdit, handleDelete }: Annota handleDelete(annotation); }, [annotation, handleDelete]); - if (code.isSuccess) { + if (code.data) { return ( diff --git a/frontend/src/components/Memo/MemoBlockEditor.tsx b/frontend/src/components/Memo/MemoBlockEditor.tsx index a5b791ce7..424443997 100644 --- a/frontend/src/components/Memo/MemoBlockEditor.tsx +++ b/frontend/src/components/Memo/MemoBlockEditor.tsx @@ -22,7 +22,7 @@ function MemoBlockEditor({ memoId, renderToolbar, onDelete, onStarred }: MemoBlo // global client state const { user } = useAuth(); const memo = MemoHooks.useGetMemo(memoId); - const attachedObject = useGetMemosAttachedObject(memo.data?.attached_object_type)(memo.data?.attached_object_id); + const attachedObject = useGetMemosAttachedObject(memo.data?.attached_object_type, memo.data?.attached_object_id); const isEditable = useMemo(() => user?.id === memo.data?.user_id, [user?.id, memo.data?.user_id]); @@ -66,8 +66,8 @@ function MemoBlockEditor({ memoId, renderToolbar, onDelete, onStarred }: MemoBlo ) : memo.isError ? (
Error: {memo.error.message}
) : attachedObject.isError ? ( -
Error: {attachedObject.error.message}
- ) : memo.isSuccess && attachedObject.isSuccess ? ( +
Error: {attachedObject.error?.message}
+ ) : memo.isSuccess && attachedObject.isSuccess && attachedObject.data ? ( <> diff --git a/frontend/src/components/Memo/MemoCard.tsx b/frontend/src/components/Memo/MemoCard.tsx index 18efc816a..d4b69d63d 100644 --- a/frontend/src/components/Memo/MemoCard.tsx +++ b/frontend/src/components/Memo/MemoCard.tsx @@ -29,7 +29,7 @@ function MemoCardWithContent({ onDeleteClick, onStarredClick, }: MemoCardSharedProps & { memo: MemoRead }) { - const attachedObject = useGetMemosAttachedObject(memo.attached_object_type)(memo.attached_object_id); + const attachedObject = useGetMemosAttachedObject(memo.attached_object_type, memo.attached_object_id); const handleClick = useCallback(() => { if (onClick) { diff --git a/frontend/src/components/Memo/MemoDialog/MemoDialog.tsx b/frontend/src/components/Memo/MemoDialog/MemoDialog.tsx index c75cf364e..7b6a62e52 100644 --- a/frontend/src/components/Memo/MemoDialog/MemoDialog.tsx +++ b/frontend/src/components/Memo/MemoDialog/MemoDialog.tsx @@ -122,7 +122,7 @@ function MemoDialog2({ // 1. memoId is set, attachedObjectId is set // 2. memoId is not set, attachedObjectId is set // 3. memoId is set, attachedObjectId is not set - const attachedObject = useGetMemosAttachedObject(attachedObjectType)(attachedObjectId); + const attachedObject = useGetMemosAttachedObject(attachedObjectType, attachedObjectId); return ( <> diff --git a/frontend/src/components/Memo/MemoRenderer.tsx b/frontend/src/components/Memo/MemoRenderer.tsx index f01f75182..412d35134 100644 --- a/frontend/src/components/Memo/MemoRenderer.tsx +++ b/frontend/src/components/Memo/MemoRenderer.tsx @@ -1,7 +1,8 @@ import StarIcon from "@mui/icons-material/Star"; import StarOutlineIcon from "@mui/icons-material/StarOutline"; -import { Stack, StackProps } from "@mui/material"; +import { Box, Stack, StackProps } from "@mui/material"; import { memo } from "react"; +import Markdown from "react-markdown"; import MemoHooks from "../../api/MemoHooks.ts"; import { MemoRead } from "../../api/openapi/models/MemoRead.ts"; import { Icon, getIconComponent } from "../../utils/icons/iconUtils.tsx"; @@ -91,7 +92,11 @@ export function MemoRendererWithData({ {showIcon && getIconComponent(Icon.MEMO, { sx: { mr: 1 } })} {showTitle && memo.title} - {showContent && memo.content} + {showContent && ( + + {memo.content} + + )} {showUser && } {showStar && (memo.starred ? : )} {showAttachedObject && ( diff --git a/frontend/src/components/Memo/useGetMemosAttachedObject.ts b/frontend/src/components/Memo/useGetMemosAttachedObject.ts index f513229e7..f0b40367f 100644 --- a/frontend/src/components/Memo/useGetMemosAttachedObject.ts +++ b/frontend/src/components/Memo/useGetMemosAttachedObject.ts @@ -5,24 +5,61 @@ import SentenceAnnotationHooks from "../../api/SentenceAnnotationHooks.ts"; import SpanAnnotationHooks from "../../api/SpanAnnotationHooks.ts"; import TagHooks from "../../api/TagHooks.ts"; import { AttachedObjectType } from "../../api/openapi/models/AttachedObjectType.ts"; +import { CodeRead } from "../../api/openapi/models/CodeRead.ts"; + +/** + * Hook to fetch the attached object of a memo based on its type. + * All hooks are called unconditionally to satisfy React's rules of hooks. + * Only the relevant query will be enabled based on the type (passing undefined disables the query). + * @param type - The type of the attached object + * @param id - The id of the attached object + * @returns The query result for the attached object + */ +const useGetMemosAttachedObject = (type: AttachedObjectType | undefined, id: number | undefined) => { + // Pass the id only when the type matches, otherwise pass undefined to disable the query + const tagQuery = TagHooks.useGetTag(type === AttachedObjectType.TAG ? id : undefined); + const codeQuery = CodeHooks.useGetCode(type === AttachedObjectType.CODE ? id : undefined); + const sdocQuery = SdocHooks.useGetDocument(type === AttachedObjectType.SOURCE_DOCUMENT ? id : undefined); + const spanQuery = SpanAnnotationHooks.useGetAnnotation(type === AttachedObjectType.SPAN_ANNOTATION ? id : undefined); + const bboxQuery = BboxAnnotationHooks.useGetAnnotation(type === AttachedObjectType.BBOX_ANNOTATION ? id : undefined); + const sentenceQuery = SentenceAnnotationHooks.useGetAnnotation( + type === AttachedObjectType.SENTENCE_ANNOTATION ? id : undefined, + ); -const useGetMemosAttachedObject = (type: AttachedObjectType | undefined) => { switch (type) { case AttachedObjectType.TAG: - return TagHooks.useGetTag; + return tagQuery; case AttachedObjectType.CODE: - return CodeHooks.useGetCode; + return codeQuery; case AttachedObjectType.SOURCE_DOCUMENT: - return SdocHooks.useGetDocument; + return sdocQuery; case AttachedObjectType.SPAN_ANNOTATION: - return SpanAnnotationHooks.useGetAnnotation; + return spanQuery; case AttachedObjectType.BBOX_ANNOTATION: - return BboxAnnotationHooks.useGetAnnotation; + return bboxQuery; case AttachedObjectType.SENTENCE_ANNOTATION: - return SentenceAnnotationHooks.useGetAnnotation; - default: - console.warn("Unknown attached object type:", type); - return CodeHooks.useGetCode; + return sentenceQuery; + default: { + // Return a "disabled" query-like object when type is undefined + const placeholder: CodeRead = { + id: 0, + name: "", + color: "", + description: "", + created: new Date().toISOString(), + updated: new Date().toISOString(), + project_id: 0, + is_system: false, + memo_ids: [], + }; + return { + data: placeholder, + isLoading: false, + isError: false, + isSuccess: type === undefined, + error: null, + }; + } } }; diff --git a/frontend/src/components/tableSlice.ts b/frontend/src/components/tableSlice.ts index 87699757e..fdb8d1ac3 100644 --- a/frontend/src/components/tableSlice.ts +++ b/frontend/src/components/tableSlice.ts @@ -2,6 +2,7 @@ import { Draft, PayloadAction, createSelector } from "@reduxjs/toolkit"; import { MRT_ColumnSizingState, MRT_DensityState, + MRT_ExpandedState, MRT_RowSelectionState, MRT_SortingState, MRT_VisibilityState, @@ -14,6 +15,7 @@ export interface TableState { columnVisibilityModel: MRT_VisibilityState; columnSizingModel: MRT_ColumnSizingState; gridDensityModel: MRT_DensityState; + expandedModel: MRT_ExpandedState; fetchSize: number; } @@ -24,6 +26,7 @@ export const initialTableState: TableState = { sortingModel: [], columnVisibilityModel: {}, columnSizingModel: {}, + expandedModel: {}, fetchSize: 20, // app state: gridDensityModel: "comfortable", @@ -52,6 +55,10 @@ export const tableReducer = { onColumnVisibilityChange: (state: Draft, action: PayloadAction) => { state.columnVisibilityModel = action.payload; }, + // expanded + onExpandedChange: (state: Draft, action: PayloadAction) => { + state.expandedModel = action.payload; + }, // column sizing onColumnSizingChange: (state: Draft, action: PayloadAction) => { state.columnSizingModel = action.payload; diff --git a/frontend/src/views/annotation/Annotation.tsx b/frontend/src/views/annotation/Annotation.tsx index ef1099a2b..f58f38d53 100644 --- a/frontend/src/views/annotation/Annotation.tsx +++ b/frontend/src/views/annotation/Annotation.tsx @@ -10,9 +10,9 @@ import EditableTypography from "../../components/EditableTypography.tsx"; import DocumentInformation from "../../components/SourceDocument/DocumentInformation/DocumentInformation.tsx"; import SidebarContentSidebarLayout from "../../layouts/ContentLayouts/SidebarContentSidebarLayout.tsx"; import { useAppSelector } from "../../plugins/ReduxHooks.ts"; -import BBoxAnnotationExplorer from "./AnnotationExploer/BBoxAnnotationExplorer.tsx"; -import SentenceAnnotationExplorer from "./AnnotationExploer/SentenceAnnotationExplorer.tsx"; -import SpanAnnotationExplorer from "./AnnotationExploer/SpanAnnotationExplorer.tsx"; +import BBoxAnnotationExplorer from "./AnnotationExplorer/BBoxAnnotationExplorer.tsx"; +import SentenceAnnotationExplorer from "./AnnotationExplorer/SentenceAnnotationExplorer.tsx"; +import SpanAnnotationExplorer from "./AnnotationExplorer/SpanAnnotationExplorer.tsx"; import AnnotationMode from "./AnnotationMode.ts"; import AudioVideoViewer from "./DocumentViewer/AudioVideoViewer.tsx"; import ImageViewer from "./DocumentViewer/ImageViewer.tsx"; diff --git a/frontend/src/views/annotation/AnnotationExploer/AnnotationCardActionMenu.tsx b/frontend/src/views/annotation/AnnotationExplorer/AnnotationCardActionMenu.tsx similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/AnnotationCardActionMenu.tsx rename to frontend/src/views/annotation/AnnotationExplorer/AnnotationCardActionMenu.tsx diff --git a/frontend/src/views/annotation/AnnotationExploer/AnnotationCardMemo.tsx b/frontend/src/views/annotation/AnnotationExplorer/AnnotationCardMemo.tsx similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/AnnotationCardMemo.tsx rename to frontend/src/views/annotation/AnnotationExplorer/AnnotationCardMemo.tsx diff --git a/frontend/src/views/annotation/AnnotationExploer/AnnotationExplorer.tsx b/frontend/src/views/annotation/AnnotationExplorer/AnnotationExplorer.tsx similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/AnnotationExplorer.tsx rename to frontend/src/views/annotation/AnnotationExplorer/AnnotationExplorer.tsx diff --git a/frontend/src/views/annotation/AnnotationExploer/BBoxAnnotationCard.tsx b/frontend/src/views/annotation/AnnotationExplorer/BBoxAnnotationCard.tsx similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/BBoxAnnotationCard.tsx rename to frontend/src/views/annotation/AnnotationExplorer/BBoxAnnotationCard.tsx diff --git a/frontend/src/views/annotation/AnnotationExploer/BBoxAnnotationDeleteMenuItem.tsx b/frontend/src/views/annotation/AnnotationExplorer/BBoxAnnotationDeleteMenuItem.tsx similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/BBoxAnnotationDeleteMenuItem.tsx rename to frontend/src/views/annotation/AnnotationExplorer/BBoxAnnotationDeleteMenuItem.tsx diff --git a/frontend/src/views/annotation/AnnotationExploer/BBoxAnnotationExplorer.tsx b/frontend/src/views/annotation/AnnotationExplorer/BBoxAnnotationExplorer.tsx similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/BBoxAnnotationExplorer.tsx rename to frontend/src/views/annotation/AnnotationExplorer/BBoxAnnotationExplorer.tsx diff --git a/frontend/src/views/annotation/AnnotationExploer/SentenceAnnotationCard.tsx b/frontend/src/views/annotation/AnnotationExplorer/SentenceAnnotationCard.tsx similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/SentenceAnnotationCard.tsx rename to frontend/src/views/annotation/AnnotationExplorer/SentenceAnnotationCard.tsx diff --git a/frontend/src/views/annotation/AnnotationExploer/SentenceAnnotationDeleteMenuItem.tsx b/frontend/src/views/annotation/AnnotationExplorer/SentenceAnnotationDeleteMenuItem.tsx similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/SentenceAnnotationDeleteMenuItem.tsx rename to frontend/src/views/annotation/AnnotationExplorer/SentenceAnnotationDeleteMenuItem.tsx diff --git a/frontend/src/views/annotation/AnnotationExploer/SentenceAnnotationExplorer.tsx b/frontend/src/views/annotation/AnnotationExplorer/SentenceAnnotationExplorer.tsx similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/SentenceAnnotationExplorer.tsx rename to frontend/src/views/annotation/AnnotationExplorer/SentenceAnnotationExplorer.tsx diff --git a/frontend/src/views/annotation/AnnotationExploer/SpanAnnotationCard.tsx b/frontend/src/views/annotation/AnnotationExplorer/SpanAnnotationCard.tsx similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/SpanAnnotationCard.tsx rename to frontend/src/views/annotation/AnnotationExplorer/SpanAnnotationCard.tsx diff --git a/frontend/src/views/annotation/AnnotationExploer/SpanAnnotationDeleteMenuItem.tsx b/frontend/src/views/annotation/AnnotationExplorer/SpanAnnotationDeleteMenuItem.tsx similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/SpanAnnotationDeleteMenuItem.tsx rename to frontend/src/views/annotation/AnnotationExplorer/SpanAnnotationDeleteMenuItem.tsx diff --git a/frontend/src/views/annotation/AnnotationExploer/SpanAnnotationExplorer.tsx b/frontend/src/views/annotation/AnnotationExplorer/SpanAnnotationExplorer.tsx similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/SpanAnnotationExplorer.tsx rename to frontend/src/views/annotation/AnnotationExplorer/SpanAnnotationExplorer.tsx diff --git a/frontend/src/views/annotation/AnnotationExploer/types/AnnotationCardProps.ts b/frontend/src/views/annotation/AnnotationExplorer/types/AnnotationCardProps.ts similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/types/AnnotationCardProps.ts rename to frontend/src/views/annotation/AnnotationExplorer/types/AnnotationCardProps.ts diff --git a/frontend/src/views/annotation/AnnotationExploer/types/AnnotationRead.ts b/frontend/src/views/annotation/AnnotationExplorer/types/AnnotationRead.ts similarity index 100% rename from frontend/src/views/annotation/AnnotationExploer/types/AnnotationRead.ts rename to frontend/src/views/annotation/AnnotationExplorer/types/AnnotationRead.ts diff --git a/frontend/src/views/annotation/AnnotationMenu/AnnotationMenu.tsx b/frontend/src/views/annotation/AnnotationMenu/AnnotationMenu.tsx index e707543e8..0e3c13dc7 100644 --- a/frontend/src/views/annotation/AnnotationMenu/AnnotationMenu.tsx +++ b/frontend/src/views/annotation/AnnotationMenu/AnnotationMenu.tsx @@ -25,17 +25,16 @@ import { useWithLevel } from "../../../components/TreeExplorer/useWithLevel.ts"; import { useAppDispatch } from "../../../plugins/ReduxHooks.ts"; import { getIconComponent, Icon } from "../../../utils/icons/iconUtils.tsx"; import { Annotation, Annotations } from "../Annotation.ts"; -import { ICode } from "../ICode.ts"; import { useComputeCodesForSelection } from "./useComputeCodesForSelection.ts"; const filter = createFilterOptions(); interface CodeSelectorProps { onClose?: (reason?: "backdropClick" | "escapeKeyDown") => void; - onAdd?: (code: CodeRead, isNewCode: boolean) => void; - onEdit?: (annotationToEdit: Annotation, newCode: ICode) => void; + onAdd?: (codeId: number, isNewCode: boolean) => void; + onEdit?: (annotationToEdit: Annotation, codeId: number) => void; onDelete?: (annotationToDelete: Annotation) => void; - onDuplicate?: (annotationToDuplicate: Annotation, currentCode: CodeRead) => void; + onDuplicate?: (annotationToDuplicate: Annotation, codeId: number) => void; } export interface CodeSelectorHandle { @@ -150,15 +149,14 @@ const AnnotationMenu = forwardRef( // submit the code selector (either we edited or created a new code) const submit = (code: CodeRead, isNewCode: boolean) => { - console.log("HI THIS IS TIM!", editingAnnotation); // when the user selected an annotation to edit, we were editing if (editingAnnotation !== undefined) { - if (onEdit) onEdit(editingAnnotation, code); + if (onEdit) onEdit(editingAnnotation, code.id); // otherwise, we opened this to add a new code } else if (duplicatingAnnotation !== undefined) { - if (onDuplicate) onDuplicate(duplicatingAnnotation, code); + if (onDuplicate) onDuplicate(duplicatingAnnotation, code.id); } else { - if (onAdd) onAdd(code, isNewCode); + if (onAdd) onAdd(code.id, isNewCode); } closeCodeSelector(); }; diff --git a/frontend/src/views/annotation/DocumentRenderer/CodeIndicator.tsx b/frontend/src/views/annotation/DocumentRenderer/CodeIndicator.tsx index a967158d8..77543f9d0 100644 --- a/frontend/src/views/annotation/DocumentRenderer/CodeIndicator.tsx +++ b/frontend/src/views/annotation/DocumentRenderer/CodeIndicator.tsx @@ -8,10 +8,18 @@ interface CodeIndicatorProps { groups?: number[]; } +/** + * Renders a stylish tag/badge indicator for a code annotation. + * Displays the code name with a colored pill design for better visual recognition. + * @param codeId - The ID of the code to display + * @param annotationId - The ID of the annotation this indicator belongs to + * @param isSelected - Whether this annotation is currently selected + * @param groups - Optional group IDs for coreference annotations + */ function CodeIndicator({ codeId, annotationId, isSelected, groups }: CodeIndicatorProps) { const code = CodeHooks.useGetCode(codeId); - if (code.isSuccess) { + if (code.data) { let text: string; let color: string; if (code.data.is_system && code.data.name === "MENTION" && groups && groups.length === 1) { @@ -26,19 +34,21 @@ function CodeIndicator({ codeId, annotationId, isSelected, groups }: CodeIndicat return ( - {text} + + {text} ); } return ( - - {" "} - ... + + ... ); } diff --git a/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.css b/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.css index 6e629ad0f..b034addbe 100644 --- a/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.css +++ b/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.css @@ -1,76 +1,134 @@ -.mark { - position:absolute; - width:100%; - top:0; - left:0; - z-index: -1; +.hovered .text { + font-weight: bold; } -.mark.start { - border-top-left-radius: 6px; - border-bottom-left-radius: 6px; +rect.hovered { + fill: #ffff0088; } -.mark.end { - border-top-right-radius: 6px; - border-bottom-right-radius: 6px; +.hoversentence:hover { + background-color: rgba(0, 255, 255, 0.5); + cursor: pointer; } +.filterhighlight { + background-color: yellow; +} + +.jumphighlight { + background-color: lightgreen; +} + +/* +TOKEN: Token.tsx +A token (.tok) consists of: +- A group (.spangroup) of CodeIndicator(s) (for NER tags) -> CodeIndicator.tsx +- Text (.text): the actual token text +- Mark (.mark): the background marks indicating span boundaries -> Mark.tsx +*/ + .tok { - position: relative; + position: relative; + white-space: nowrap; } .text { - white-space: nowrap; + white-space: nowrap; + position: relative; + z-index: 1; } -.hovered .text { - font-weight: bold; +.spangroup { + user-select: none; + position: relative; + z-index: 1; } -rect.hovered { - fill: #ffff0088; +.spangroup.above { + position: absolute; + bottom: 100%; + left: 0; + line-height: 12px; + white-space: nowrap; + user-select: auto; } -.spangroup { - user-select: none; - font-size: 14px; - white-space: nowrap; +.spangroup.above > span { + margin-left: 0; + margin-right: 4px; } -.spangroup.inline { - vertical-align: top; - margin-left: 4px; +.spangroup.above:hover { + z-index: 100; } -.spangroup.above { - position: absolute; - bottom: 100%; - left: 0; - line-height: 12px; - white-space: nowrap; - user-select: auto; - z-index: -1; +/* +CODE INDICATOR: CodeIndicator.tsx +This is the styling of the code indicators that show NER tags above or inline with tokens. +A code indicator (.code-indicator) can be selected (code-indicator--selected) and contains: +- A color dot (.code-indicator__color-dot) +- Text (.code-indicator__text) +*/ + +.code-indicator { + --indicator-color: #888; + border-radius: 8px; + background: color-mix(in srgb, var(--indicator-color) 15%, white); + border: 1px solid var(--indicator-color); + cursor: default; + display: inline-block; + line-height: 0.9; + margin-left: 4px; + padding-left: 14px; + padding-right: 4px; + position: relative; + transform: translateY(0px); } -.spangroup > span { - margin-right: 4px; +.code-indicator__color-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--indicator-color); + position: absolute; + left: 4px; + top: calc(50% - 3px); } -.spangroup.inline > span { - border: 1px solid black; - border-radius: 4px; +.code-indicator__text { + font-size: 0.75rem; + font-weight: bold; + font-style: normal; + color: color-mix(in srgb, var(--indicator-color) 50%, #222); + text-transform: uppercase; + vertical-align: middle; } -.hoversentence:hover { - background-color: rgba(0, 255, 255, 0.5); - cursor: pointer; +.code-indicator--selected { + background: color-mix(in srgb, var(--indicator-color) 20%, white); + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--indicator-color) 25%, transparent), + 0 2px 8px color-mix(in srgb, var(--indicator-color) 30%, transparent); } -.filterhighlight { - background-color: yellow; +/* +MARKER: Mark.tsx +This is the styling of the "background" marks that indicate the span boundaries. +*/ + +.mark { + position: absolute; + width: 100%; + top: 0; + left: 0; } -.jumphighlight { - background-color: lightgreen; +.mark.start { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; +} + +.mark.end { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; } diff --git a/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.tsx b/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.tsx index 594db241d..93902f5e7 100644 --- a/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.tsx +++ b/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.tsx @@ -1,6 +1,5 @@ import { Box, BoxProps } from "@mui/material"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import "./DocumentRenderer.css"; import { DOMNode, Element, HTMLReactParserOptions, domToReact } from "html-react-parser"; @@ -32,23 +31,6 @@ function DocumentRenderer({ projectId, ...props }: DocumentRendererProps & BoxProps) { - // computed - const htmlPages = useMemo(() => { - let content = html; - if (content.startsWith("
")) { - content = content.substring(5); - } - if (content.endsWith("
")) { - content = content.substring(0, content.length - 6); - } - content = content.trim(); - const regex = /
|<\/section>
|<\/section>/gm; - let splitted = content.split(regex); - splitted = splitted.filter((s) => s.length > 0); - return splitted; - }, [html]); - const numPages = htmlPages.length; - // jump to annotations const selectedAnnotationId = useAppSelector((state) => state.annotations.selectedAnnotationId); useEffect(() => { @@ -69,14 +51,6 @@ function DocumentRenderer({ } }, [selectedAnnotationId]); - // virtualization - const listRef: React.MutableRefObject = useRef(null); - const virtualizer = useVirtualizer({ - count: numPages, - getScrollElement: () => listRef.current, - estimateSize: () => 155, - }); - const basicProcessingInstructions = useCallback( (options: HTMLReactParserOptions) => (domNode: Element) => { // links @@ -185,32 +159,8 @@ function DocumentRenderer({ }, [annotationMap, annotationsPerToken, tokenData, basicProcessingInstructions]); return ( - -
- {virtualizer.getVirtualItems().map((virtualItem) => ( -
- -
- ))} -
+ + ); } diff --git a/frontend/src/views/annotation/DocumentRenderer/Mark.tsx b/frontend/src/views/annotation/DocumentRenderer/Mark.tsx index c8cf4cd74..6d5007142 100644 --- a/frontend/src/views/annotation/DocumentRenderer/Mark.tsx +++ b/frontend/src/views/annotation/DocumentRenderer/Mark.tsx @@ -13,7 +13,7 @@ interface MarkProps { function Mark({ codeId, isStart, isEnd, height, top, groups }: MarkProps) { const code = CodeHooks.useGetCode(codeId); - if (code.isSuccess) { + if (code.data) { let color: string; if (code.data.is_system && code.data.name === "MENTION" && groups && groups.length === 1) { // coreference annotation diff --git a/frontend/src/views/annotation/ICode.ts b/frontend/src/views/annotation/ICode.ts deleted file mode 100644 index ea80c8204..000000000 --- a/frontend/src/views/annotation/ICode.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ICode { - id: number; - name: string; - color: string; -} diff --git a/frontend/src/views/annotation/ImageAnnotator/ImageAnnotator.tsx b/frontend/src/views/annotation/ImageAnnotator/ImageAnnotator.tsx index 49377c45b..adc56723a 100644 --- a/frontend/src/views/annotation/ImageAnnotator/ImageAnnotator.tsx +++ b/frontend/src/views/annotation/ImageAnnotator/ImageAnnotator.tsx @@ -10,7 +10,6 @@ import ConfirmationAPI from "../../../components/ConfirmationDialog/Confirmation import { useAppSelector } from "../../../plugins/ReduxHooks.ts"; import { Annotation } from "../Annotation.ts"; import AnnotationMenu, { CodeSelectorHandle } from "../AnnotationMenu/AnnotationMenu.tsx"; -import { ICode } from "../ICode.ts"; import SVGBBox from "./SVGBBox.tsx"; import SVGBBoxText from "./SVGBBoxText.tsx"; @@ -202,14 +201,14 @@ function ImageAnnotatorWithHeight({ sdocData, height }: ImageAnnotatorProps & { }; // code selector events - const onCodeSelectorAddCode = (code: ICode) => { + const onCodeSelectorAddCode = (codeId: number) => { const myRect = d3.select(rectRef.current); const x = parseInt(myRect.attr("x")); const y = parseInt(myRect.attr("y")); const width = parseInt(myRect.attr("width")); const height = parseInt(myRect.attr("height")); createMutation.mutate({ - code_id: code.id, + code_id: codeId, sdoc_id: sdocData.id, x_min: x, x_max: x + width, @@ -218,12 +217,12 @@ function ImageAnnotatorWithHeight({ sdocData, height }: ImageAnnotatorProps & { }); }; - const onCodeSelectorEditCode = (_annotationToEdit: Annotation, code: ICode) => { + const onCodeSelectorEditCode = (_annotationToEdit: Annotation, codeId: number) => { if (selectedBbox) { updateMutation.mutate({ bboxToUpdate: selectedBbox, requestBody: { - code_id: code.id, + code_id: codeId, }, }); } else { diff --git a/frontend/src/views/annotation/SentenceAnnotator/Annotator/DocumentSentence.tsx b/frontend/src/views/annotation/SentenceAnnotator/Annotator/DocumentSentence.tsx index 27b91fdcd..473272623 100644 --- a/frontend/src/views/annotation/SentenceAnnotator/Annotator/DocumentSentence.tsx +++ b/frontend/src/views/annotation/SentenceAnnotator/Annotator/DocumentSentence.tsx @@ -1,14 +1,13 @@ import { ListItemButton, Stack, StackProps, Tooltip } from "@mui/material"; import { useMemo } from "react"; import { CodeMap } from "../../../../api/CodeHooks.ts"; -import { CodeRead } from "../../../../api/openapi/models/CodeRead.ts"; import { SentenceAnnotationRead } from "../../../../api/openapi/models/SentenceAnnotationRead.ts"; import ColorUtils from "../../../../utils/ColorUtils.ts"; interface DocumentSentenceProps { sentenceId: number; isSelected: boolean; - selectedCode: CodeRead | undefined; + selectedCodeId: number | undefined; selectedSentAnnoId: number | undefined; hoveredSentAnnoId: number | null; hoveredCodeId: number | undefined; @@ -28,7 +27,7 @@ interface DocumentSentenceProps { function DocumentSentence({ sentenceId, isSelected, - selectedCode, + selectedCodeId, selectedSentAnnoId, hoveredSentAnnoId, hoveredCodeId, @@ -59,8 +58,8 @@ function DocumentSentence({ const sentAnnoCodeIds = useMemo(() => sentenceAnnotations.map((anno) => anno.code_id), [sentenceAnnotations]); const highlightedColor = useMemo(() => { - if (isSelected) { - return selectedCode?.color || "rgb(255, 0, 0)"; + if (isSelected && selectedCodeId) { + return codeMap[selectedCodeId]?.color || "rgb(255, 0, 0)"; } if (hoveredSentAnnoId) { const sa = sentAnnoMap[hoveredSentAnnoId]; @@ -80,7 +79,7 @@ function DocumentSentence({ sentAnnoCodeIds, selectedSentAnnoId, sentAnnoMap, - selectedCode?.color, + selectedCodeId, codeMap, ]); diff --git a/frontend/src/views/annotation/SentenceAnnotator/Annotator/SentenceAnnotator.tsx b/frontend/src/views/annotation/SentenceAnnotator/Annotator/SentenceAnnotator.tsx index ec879684d..8bafb0647 100644 --- a/frontend/src/views/annotation/SentenceAnnotator/Annotator/SentenceAnnotator.tsx +++ b/frontend/src/views/annotation/SentenceAnnotator/Annotator/SentenceAnnotator.tsx @@ -2,14 +2,12 @@ import { Box, BoxProps } from "@mui/material"; import { useVirtualizer } from "@tanstack/react-virtual"; import { memo, useMemo, useRef, useState } from "react"; import CodeHooks from "../../../../api/CodeHooks.ts"; -import { CodeRead } from "../../../../api/openapi/models/CodeRead.ts"; import { SentenceAnnotationRead } from "../../../../api/openapi/models/SentenceAnnotationRead.ts"; import { SourceDocumentDataRead } from "../../../../api/openapi/models/SourceDocumentDataRead.ts"; import { useAppDispatch, useAppSelector } from "../../../../plugins/ReduxHooks.ts"; import { AnnoActions } from "../../annoSlice.ts"; import { Annotation } from "../../Annotation.ts"; import AnnotationMenu, { CodeSelectorHandle } from "../../AnnotationMenu/AnnotationMenu.tsx"; -import { ICode } from "../../ICode.ts"; import SentenceAnnotationHooks from "../../../../api/SentenceAnnotationHooks.ts"; import { useGetSentenceAnnotator } from "../useGetSentenceAnnotator.ts"; @@ -29,7 +27,7 @@ function SentenceAnnotator({ sdocData, virtualizerScrollElementRef, ...props }: const annotator = useGetSentenceAnnotator({ sdocId: sdocData.id, userId: visibleUserId }); // selection - const mostRecentCode = useAppSelector((state) => state.annotations.mostRecentCode); + const mostRecentCodeId = useAppSelector((state) => state.annotations.mostRecentCodeId); const [selectedSentences, setSelectedSentences] = useState([]); const [lastClickedIndex, setLastClickedIndex] = useState(null); const [isDragging, setIsDragging] = useState(false); @@ -48,21 +46,21 @@ function SentenceAnnotator({ sdocData, virtualizerScrollElementRef, ...props }: const handleCodeSelectorDeleteAnnotation = (annotation: Annotation) => { deleteMutation.mutate(annotation as SentenceAnnotationRead); }; - const handleCodeSelectorEditCode = (annotation: Annotation, code: ICode) => { + const handleCodeSelectorEditCode = (annotation: Annotation, codeId: number) => { updateMutation.mutate({ sentenceAnnoToUpdate: annotation as SentenceAnnotationRead, update: { - code_id: code.id, + code_id: codeId, }, }); }; - const handleCodeSelectorAddCode = (code: CodeRead, isNewCode: boolean) => { + const handleCodeSelectorAddCode = (codeId: number, isNewCode: boolean) => { setSelectedSentences([]); setLastClickedIndex(null); createMutation.mutate( { requestBody: { - code_id: code.id, + code_id: codeId, sdoc_id: sdocData.id, sentence_id_start: selectedSentences[0], sentence_id_end: selectedSentences[selectedSentences.length - 1], @@ -72,7 +70,7 @@ function SentenceAnnotator({ sdocData, virtualizerScrollElementRef, ...props }: onSuccess: () => { if (!isNewCode) { // if we use an existing code to annotate, we move it to the top - dispatch(AnnoActions.moveCodeToTop(code)); + dispatch(AnnoActions.moveCodeToTop(codeId)); } }, }, @@ -80,10 +78,10 @@ function SentenceAnnotator({ sdocData, virtualizerScrollElementRef, ...props }: }; const handleCodeSelectorClose = (reason?: "backdropClick" | "escapeKeyDown") => { // i clicked away because i like the annotation as is - if (selectedSentences.length > 0 && reason === "backdropClick" && mostRecentCode) { + if (selectedSentences.length > 0 && reason === "backdropClick" && mostRecentCodeId) { createMutation.mutate({ requestBody: { - code_id: mostRecentCode.id, + code_id: mostRecentCodeId, sdoc_id: sdocData.id, sentence_id_start: selectedSentences[0], sentence_id_end: selectedSentences[selectedSentences.length - 1], @@ -245,7 +243,7 @@ function SentenceAnnotator({ sdocData, virtualizerScrollElementRef, ...props }: sentenceAnnotations={annotator.annotatorResult!.sentence_annotations[item.index]} sentence={sentence} isSelected={selectedSentences.includes(item.index)} - selectedCode={mostRecentCode} + selectedCodeId={mostRecentCodeId} onAnnotationClick={(event, sentAnnoId) => handleAnnotationClick(event, sentAnnoId, item.index)} onAnnotationMouseEnter={handleAnnotationMouseEnter} onAnnotationMouseLeave={handleAnnotationMouseLeave} diff --git a/frontend/src/views/annotation/SentenceAnnotator/Comparator/DocumentSentence.tsx b/frontend/src/views/annotation/SentenceAnnotator/Comparator/DocumentSentence.tsx index c8d4755e4..ad7a87576 100644 --- a/frontend/src/views/annotation/SentenceAnnotator/Comparator/DocumentSentence.tsx +++ b/frontend/src/views/annotation/SentenceAnnotator/Comparator/DocumentSentence.tsx @@ -5,7 +5,6 @@ import SquareIcon from "@mui/icons-material/Square"; import { Box, IconButton, ListItemButton, Stack, Tooltip } from "@mui/material"; import { useMemo } from "react"; import { CodeMap } from "../../../../api/CodeHooks.ts"; -import { CodeRead } from "../../../../api/openapi/models/CodeRead.ts"; import { SentenceAnnotationRead } from "../../../../api/openapi/models/SentenceAnnotationRead.ts"; import ColorUtils from "../../../../utils/ColorUtils.ts"; import { UseGetSentenceAnnotator } from "../useGetSentenceAnnotator.ts"; @@ -15,7 +14,7 @@ import { SentAnnoMap, useComputeSentAnnoMap } from "./useComputeSentAnnoMap.ts"; interface DocumentSentenceProps { sentenceId: number; isSelected: boolean; - selectedCode: CodeRead | undefined; + selectedCodeId: number | undefined; hoveredSentAnnoId: number | null; hoveredCodeId: number | undefined; sentence: string; @@ -40,7 +39,7 @@ interface DocumentSentenceProps { function DocumentSentence({ sentenceId, isSelected, - selectedCode, + selectedCodeId, hoveredSentAnnoId, hoveredCodeId, sentence, @@ -66,7 +65,7 @@ function DocumentSentence({ Object.values(sentAnnoMap).map((anno) => anno.code_id), [sentAnnoMap]); const highlightedColor = useMemo(() => { - if (isSelected) { - return selectedCode?.color || "rgb(255, 0, 0)"; + if (isSelected && selectedCodeId) { + return codeMap[selectedCodeId]?.color || "rgb(255, 0, 0)"; } if (hoveredSentAnnoId) { const sa = sentAnnoMap[hoveredSentAnnoId]; @@ -169,7 +168,7 @@ function DocumentSentencePart({ if (hoveredCodeId && sentAnnoCodeIds.includes(hoveredCodeId)) { return codeMap[hoveredCodeId]?.color; } - }, [isSelected, hoveredSentAnnoId, hoveredCodeId, sentAnnoCodeIds, selectedCode?.color, sentAnnoMap, codeMap]); + }, [isSelected, hoveredSentAnnoId, hoveredCodeId, sentAnnoCodeIds, selectedCodeId, sentAnnoMap, codeMap]); return ( <> diff --git a/frontend/src/views/annotation/SentenceAnnotator/Comparator/SentenceAnnotationComparison.tsx b/frontend/src/views/annotation/SentenceAnnotator/Comparator/SentenceAnnotationComparison.tsx index 20cf1586d..04924308d 100644 --- a/frontend/src/views/annotation/SentenceAnnotator/Comparator/SentenceAnnotationComparison.tsx +++ b/frontend/src/views/annotation/SentenceAnnotator/Comparator/SentenceAnnotationComparison.tsx @@ -2,7 +2,6 @@ import { Box, BoxProps } from "@mui/material"; import { useVirtualizer } from "@tanstack/react-virtual"; import { memo, useMemo, useRef, useState } from "react"; import CodeHooks from "../../../../api/CodeHooks.ts"; -import { CodeRead } from "../../../../api/openapi/models/CodeRead.ts"; import { SentenceAnnotationRead } from "../../../../api/openapi/models/SentenceAnnotationRead.ts"; import { SourceDocumentDataRead } from "../../../../api/openapi/models/SourceDocumentDataRead.ts"; import { useAuth } from "../../../../auth/useAuth.ts"; @@ -10,7 +9,6 @@ import { useAppDispatch, useAppSelector } from "../../../../plugins/ReduxHooks.t import { AnnoActions } from "../../annoSlice.ts"; import { Annotation } from "../../Annotation.ts"; import AnnotationMenu, { CodeSelectorHandle } from "../../AnnotationMenu/AnnotationMenu.tsx"; -import { ICode } from "../../ICode.ts"; import SentenceAnnotationHooks from "../../../../api/SentenceAnnotationHooks.ts"; import { useGetSentenceAnnotator } from "../useGetSentenceAnnotator.ts"; @@ -41,7 +39,7 @@ function SentenceAnnotationComparison({ const annotatorRight = useGetSentenceAnnotator({ sdocId: sdocData.id, userId: rightUserId }); // selection - const mostRecentCode = useAppSelector((state) => state.annotations.mostRecentCode); + const mostRecentCodeId = useAppSelector((state) => state.annotations.mostRecentCodeId); const [selectedSentences, setSelectedSentences] = useState([]); const [lastClickedIndex, setLastClickedIndex] = useState(null); const [isDragging, setIsDragging] = useState(false); @@ -61,21 +59,21 @@ function SentenceAnnotationComparison({ const handleCodeSelectorDeleteAnnotation = (annotation: Annotation) => { deleteMutation.mutate(annotation as SentenceAnnotationRead); }; - const handleCodeSelectorEditCode = (annotation: Annotation, code: ICode) => { + const handleCodeSelectorEditCode = (annotation: Annotation, codeId: number) => { updateMutation.mutate({ sentenceAnnoToUpdate: annotation as SentenceAnnotationRead, update: { - code_id: code.id, + code_id: codeId, }, }); }; - const handleCodeSelectorAddCode = (code: CodeRead, isNewCode: boolean) => { + const handleCodeSelectorAddCode = (codeId: number, isNewCode: boolean) => { setSelectedSentences([]); setLastClickedIndex(null); createMutation.mutate( { requestBody: { - code_id: code.id, + code_id: codeId, sdoc_id: sdocData.id, sentence_id_start: selectedSentences[0], sentence_id_end: selectedSentences[selectedSentences.length - 1], @@ -85,7 +83,7 @@ function SentenceAnnotationComparison({ onSuccess: () => { if (!isNewCode) { // if we use an existing code to annotate, we move it to the top - dispatch(AnnoActions.moveCodeToTop(code)); + dispatch(AnnoActions.moveCodeToTop(codeId)); } }, }, @@ -93,10 +91,10 @@ function SentenceAnnotationComparison({ }; const handleCodeSelectorClose = (reason?: "backdropClick" | "escapeKeyDown") => { // i clicked away because i like the annotation as is - if (selectedSentences.length > 0 && reason === "backdropClick" && mostRecentCode) { + if (selectedSentences.length > 0 && reason === "backdropClick" && mostRecentCodeId) { createMutation.mutate({ requestBody: { - code_id: mostRecentCode.id, + code_id: mostRecentCodeId, sdoc_id: sdocData.id, sentence_id_start: selectedSentences[0], sentence_id_end: selectedSentences[selectedSentences.length - 1], @@ -359,7 +357,7 @@ function SentenceAnnotationComparison({ sentenceId={sentId} sentence={sentence} isSelected={selectedSentences.includes(sentId)} - selectedCode={mostRecentCode} + selectedCodeId={mostRecentCodeId} onSentenceMouseDown={handleSentenceMouseDown} onSentenceMouseEnter={handleSentenceMouseEnter} onAnnotationClick={handleAnnotationClick} diff --git a/frontend/src/views/annotation/TextAnnotator/TextAnnotator.tsx b/frontend/src/views/annotation/TextAnnotator/TextAnnotator.tsx index 75fc6c716..5feb9c197 100644 --- a/frontend/src/views/annotation/TextAnnotator/TextAnnotator.tsx +++ b/frontend/src/views/annotation/TextAnnotator/TextAnnotator.tsx @@ -3,20 +3,19 @@ import React, { MouseEvent, useRef, useState } from "react"; import { QueryKey } from "../../../api/QueryKey.ts"; import SpanAnnotationHooks, { FAKE_ANNOTATION_ID } from "../../../api/SpanAnnotationHooks.ts"; -import { CodeRead } from "../../../api/openapi/models/CodeRead.ts"; import { SourceDocumentDataRead } from "../../../api/openapi/models/SourceDocumentDataRead.ts"; import { SpanAnnotationCreate } from "../../../api/openapi/models/SpanAnnotationCreate.ts"; import { SpanAnnotationRead } from "../../../api/openapi/models/SpanAnnotationRead.ts"; +import { useAuth } from "../../../auth/useAuth.ts"; import ConfirmationAPI from "../../../components/ConfirmationDialog/ConfirmationAPI.ts"; import { useOpenSnackbar } from "../../../components/SnackbarDialog/useOpenSnackbar.ts"; import { useAppDispatch, useAppSelector } from "../../../plugins/ReduxHooks.ts"; +import { SYSTEM_USER_ID } from "../../../utils/GlobalConstants.ts"; import { Annotation } from "../Annotation.ts"; import AnnotationMenu, { CodeSelectorHandle } from "../AnnotationMenu/AnnotationMenu.tsx"; import DocumentRenderer from "../DocumentRenderer/DocumentRenderer.tsx"; import useComputeTokenData from "../DocumentRenderer/useComputeTokenData.ts"; -import { ICode } from "../ICode.ts"; import { AnnoActions, TagStyle } from "../annoSlice.ts"; - const selectionIsEmpty = (selection: Selection): boolean => { return selection.toString().trim().length === 0; }; @@ -26,13 +25,15 @@ interface TextAnnotatorProps { } function TextAnnotator({ sdocData }: TextAnnotatorProps) { + const { user } = useAuth(); + // local state const spanMenuRef = useRef(null); const [fakeAnnotation, setFakeAnnotation] = useState(undefined); // global client state (redux) const visibleUserId = useAppSelector((state) => state.annotations.visibleUserId); - const mostRecentCode = useAppSelector((state) => state.annotations.mostRecentCode); + const mostRecentCodeId = useAppSelector((state) => state.annotations.mostRecentCodeId); const selectedCodeId = useAppSelector((state) => state.annotations.selectedCodeId); const tagStyle = useAppSelector((state) => state.annotations.tagStyle); const dispatch = useAppDispatch(); @@ -108,7 +109,7 @@ function TextAnnotator({ sdocData }: TextAnnotatorProps) { } // the selection is valid - if (!mostRecentCode && !selectedCodeId) { + if (!mostRecentCodeId && !selectedCodeId) { openSnackbar({ severity: "warning", text: "Select a code in the Code Explorer (left) first!", @@ -147,7 +148,7 @@ function TextAnnotator({ sdocData }: TextAnnotatorProps) { .join(" "); const requestBody: SpanAnnotationCreate = { - code_id: mostRecentCode?.id || selectedCodeId || -1, + code_id: mostRecentCodeId || selectedCodeId || -1, sdoc_id: sdocData.id, begin: tokenData[begin_token].beginChar, end: tokenData[end_token].endChar, @@ -175,7 +176,7 @@ function TextAnnotator({ sdocData }: TextAnnotatorProps) { code_id: requestBody.code_id, created: "", updated: "", - user_id: 0, + user_id: user?.id || SYSTEM_USER_ID, group_ids: [], memo_ids: [], }; @@ -209,32 +210,32 @@ function TextAnnotator({ sdocData }: TextAnnotatorProps) { }, }); }; - const handleCodeSelectorEditCode = (annotation: Annotation, code: ICode) => { + const handleCodeSelectorEditCode = (annotation: Annotation, codeId: number) => { updateMutation.mutate({ spanAnnotationToUpdate: annotation as SpanAnnotationRead, requestBody: { - code_id: code.id, + code_id: codeId, }, }); }; - const handleCodeSelectorAddCode = (code: CodeRead, isNewCode: boolean) => { + const handleCodeSelectorAddCode = (codeId: number, isNewCode: boolean) => { if (!fakeAnnotation) return; createMutation.mutate( { ...fakeAnnotation, - code_id: code.id, + code_id: codeId, }, { onSuccess: () => { if (!isNewCode) { // if we use an existing code to annotate, we move it to the top - dispatch(AnnoActions.moveCodeToTop(code)); + dispatch(AnnoActions.moveCodeToTop(codeId)); } }, }, ); }; - const handleCodeSelectorDuplicateAnnotation = (annotation: Annotation, code: CodeRead) => { + const handleCodeSelectorDuplicateAnnotation = (annotation: Annotation, codeId: number) => { if ("id" in annotation && "begin_token" in annotation && "end_token" in annotation) { const fakeAnnotation: SpanAnnotationCreate = { begin: annotation.begin, @@ -243,11 +244,11 @@ function TextAnnotator({ sdocData }: TextAnnotatorProps) { end_token: annotation.end_token, span_text: annotation.text, sdoc_id: annotation.sdoc_id, - code_id: code.id, + code_id: codeId, }; createMutation.mutate(fakeAnnotation, { onSuccess: () => { - dispatch(AnnoActions.moveCodeToTop(code)); + dispatch(AnnoActions.moveCodeToTop(codeId)); }, }); } @@ -258,7 +259,14 @@ function TextAnnotator({ sdocData }: TextAnnotatorProps) { // i clicked away because i like the annotation as is if (reason === "backdropClick") { // add the annotation as is - createMutation.mutate({ ...fakeAnnotation }); + createMutation.mutate( + { ...fakeAnnotation }, + { + onSuccess: () => { + dispatch(AnnoActions.moveCodeToTop(fakeAnnotation.code_id)); + }, + }, + ); } // i clicked escape because i want to cancel the annotation if (reason === "escapeKeyDown") { diff --git a/frontend/src/views/annotation/annoSlice.ts b/frontend/src/views/annotation/annoSlice.ts index c221452eb..f0f484cf9 100644 --- a/frontend/src/views/annotation/annoSlice.ts +++ b/frontend/src/views/annotation/annoSlice.ts @@ -1,7 +1,6 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { persistReducer } from "redux-persist"; import storage from "redux-persist/lib/storage"; -import { CodeRead } from "../../api/openapi/models/CodeRead.ts"; import { ProjectActions } from "../../components/Project/projectSlice.ts"; import { RootState } from "../../store/store.ts"; import AnnotationMode from "./AnnotationMode.ts"; @@ -16,7 +15,7 @@ export interface AnnoState { selectedAnnotationId: number | undefined; // the annotation selected in the annotation explorer. selectedCodeId: number | undefined; // the code selected in the code explorer, used to compute which codes are shown in the annotation menu. hoveredCodeId: number | undefined; // the code hovered in the code explorer, used to compute highlightings. - mostRecentCode: CodeRead | undefined; // the most recently applied code, it is always at the top of the annotation menu and the default code for new annotations. + mostRecentCodeId: number | undefined; // the most recently applied code, it is always at the top of the annotation menu and the default code for new annotations. expandedCodeIds: string[]; // the code ids of the expanded codes in the code explorer. hiddenCodeIds: number[]; // the code ids of the hidden codes. Hidden codes are shown in the CodeExplorer, but are not rendered in the Annotator. visibleUserId: number | undefined; // the user id of the user whose annotations are shown in the Annotator. @@ -32,7 +31,7 @@ const initialState: AnnoState = { selectedAnnotationId: undefined, selectedCodeId: undefined, hoveredCodeId: undefined, - mostRecentCode: undefined, + mostRecentCodeId: undefined, expandedCodeIds: [], hiddenCodeIds: [], visibleUserId: undefined, @@ -102,8 +101,8 @@ export const annoSlice = createSlice({ } state.visibleUserId = action.payload; }, - moveCodeToTop: (state, action: PayloadAction) => { - state.mostRecentCode = action.payload; + moveCodeToTop: (state, action: PayloadAction) => { + state.mostRecentCodeId = action.payload; }, onSetAnnotatorTagStyle: (state, action: PayloadAction) => { if (action.payload !== undefined && action.payload !== null) { @@ -127,6 +126,25 @@ export const annoSlice = createSlice({ state.compareWithUserId = undefined; state.isCompareMode = false; }, + onDeleteCode: (state, action: PayloadAction) => { + // remove references to deleted code + if (state.selectedCodeId === action.payload) { + state.selectedCodeId = undefined; + } + if (state.mostRecentCodeId === action.payload) { + state.mostRecentCodeId = undefined; + } + if (state.hoveredCodeId === action.payload) { + state.hoveredCodeId = undefined; + } + if (state.expandedCodeIds.length > 0) { + state.expandedCodeIds = state.expandedCodeIds.filter((id) => parseInt(id, 10) !== action.payload); + } + if (state.hiddenCodeIds.length > 0) { + state.hiddenCodeIds = state.hiddenCodeIds.filter((id) => id !== action.payload); + } + state.selectedAnnotationId = undefined; + }, }, extraReducers: (builder) => { builder.addCase(ProjectActions.changeProject, (state) => { @@ -134,7 +152,7 @@ export const annoSlice = createSlice({ state.selectedAnnotationId = initialState.selectedAnnotationId; state.selectedCodeId = initialState.selectedCodeId; state.hoveredCodeId = initialState.hoveredCodeId; - state.mostRecentCode = initialState.mostRecentCode; + state.mostRecentCodeId = initialState.mostRecentCodeId; state.expandedCodeIds = initialState.expandedCodeIds; state.hiddenCodeIds = initialState.hiddenCodeIds; state.visibleUserId = initialState.visibleUserId; diff --git a/frontend/src/views/search/DocumentSearch/SearchDocumentTable.tsx b/frontend/src/views/search/DocumentSearch/SearchDocumentTable.tsx index 48c88e28f..b845fa6b6 100644 --- a/frontend/src/views/search/DocumentSearch/SearchDocumentTable.tsx +++ b/frontend/src/views/search/DocumentSearch/SearchDocumentTable.tsx @@ -15,7 +15,7 @@ import { MRT_Updater, useMaterialReactTable, } from "material-react-table"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { FolderMap } from "../../../api/FolderHooks.ts"; import { QueryKey } from "../../../api/QueryKey.ts"; @@ -54,18 +54,12 @@ import { useTableFetchMoreOnScroll } from "../../../utils/useTableInfiniteScroll import { useInitSearchFilterSlice } from "../useInitSearchFilterSlice.ts"; import OpenInTabsButton from "./OpenInTabsButton.tsx"; import SearchOptionsMenu from "./SearchOptionsMenu.tsx"; -import { SearchActions } from "./searchSlice.ts"; +import { FolderSelection, SearchActions } from "./searchSlice.ts"; // this has to match Search.tsx! const filterStateSelector = (state: RootState) => state.search; const filterName = "root"; -enum FolderSelection { - FOLDER = "FOLDER", - SDOC = "SDOC", - UNKNOWN = "UNKNOWN", -} - const rowSelection = (fs: FolderSelection) => (row: MRT_Row) => { switch (fs) { case FolderSelection.FOLDER: @@ -91,7 +85,7 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable const dispatch = useAppDispatch(); // custom row selection state (it distinguishes between folders and source documents) - const [folderSelectionType, setFolderSelectionType] = useState(FolderSelection.UNKNOWN); + const folderSelectionType = useAppSelector((state) => state.search.folderSelectionType); const rowSelectionModel = useAppSelector((state) => state.search.rowSelectionModel); const setRowSelectionModel = useCallback( (updater: MRT_Updater) => { @@ -100,7 +94,7 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable // if it contains any numbers, it's SDOC, otherwise it's FOLDER if (Object.keys(rowSelectionModel).length === 0) { if (Object.keys(newState).some((key) => !isNaN(Number(key)))) { - setFolderSelectionType(FolderSelection.SDOC); + dispatch(SearchActions.onFolderSelectionChange(FolderSelection.SDOC)); // remove all keys that are not numbers Object.keys(newState).forEach((key) => { if (isNaN(Number(key))) { @@ -108,12 +102,12 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable } }); } else { - setFolderSelectionType(FolderSelection.FOLDER); + dispatch(SearchActions.onFolderSelectionChange(FolderSelection.FOLDER)); } } // if new state is empty, reset the folder selection type if (Object.keys(newState).length === 0) { - setFolderSelectionType(FolderSelection.UNKNOWN); + dispatch(SearchActions.onFolderSelectionChange(FolderSelection.UNKNOWN)); } dispatch(SearchActions.onRowSelectionChange(newState)); }, @@ -133,6 +127,10 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable (state) => state.search.columnVisibilityModel, SearchActions.onColumnVisibilityChange, ); + const [expandedModel, setExpandedModel] = useReduxConnector( + (state) => state.search.expandedModel, + SearchActions.onExpandedChange, + ); const [columnSizingModel, setColumnSizingModel] = useReduxConnector( (state) => state.search.columnSizingModel, SearchActions.onColumnSizingChange, @@ -313,6 +311,7 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable // if showFolders is true, we need to merge the sub_rows const hits: Record = {}; + const sortedHitIds: number[] = []; data.pages.forEach((page) => { page.hits.forEach((hit) => { // do the merging here! @@ -321,9 +320,14 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable } else { hits[hit.id] = JSON.parse(JSON.stringify(hit)); // deep clone to avoid mutating the original hit } + + // keep track of the order + if (!sortedHitIds.includes(hit.id)) { + sortedHitIds.push(hit.id); + } }); }); - const flatData = Object.values(hits); + const flatData = sortedHitIds.map((id) => hits[id]); return { flatData, totalFetchedSdocs: flatData.reduce((acc, hit) => acc + hit.sub_rows.length, 0), @@ -349,9 +353,11 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable columns: columns, getRowId: (row) => (row.is_folder ? `folder-${row.id}` : `${row.id}`), // sub rows / folders - enableExpanding: showFolders, getSubRows: showFolders ? (originalRow) => originalRow.sub_rows : undefined, //default, can customize rowCount: folderSelectionType === FolderSelection.FOLDER ? totalFetchedFolders : totalFetchedSdocs, + //expansion + enableExpanding: showFolders, + onExpandedChange: setExpandedModel, // state state: { globalFilter: searchQuery, @@ -364,6 +370,7 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable showAlertBanner: isError, showProgressBars: isFetching, showGlobalFilter: true, + expanded: expandedModel, }, // search query autoResetAll: false, diff --git a/frontend/src/views/search/DocumentSearch/searchSlice.ts b/frontend/src/views/search/DocumentSearch/searchSlice.ts index 00e426d93..52f7d2c22 100644 --- a/frontend/src/views/search/DocumentSearch/searchSlice.ts +++ b/frontend/src/views/search/DocumentSearch/searchSlice.ts @@ -26,6 +26,12 @@ import { ProjectActions } from "../../../components/Project/projectSlice.ts"; import { TableState, initialTableState, resetProjectTableState, tableReducer } from "../../../components/tableSlice.ts"; import { getValue } from "../metadataUtils.ts"; +export enum FolderSelection { + FOLDER = "FOLDER", + SDOC = "SDOC", + UNKNOWN = "UNKNOWN", +} + interface SearchState { // project state: selectedDocumentId: number | undefined; // the id of the selected document. Used to highlight the selected document in the table, and to show the document information (tags, metadata etc.). @@ -35,6 +41,7 @@ interface SearchState { selectedFolderId: number; // the id of the selected folder. (the root folder is -1) showFolders: boolean; // whether the folders are shown in search table. scrollPosition: number; // the scroll position of the document table, used to restore position when returning to the table + folderSelectionType: FolderSelection; // whether a folder or a document is selected // app state: expertSearchMode: boolean; // whether the expert search mode is enabled. sortStatsByGlobal: boolean; // whether the search statistics are sorted by the global frequency or the "local" (). @@ -58,6 +65,7 @@ const initialState: FilterState & TableState & SearchState = { selectedFolderId: -1, // the root folder is -1 showFolders: true, scrollPosition: 0, + folderSelectionType: FolderSelection.UNKNOWN, // app state: expertSearchMode: false, sortStatsByGlobal: false, @@ -118,6 +126,10 @@ export const searchSlice = createSlice({ delete state.rowSelectionModel[`${sdocId}`]; } }, + // folder selection type + onFolderSelectionChange: (state, action: PayloadAction) => { + state.folderSelectionType = action.payload; + }, // tag explorer setExpandedTagIds: (state, action: PayloadAction) => { state.expandedTagIds = action.payload; @@ -241,6 +253,7 @@ export const searchSlice = createSlice({ state.scrollPosition = initialState.scrollPosition; state.expandedFolderIds = initialState.expandedFolderIds; state.selectedFolderId = initialState.selectedFolderId; + state.folderSelectionType = initialState.folderSelectionType; resetProjectTableState(state); resetProjectFilterState({ state, defaultFilterExpression, projectId: action.payload, sliceName: "search" }); }) diff --git a/frontend/src/views/whiteboard/WhiteboardFlow.tsx b/frontend/src/views/whiteboard/WhiteboardFlow.tsx index 888323dab..1c5ebf974 100644 --- a/frontend/src/views/whiteboard/WhiteboardFlow.tsx +++ b/frontend/src/views/whiteboard/WhiteboardFlow.tsx @@ -481,7 +481,7 @@ function WhiteboardFlow({ whiteboard }: WhiteboardFlowProps) { }} > - + - + + + + + { switch (attachedObjectType) { @@ -210,7 +211,7 @@ function MemoNode(props: NodeProps) { // global server state (react-query) const memo = MemoHooks.useGetMemo(props.data.memoId); - const attachedObject = useGetMemosAttachedObject(memo.data?.attached_object_type)(memo.data?.attached_object_id); + const attachedObject = useGetMemosAttachedObject(memo.data?.attached_object_type, memo.data?.attached_object_id); useEffect(() => { if (!memo.data || !attachedObject.data) return; @@ -285,7 +286,7 @@ function MemoNode(props: NodeProps) { <> } /> - {memo.data.content} + {memo.data.content} ) : memo.isError ? (