From 894473316662b72ea227b6a45522dc2876bda0aa Mon Sep 17 00:00:00 2001 From: tenkus47 Date: Thu, 9 Apr 2026 13:42:13 +0530 Subject: [PATCH 01/14] refactor: streamline API interactions by integrating OpenPecha client functions - Removed direct API calls using requests in favor of OpenPecha client functions for better abstraction and maintainability. - Updated environment variable handling to include new OpenPecha API keys in the .env.example file. - Enhanced error handling and response management across various routers, improving overall robustness. - Cleaned up unused imports and variables, optimizing the codebase for clarity and performance. --- backend/.env.example | 3 + backend/cataloger/controller/enum.py | 45 +-- .../controller/openpecha_api/__init__.py | 1 + .../controller/openpecha_api/annotations.py | 105 ++++++ .../controller/openpecha_api/base.py | 44 +++ .../controller/openpecha_api/categories.py | 113 +++++++ .../controller/openpecha_api/enums.py | 59 ++++ .../controller/openpecha_api/instances.py | 302 +++++++++++++++++ .../controller/openpecha_api/openapi.json | 1 + .../controller/openpecha_api/persons.py | 76 +++++ .../controller/openpecha_api/segments.py | 39 +++ .../controller/openpecha_api/texts.py | 276 ++++++++++++++++ .../controller/openpecha_api/v2_adapters.py | 309 ++++++++++++++++++ backend/cataloger/routers/aligner_data.py | 300 ++++++++--------- backend/cataloger/routers/annotation.py | 63 +--- backend/cataloger/routers/category.py | 121 ++----- backend/cataloger/routers/person.py | 43 +-- backend/cataloger/routers/segments.py | 42 +-- backend/cataloger/routers/text.py | 252 +++----------- backend/cataloger/routers/tokenize.py | 24 +- backend/cataloger/routers/translation.py | 99 +----- frontend/vite.config.ts | 7 - 22 files changed, 1593 insertions(+), 731 deletions(-) create mode 100644 backend/cataloger/controller/openpecha_api/__init__.py create mode 100644 backend/cataloger/controller/openpecha_api/annotations.py create mode 100644 backend/cataloger/controller/openpecha_api/base.py create mode 100644 backend/cataloger/controller/openpecha_api/categories.py create mode 100644 backend/cataloger/controller/openpecha_api/enums.py create mode 100644 backend/cataloger/controller/openpecha_api/instances.py create mode 100644 backend/cataloger/controller/openpecha_api/openapi.json create mode 100644 backend/cataloger/controller/openpecha_api/persons.py create mode 100644 backend/cataloger/controller/openpecha_api/segments.py create mode 100644 backend/cataloger/controller/openpecha_api/texts.py create mode 100644 backend/cataloger/controller/openpecha_api/v2_adapters.py diff --git a/backend/.env.example b/backend/.env.example index 15f933cd..62660291 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,7 @@ OPENPECHA_ENDPOINT= +# OpenPecha API v2 (X-API-Key) and default X-Application for /v2 routes +OPENPECHA_API_KEY= +OPENPECHA_X_APPLICATION= API_ENDPOINT= BDRC_SEARCH_ENDPOINT= PORT=8000 diff --git a/backend/cataloger/controller/enum.py b/backend/cataloger/controller/enum.py index e8d8e227..eb57f3bb 100644 --- a/backend/cataloger/controller/enum.py +++ b/backend/cataloger/controller/enum.py @@ -1,46 +1,7 @@ -import requests -from fastapi import HTTPException -import os -from dotenv import load_dotenv +"""Re-export enum OpenPecha client for backward compatibility.""" -load_dotenv(override=True) +from cataloger.controller.openpecha_api import enums -API_ENDPOINT = os.getenv("OPENPECHA_ENDPOINT") def get_enum(type: str): - """ - Get enum values by type. - - - **type**: The type of enum to retrieve (e.g., "language", "role") - """ - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - params = {"type": type} - url = f"{API_ENDPOINT}/enum" - response = requests.get(url, params=params, timeout=30) - - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - - return response.json() - - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=502, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Internal server error: {str(e)}" - ) \ No newline at end of file + return enums.get_enum(type) diff --git a/backend/cataloger/controller/openpecha_api/__init__.py b/backend/cataloger/controller/openpecha_api/__init__.py new file mode 100644 index 00000000..dc5968e2 --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/__init__.py @@ -0,0 +1 @@ +"""HTTP client helpers for the external OpenPecha API.""" diff --git a/backend/cataloger/controller/openpecha_api/annotations.py b/backend/cataloger/controller/openpecha_api/annotations.py new file mode 100644 index 00000000..17e13137 --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/annotations.py @@ -0,0 +1,105 @@ +from typing import Any, Callable, Dict, List, Optional, Tuple + +import requests +from fastapi import HTTPException + +from cataloger.controller.openpecha_api.base import json_headers, openpecha_headers, openpecha_url +from cataloger.controller.openpecha_api.v2_adapters import ( + alignment_output_to_legacy_data, + segmentation_output_to_legacy_data, +) + + +def _try_get(url: str, *, timeout: Optional[int] = None) -> Optional[requests.Response]: + kw: Dict[str, Any] = {} + if timeout is not None: + kw["timeout"] = timeout + r = requests.get(url, headers=openpecha_headers(), **kw) + if r.status_code == 200: + return r + return None + + +def get_annotation(annotation_id: str, *, timeout: Optional[int] = None) -> Any: + bases: List[Tuple[str, Callable[[Dict[str, Any]], Dict[str, Any]]]] = [ + ( + "annotations/alignment", + lambda b: {"data": alignment_output_to_legacy_data(b)}, + ), + ("annotations/segmentation", lambda b: {"data": segmentation_output_to_legacy_data(b)}), + ("annotations/pagination", lambda b: {"data": b}), + ("annotations/bibliographic", lambda b: {"data": b}), + ("annotations/durchen", lambda b: {"data": b}), + ] + for path_prefix, wrap in bases: + r = _try_get( + openpecha_url(path_prefix, annotation_id), + timeout=timeout, + ) + if r: + body = r.json() + merged = wrap(body) if isinstance(body, dict) else {"data": body} + eid = body.get("id") if isinstance(body, dict) else None + merged.setdefault("id", eid or annotation_id) + return merged + raise HTTPException(status_code=404, detail="Annotation not found for v2 typed paths") + + +def update_annotation_body(annotation_id: str, payload: Dict[str, Any]) -> Any: + del annotation_id, payload + raise HTTPException( + status_code=501, + detail="OpenPecha v2 has no generic annotation update; delete and recreate via edition annotations.", + ) + + +def create_annotation_for_instance(instance_id: str, payload: Dict[str, Any]) -> Any: + """instance_id is an edition id; maps legacy union payload to AnnotationRequestInput.""" + ptype = payload.get("type") + ann: Dict[str, Any] = {} + if ptype == "segmentation" and payload.get("annotation"): + segs = [] + for item in payload["annotation"]: + if isinstance(item, dict) and item.get("span"): + segs.append({"lines": [item["span"]]}) + if segs: + ann["segmentation"] = {"segments": segs} + elif ptype == "alignment": + ta = payload.get("target_annotation") or [] + aa = payload.get("alignment_annotation") or [] + target_segments = [ + {"lines": [x["span"]]} for x in ta if isinstance(x, dict) and x.get("span") + ] + aligned_segments = [] + for x in aa: + if isinstance(x, dict) and x.get("span"): + aligned_segments.append( + { + "lines": [x["span"]], + "alignment_indices": x.get("alignment_index") or [], + } + ) + tid = payload.get("target_manifestation_id") or payload.get("target_id") + if not tid: + raise HTTPException(status_code=400, detail="alignment requires target_manifestation_id") + ann["alignment"] = { + "target_id": tid, + "target_segments": target_segments, + "aligned_segments": aligned_segments, + } + elif ptype == "table_of_contents": + ann["pagination"] = {"volume": {"pages": payload.get("annotation") or []}} + if not ann: + raise HTTPException( + status_code=400, + detail="Unsupported or empty annotation payload for v2", + ) + r = requests.post( + openpecha_url("editions", instance_id, "annotations"), + json=ann, + headers=json_headers(), + timeout=120, + ) + if r.status_code not in (200, 201): + raise HTTPException(status_code=r.status_code, detail=r.text) + return r.json() diff --git a/backend/cataloger/controller/openpecha_api/base.py b/backend/cataloger/controller/openpecha_api/base.py new file mode 100644 index 00000000..24d947aa --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/base.py @@ -0,0 +1,44 @@ +import os +from typing import Any, Dict, Optional + +from dotenv import load_dotenv +from fastapi import HTTPException + +load_dotenv(override=True) + +OPENPECHA_ENDPOINT_ENV = "OPENPECHA_ENDPOINT" +OPENPECHA_API_KEY_ENV = "OPENPECHA_API_KEY" +OPENPECHA_X_APPLICATION_ENV = "OPENPECHA_X_APPLICATION" +MISSING_ENDPOINT_DETAIL = "OPENPECHA_ENDPOINT environment variable is not set" + +API_V2_PREFIX = "/v2" + + +def require_openpecha_base_url() -> str: + base = os.getenv(OPENPECHA_ENDPOINT_ENV) + if not base: + raise HTTPException(status_code=500, detail=MISSING_ENDPOINT_DETAIL) + return base.rstrip("/") + + +def openpecha_url(*path_parts: str) -> str: + base = require_openpecha_base_url() + tail = "/".join(p.strip("/") for p in path_parts if p) + return f"{base}{API_V2_PREFIX}/{tail}" + + +def openpecha_headers(*, x_application: Optional[str] = None) -> Dict[str, str]: + headers: Dict[str, str] = {"Accept": "application/json"} + api_key = os.getenv(OPENPECHA_API_KEY_ENV) + if api_key: + headers["X-API-Key"] = api_key + app = x_application if x_application is not None else os.getenv(OPENPECHA_X_APPLICATION_ENV) + if app: + headers["X-Application"] = app + return headers + + +def json_headers(*, x_application: Optional[str] = None) -> Dict[str, str]: + h = openpecha_headers(x_application=x_application) + h["Content-Type"] = "application/json" + return h diff --git a/backend/cataloger/controller/openpecha_api/categories.py b/backend/cataloger/controller/openpecha_api/categories.py new file mode 100644 index 00000000..8752350f --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/categories.py @@ -0,0 +1,113 @@ +from typing import Any, Dict, Optional + +import requests +from fastapi import HTTPException + +from cataloger.controller.openpecha_api.base import json_headers, openpecha_headers, openpecha_url + + +def _category_v2_to_legacy_row(c: Dict[str, Any]) -> Dict[str, Any]: + title_val = c.get("title") + if isinstance(title_val, dict) and title_val: + title_str = next(iter(title_val.values()), "") + else: + title_str = str(title_val or "") + children = c.get("children") or [] + return { + "id": c.get("id", ""), + "parent": c.get("parent_id"), + "title": title_str, + "has_child": bool(children), + } + + +def _require_application_header(application: Optional[str]) -> str: + if not application: + raise HTTPException( + status_code=400, + detail="OpenPecha v2 categories require `application` query/body or OPENPECHA_X_APPLICATION", + ) + return application + + +def list_categories( + *, + application: Optional[str] = None, + language: Optional[str] = None, + parent_id: Optional[str] = None, + timeout: int = 30, +) -> Any: + del language + headers = openpecha_headers(x_application=application) + if "X-Application" not in headers: + raise HTTPException( + status_code=400, + detail="OpenPecha v2 categories require `application` query param or OPENPECHA_X_APPLICATION", + ) + params: Dict[str, str] = {} + if parent_id: + params["parent_id"] = parent_id + try: + response = requests.get( + openpecha_url("categories"), + params=params, + headers=headers, + timeout=timeout, + ) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + raw = response.json() + if isinstance(raw, list): + return [_category_v2_to_legacy_row(x) for x in raw if isinstance(x, dict)] + return raw + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=502, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + +def create_category( + *, + application: str, + title: dict, + parent: Optional[str], + timeout: int = 30, +) -> Any: + headers = json_headers(x_application=_require_application_header(application)) + payload: Dict[str, Any] = {"title": title} + if parent is not None: + payload["parent_id"] = parent + try: + response = requests.post( + openpecha_url("categories"), + json=payload, + headers=headers, + timeout=timeout, + ) + if response.status_code not in (200, 201): + raise HTTPException(status_code=response.status_code, detail=response.text) + return response.json() + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=502, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") diff --git a/backend/cataloger/controller/openpecha_api/enums.py b/backend/cataloger/controller/openpecha_api/enums.py new file mode 100644 index 00000000..40c89ac6 --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/enums.py @@ -0,0 +1,59 @@ +from typing import Any + +import requests +from fastapi import HTTPException + +from cataloger.controller.openpecha_api.base import ( + openpecha_headers, + openpecha_url, + require_openpecha_base_url, +) + + +def get_enum(enum_type: str, *, timeout: int = 30) -> Any: + if enum_type == "language": + try: + response = requests.get( + openpecha_url("languages"), + headers=openpecha_headers(), + timeout=timeout, + ) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + return response.json() + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=502, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + base = require_openpecha_base_url() + try: + params = {"type": enum_type} + response = requests.get(f"{base}/enum", params=params, timeout=timeout) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + return response.json() + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=502, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") diff --git a/backend/cataloger/controller/openpecha_api/instances.py b/backend/cataloger/controller/openpecha_api/instances.py new file mode 100644 index 00000000..d594be4a --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/instances.py @@ -0,0 +1,302 @@ +from typing import Any, Dict, List, Optional + +import requests +from fastapi import HTTPException + +from cataloger.controller.openpecha_api.base import json_headers, openpecha_headers, openpecha_url +from cataloger.controller.openpecha_api.texts import ( + _post_edition_annotations, + fetch_edition_annotations, + fetch_edition_content, + fetch_edition_metadata, + get_text, +) +from cataloger.controller.openpecha_api.v2_adapters import ( + alignment_request_from_legacy_segments, + bibliographic_request_from_legacy, + build_legacy_instance_view, + normalize_license, + segmentation_from_legacy_annotation_list, +) + + +def get_instance( + instance_id: str, + *, + query_params: Optional[Dict[str, Any]] = None, + annotation: bool = True, + content: Any = True, + timeout: Optional[int] = None, +) -> Any: + _ = query_params + fetch_ann = annotation is not False + fetch_body = content is not False + try: + meta = fetch_edition_metadata(instance_id, timeout=timeout) + text_content = "" + if fetch_body: + text_content = fetch_edition_content(instance_id, timeout=timeout) + ann_bundle: Dict[str, Any] = {} + if fetch_ann: + ann_bundle = fetch_edition_annotations(instance_id, timeout=timeout) + return build_legacy_instance_view(meta, text_content, ann_bundle) + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + +def _patch_edition_content_replace_all(edition_id: str, new_text: str, *, timeout: int = 120) -> None: + current = fetch_edition_content(edition_id, timeout=timeout) + if not isinstance(current, str): + current = str(current) + n = len(current) + if n == 0: + op: Dict[str, Any] = {"type": "insert", "position": 0, "text": new_text or " "} + else: + op = {"type": "replace", "start": 0, "end": n, "text": new_text} + headers = json_headers() + r = requests.patch( + openpecha_url("editions", edition_id, "content"), + json=op, + headers=headers, + timeout=timeout, + ) + if r.status_code not in (200, 204): + raise HTTPException(status_code=r.status_code, detail=r.text) + + +def update_instance(instance_id: str, payload: Dict[str, Any]) -> Any: + try: + _patch_edition_content_replace_all(instance_id, payload.get("content") or "") + bib = bibliographic_request_from_legacy(payload.get("biblography_annotation")) + ann: Dict[str, Any] = {} + if bib: + ann["bibliographic_metadata"] = bib + seg = segmentation_from_legacy_annotation_list(payload.get("annotation") or []) + if seg: + ann["segmentation"] = seg + if ann: + _post_edition_annotations(instance_id, ann) + return get_instance(instance_id) + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + +def _child_text_body( + *, + parent_text_id: str, + category_id: str, + language: str, + title: str, + license_str: str, + contributions: List[Dict[str, Any]], + link_field: str, +) -> Dict[str, Any]: + title_loc = {language: title} if title else {language: "untitled"} + body: Dict[str, Any] = { + "title": title_loc, + "language": language, + "category_id": category_id, + "contributions": contributions, + "license": normalize_license(license_str), + link_field: parent_text_id, + } + return body + + +def _create_linked_text_and_edition( + *, + source_edition_id: str, + payload: Dict[str, Any], + link_field: str, + timeout: int = 30, +) -> Dict[str, Any]: + meta = fetch_edition_metadata(source_edition_id, timeout=timeout) + parent_text_id = meta.get("text_id") + if not parent_text_id: + raise HTTPException(status_code=502, detail="Source edition has no text_id") + parent = get_text(parent_text_id) + category_id = payload.get("category_id") or parent.get("category_id") + if not category_id: + raise HTTPException( + status_code=400, + detail="category_id is required (set on commentary payload or parent text must have category_id)", + ) + default_role = "translator" if link_field == "translation_of" else "author" + contribs: List[Dict[str, Any]] = [] + author = payload.get("author") + if isinstance(author, dict) and author.get("person_id"): + contribs.append({"person_id": author["person_id"], "role": default_role}) + elif isinstance(author, dict) and author.get("person_bdrc_id"): + contribs.append({"person_bdrc_id": author["person_bdrc_id"], "role": default_role}) + if not contribs: + contribs.append({"role": default_role}) + text_body = _child_text_body( + parent_text_id=parent_text_id, + category_id=category_id, + language=payload.get("language", "en"), + title=payload.get("title") or "", + license_str=payload.get("license"), + contributions=contribs, + link_field=link_field, + ) + headers = json_headers() + tr = requests.post( + openpecha_url("texts"), + json=text_body, + headers=headers, + timeout=timeout, + ) + if tr.status_code != 201: + raise HTTPException(status_code=tr.status_code, detail=tr.text) + new_text_id = tr.json().get("id") + if not new_text_id: + raise HTTPException(status_code=502, detail="OpenPecha v2 did not return new text id") + + seg = segmentation_from_legacy_annotation_list(payload.get("segmentation") or []) + edition_body: Dict[str, Any] = { + "metadata": { + "type": "diplomatic", + "source": source_edition_id, + }, + "content": payload.get("content") or "", + } + if seg: + edition_body["segmentation"] = seg + er = requests.post( + openpecha_url("texts", new_text_id, "editions"), + json=edition_body, + headers=headers, + timeout=timeout, + ) + if er.status_code != 201: + raise HTTPException(status_code=er.status_code, detail=er.text) + new_edition_id = er.json().get("id") + if not new_edition_id: + raise HTTPException(status_code=502, detail="OpenPecha v2 did not return new edition id") + + align = alignment_request_from_legacy_segments( + payload.get("target_annotation"), + payload.get("alignment_annotation"), + source_edition_id, + ) + if align: + _post_edition_annotations(new_edition_id, {"alignment": align}) + + bib = bibliographic_request_from_legacy(payload.get("biblography_annotation")) + if bib: + _post_edition_annotations(new_edition_id, {"bibliographic_metadata": bib}) + + return { + "message": "Created successfully", + "instance_id": new_edition_id, + "text_id": new_text_id, + } + + +def create_translation(instance_id: str, payload: Dict[str, Any], *, timeout: int = 30) -> Any: + try: + return _create_linked_text_and_edition( + source_edition_id=instance_id, + payload=payload, + link_field="translation_of", + timeout=timeout, + ) + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + +def create_commentary(instance_id: str, payload: Dict[str, Any], *, timeout: int = 30) -> Any: + try: + return _create_linked_text_and_edition( + source_edition_id=instance_id, + payload=payload, + link_field="commentary_of", + timeout=timeout, + ) + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + +def _related_edition_to_legacy(ed: Dict[str, Any], relationship: str = "related") -> Dict[str, Any]: + text_id = ed.get("text_id") or "" + t: Dict[str, Any] = {} + if text_id: + try: + t = get_text(text_id) + except HTTPException: + t = {} + return { + "instance_id": ed.get("id"), + "metadata": { + "instance_type": ed.get("type", ""), + "copyright": "", + "text_id": text_id, + "title": t.get("title") or {}, + "alt_titles": [], + "language": t.get("language") or "", + "contributions": t.get("contributions") or [], + }, + "annotation": None, + "relationship": relationship, + } + + +def list_related_instances( + instance_id: str, + *, + relationship_type: Optional[str] = None, + timeout: Optional[int] = None, +) -> Any: + del relationship_type + kw: Dict[str, Any] = {"headers": openpecha_headers()} + if timeout is not None: + kw["timeout"] = timeout + response = requests.get( + openpecha_url("editions", instance_id, "related"), + **kw, + ) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + data = response.json() + editions = data if isinstance(data, list) else [] + return [_related_edition_to_legacy(e) for e in editions if isinstance(e, dict)] diff --git a/backend/cataloger/controller/openpecha_api/openapi.json b/backend/cataloger/controller/openpecha_api/openapi.json new file mode 100644 index 00000000..c907d65e --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/openapi.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"OpenPecha API v2","version":"2.2.0"},"paths":{"/__/health":{"get":{"summary":"Health Check","description":"Health check endpoint.","operationId":"health_check____health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Health Check Health Get"}}}}}}},"/v2/texts":{"get":{"tags":["Texts"],"summary":"List all texts","description":"Retrieve a paginated list of texts (texts) with optional filters.","operationId":"get_all_texts_v2_texts_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"language","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"}},{"name":"title","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"}},{"name":"category_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Category Id"}},{"name":"tag_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tag Id"}},{"name":"author_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Author Id"}},{"name":"bdrc","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bdrc"}},{"name":"wiki","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Wiki"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Maximum number of items to return","default":20,"title":"Limit"},"description":"Maximum number of items to return"},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Number of items to skip","default":0,"title":"Offset"},"description":"Number of items to skip"},{"name":"X-Application","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Application"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextOutput"},"title":"Response Get All Texts V2 Texts Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Texts"],"summary":"Create a text","description":"Create a new text (text) with the provided data.","operationId":"create_text_v2_texts_post","security":[{"APIKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TextInput"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/texts/{text_id}":{"get":{"tags":["Texts"],"summary":"Get a text","description":"Retrieve a single text (text) by its ID.","operationId":"get_text_v2_texts__text_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"text_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the text to retrieve","title":"Text Id"},"description":"The ID of the text to retrieve"},{"name":"X-Application","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Application"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TextOutput"}}}},"404":{"description":"Text not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Texts"],"summary":"Update a text","description":"Partially update a text with the provided data.","operationId":"update_text_v2_texts__text_id__patch","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"text_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the text to update","title":"Text Id"},"description":"The ID of the text to update"},{"name":"X-Application","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Application"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TextPatch"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TextOutput"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/texts/{text_id}/editions":{"get":{"tags":["Texts"],"summary":"List editions for a text","description":"Retrieve all editions (editions) for a given text.","operationId":"get_editions_v2_texts__text_id__editions_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"text_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the text","title":"Text Id"},"description":"The ID of the text"},{"name":"edition_type","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/EditionType"},{"type":"null"}],"description":"Filter by edition type","title":"Edition Type"},"description":"Filter by edition type"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/EditionOutput"},"title":"Response Get Editions V2 Texts Text Id Editions Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Texts"],"summary":"Create an edition","description":"Create a new edition (edition) for a text.","operationId":"create_edition_v2_texts__text_id__editions_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"text_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the text","title":"Text Id"},"description":"The ID of the text"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EditionRequestModel"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/texts/{text_id}/tags/{tag_id}":{"post":{"tags":["Texts"],"summary":"Add tag to text","description":"Add a tag to a text.","operationId":"tag_text_v2_texts__text_id__tags__tag_id__post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"text_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the text","title":"Text Id"},"description":"The ID of the text"},{"name":"tag_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the tag to add","title":"Tag Id"},"description":"The ID of the tag to add"}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Texts"],"summary":"Remove tag from text","description":"Remove a tag from a text.","operationId":"untag_text_v2_texts__text_id__tags__tag_id__delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"text_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the text","title":"Text Id"},"description":"The ID of the text"},{"name":"tag_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the tag to remove","title":"Tag Id"},"description":"The ID of the tag to remove"}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/editions/{edition_id}/metadata":{"get":{"tags":["Editions"],"summary":"Get edition metadata","description":"Retrieve metadata for a specific edition.","operationId":"get_metadata_v2_editions__edition_id__metadata_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"edition_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the edition","title":"Edition Id"},"description":"The ID of the edition"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EditionOutput"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/editions/{edition_id}/content":{"get":{"tags":["Editions"],"summary":"Get edition content","description":"Retrieve the base text content of an edition.","operationId":"get_content_v2_editions__edition_id__content_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"edition_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the edition","title":"Edition Id"},"description":"The ID of the edition"},{"name":"span_start","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Start position for text slice","title":"Span Start"},"description":"Start position for text slice"},{"name":"span_end","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"End position for text slice","title":"Span End"},"description":"End position for text slice"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"string","title":"Response Get Content V2 Editions Edition Id Content Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Editions"],"summary":"Patch edition content","description":"Apply a text operation (INSERT, DELETE, or REPLACE) to the edition's content.","operationId":"patch_content_v2_editions__edition_id__content_patch","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"edition_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the edition","title":"Edition Id"},"description":"The ID of the edition"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContentOperation"}}}},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/editions/{edition_id}/annotations":{"post":{"tags":["Editions"],"summary":"Add annotation","description":"Add an annotation to an edition.","operationId":"post_annotation_v2_editions__edition_id__annotations_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"edition_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the edition","title":"Edition Id"},"description":"The ID of the edition"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationRequestInput"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["Editions"],"summary":"Get annotations","description":"Retrieve annotations for an edition.","operationId":"get_annotations_v2_editions__edition_id__annotations_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"edition_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the edition","title":"Edition Id"},"description":"The ID of the edition"},{"name":"type","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"$ref":"#/components/schemas/AnnotationType"}},{"type":"null"}],"title":"Type"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationRequestOutput"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/editions/{edition_id}/segments/related":{"get":{"tags":["Editions"],"summary":"Get related segments","description":"Find segments related to a span in an edition.","operationId":"get_segment_related_v2_editions__edition_id__segments_related_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"edition_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the edition","title":"Edition Id"},"description":"The ID of the edition"},{"name":"span_start","in":"query","required":true,"schema":{"type":"integer","minimum":0,"title":"Span Start"}},{"name":"span_end","in":"query","required":true,"schema":{"type":"integer","minimum":1,"title":"Span End"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SegmentOutput"},"title":"Response Get Segment Related V2 Editions Edition Id Segments Related Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/editions/{edition_id}/related":{"get":{"tags":["Editions"],"summary":"Get related editions","description":"Find editions related to a given edition.","operationId":"get_related_editions_v2_editions__edition_id__related_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"edition_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the edition","title":"Edition Id"},"description":"The ID of the edition"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/EditionOutput"},"title":"Response Get Related Editions V2 Editions Edition Id Related Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/editions/{edition_id}":{"delete":{"tags":["Editions"],"summary":"Delete edition","description":"Delete an edition and its associated content.","operationId":"delete_edition_v2_editions__edition_id__delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"edition_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the edition","title":"Edition Id"},"description":"The ID of the edition"}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/persons/{person_id}":{"get":{"tags":["Persons"],"summary":"Get a person","description":"Retrieve a person by their ID.","operationId":"get_person_v2_persons__person_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"person_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the person","title":"Person Id"},"description":"The ID of the person"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonOutput"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Persons"],"summary":"Update a person","description":"Partially update a person.","operationId":"update_person_v2_persons__person_id__patch","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"person_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the person","title":"Person Id"},"description":"The ID of the person"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonPatch"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonOutput"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/persons":{"get":{"tags":["Persons"],"summary":"List all persons","description":"Retrieve a paginated list of persons.","operationId":"get_all_persons_v2_persons_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by person name","title":"Name"},"description":"Filter by person name"},{"name":"bdrc","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by BDRC ID","title":"Bdrc"},"description":"Filter by BDRC ID"},{"name":"wiki","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by Wiki ID","title":"Wiki"},"description":"Filter by Wiki ID"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Maximum number of items to return","default":20,"title":"Limit"},"description":"Maximum number of items to return"},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Number of items to skip","default":0,"title":"Offset"},"description":"Number of items to skip"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PersonOutput"},"title":"Response Get All Persons V2 Persons Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Persons"],"summary":"Create a person","description":"Create a new person.","operationId":"create_person_v2_persons_post","security":[{"APIKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonInput"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/categories":{"get":{"tags":["Categories"],"summary":"List categories","description":"Retrieve categories for an application.","operationId":"get_categories_v2_categories_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"parent_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by parent category ID","title":"Parent Id"},"description":"Filter by parent category ID"},{"name":"X-Application","in":"header","required":true,"schema":{"type":"string","title":"X-Application"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CategoryOutput"},"title":"Response Get Categories V2 Categories Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Categories"],"summary":"Create category","description":"Create a new category for an application.","operationId":"create_category_v2_categories_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"X-Application","in":"header","required":true,"schema":{"type":"string","title":"X-Application"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CategoryInput"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/tags":{"get":{"tags":["Tags"],"summary":"List tags","description":"Retrieve all tags for an application.","operationId":"get_tags_v2_tags_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"X-Application","in":"header","required":true,"schema":{"type":"string","title":"X-Application"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagOutput"},"title":"Response Get Tags V2 Tags Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Tags"],"summary":"Create tag","description":"Create a new tag for an application.","operationId":"create_tag_v2_tags_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"X-Application","in":"header","required":true,"schema":{"type":"string","title":"X-Application"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TagInput"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/tags/{tag_id}":{"delete":{"tags":["Tags"],"summary":"Delete tag","description":"Delete a tag from an application.","operationId":"delete_tag_v2_tags__tag_id__delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"tag_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the tag","title":"Tag Id"},"description":"The ID of the tag"},{"name":"X-Application","in":"header","required":true,"schema":{"type":"string","title":"X-Application"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/languages":{"get":{"tags":["Languages"],"summary":"List languages","description":"Retrieve all available languages.","operationId":"get_all_languages_v2_languages_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/LanguageResponse"},"type":"array","title":"Response Get All Languages V2 Languages Get"}}}}},"security":[{"APIKeyHeader":[]}]},"post":{"tags":["Languages"],"summary":"Create language","description":"Create a new language.","operationId":"create_language_v2_languages_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LanguageCreateRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LanguageResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"APIKeyHeader":[]}]}},"/v2/annotations/segmentation/{segmentation_id}":{"get":{"tags":["Annotations"],"summary":"Get segmentation","description":"Retrieve a segmentation annotation by ID.","operationId":"get_segmentation_v2_annotations_segmentation__segmentation_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"segmentation_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the segmentation","title":"Segmentation Id"},"description":"The ID of the segmentation"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SegmentationOutput"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Annotations"],"summary":"Delete segmentation","description":"Delete a segmentation annotation.","operationId":"delete_segmentation_v2_annotations_segmentation__segmentation_id__delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"segmentation_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the segmentation","title":"Segmentation Id"},"description":"The ID of the segmentation"}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/annotations/alignment/{alignment_id}":{"get":{"tags":["Annotations"],"summary":"Get alignment","description":"Retrieve an alignment annotation by ID.","operationId":"get_alignment_v2_annotations_alignment__alignment_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"alignment_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the alignment","title":"Alignment Id"},"description":"The ID of the alignment"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlignmentOutput"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Annotations"],"summary":"Delete alignment","description":"Delete an alignment annotation.","operationId":"delete_alignment_v2_annotations_alignment__alignment_id__delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"alignment_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the alignment","title":"Alignment Id"},"description":"The ID of the alignment"}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/annotations/pagination/{pagination_id}":{"get":{"tags":["Annotations"],"summary":"Get pagination","description":"Retrieve a pagination annotation by ID.","operationId":"get_pagination_v2_annotations_pagination__pagination_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"pagination_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the pagination","title":"Pagination Id"},"description":"The ID of the pagination"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaginationOutput"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Annotations"],"summary":"Delete pagination","description":"Delete a pagination annotation.","operationId":"delete_pagination_v2_annotations_pagination__pagination_id__delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"pagination_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the pagination","title":"Pagination Id"},"description":"The ID of the pagination"}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/annotations/durchen/{note_id}":{"get":{"tags":["Annotations"],"summary":"Get durchen note","description":"Retrieve a durchen note annotation by ID.","operationId":"get_durchen_v2_annotations_durchen__note_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"note_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the durchen note","title":"Note Id"},"description":"The ID of the durchen note"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NoteOutput"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Annotations"],"summary":"Delete durchen note","description":"Delete a durchen note annotation.","operationId":"delete_durchen_v2_annotations_durchen__note_id__delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"note_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the durchen note","title":"Note Id"},"description":"The ID of the durchen note"}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/annotations/bibliographic/{bibliographic_id}":{"get":{"tags":["Annotations"],"summary":"Get bibliographic metadata","description":"Retrieve a bibliographic metadata annotation by ID.","operationId":"get_bibliographic_v2_annotations_bibliographic__bibliographic_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"bibliographic_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the bibliographic metadata","title":"Bibliographic Id"},"description":"The ID of the bibliographic metadata"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BibliographicMetadataOutput"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Annotations"],"summary":"Delete bibliographic metadata","description":"Delete a bibliographic metadata annotation.","operationId":"delete_bibliographic_v2_annotations_bibliographic__bibliographic_id__delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"bibliographic_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the bibliographic metadata","title":"Bibliographic Id"},"description":"The ID of the bibliographic metadata"}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/applications":{"post":{"tags":["Applications"],"summary":"Create application","description":"Create a new application.","operationId":"create_application_v2_applications_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCreateRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"APIKeyHeader":[]}]}},"/v2/segments/{segment_id}/related":{"get":{"tags":["Segments"],"summary":"Get related segments","description":"Retrieve segments related to a given segment.","operationId":"get_related_v2_segments__segment_id__related_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"segment_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the segment","title":"Segment Id"},"description":"The ID of the segment"},{"name":"X-Application","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Application"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SegmentOutput"},"title":"Response Get Related V2 Segments Segment Id Related Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/segments/{segment_id}/content":{"get":{"tags":["Segments"],"summary":"Get segment content","description":"Retrieve the text content of a segment.","operationId":"get_segment_content_v2_segments__segment_id__content_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"segment_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the segment","title":"Segment Id"},"description":"The ID of the segment"},{"name":"X-Application","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Application"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"string","title":"Response Get Segment Content V2 Segments Segment Id Content Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/segments/{segment_id}/tags/{tag_id}":{"post":{"tags":["Segments"],"summary":"Add tag to segment","description":"Add a tag to a segment.","operationId":"tag_segment_v2_segments__segment_id__tags__tag_id__post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"segment_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the segment","title":"Segment Id"},"description":"The ID of the segment"},{"name":"tag_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the tag","title":"Tag Id"},"description":"The ID of the tag"}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Segments"],"summary":"Remove tag from segment","description":"Remove a tag from a segment.","operationId":"untag_segment_v2_segments__segment_id__tags__tag_id__delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"segment_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the segment","title":"Segment Id"},"description":"The ID of the segment"},{"name":"tag_id","in":"path","required":true,"schema":{"type":"string","description":"The ID of the tag","title":"Tag Id"},"description":"The ID of the tag"}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v2/segments/search":{"get":{"tags":["Segments"],"summary":"Search segments","description":"Search segments using the external search API.","operationId":"search_segments_v2_segments_search_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"query","in":"query","required":true,"schema":{"type":"string","description":"Search query","title":"Query"},"description":"Search query"},{"name":"params","in":"query","required":true,"schema":{"$ref":"#/components/schemas/SegmentsQueryParams"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AIContribution":{"properties":{"ai_id":{"$ref":"#/components/schemas/NonEmptyStr"},"role":{"$ref":"#/components/schemas/ContributorRole"}},"additionalProperties":false,"type":"object","required":["ai_id","role"],"title":"AIContribution"},"AlignedSegment":{"properties":{"lines":{"items":{"$ref":"#/components/schemas/Span"},"type":"array","minItems":1,"title":"Lines"},"alignment_indices":{"items":{"type":"integer"},"type":"array","minItems":1,"title":"Alignment Indices"}},"additionalProperties":false,"type":"object","required":["lines","alignment_indices"],"title":"AlignedSegment"},"AlignmentInput":{"properties":{"target_id":{"$ref":"#/components/schemas/NonEmptyStr"},"target_segments":{"items":{"$ref":"#/components/schemas/SegmentInput"},"type":"array","title":"Target Segments"},"aligned_segments":{"items":{"$ref":"#/components/schemas/AlignedSegment"},"type":"array","title":"Aligned Segments"},"metadata":{"anyOf":[{"$ref":"#/components/schemas/AnnotationMetadata"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["target_id","target_segments","aligned_segments"],"title":"AlignmentInput"},"AlignmentOutput":{"properties":{"target_id":{"$ref":"#/components/schemas/NonEmptyStr"},"target_segments":{"items":{"$ref":"#/components/schemas/SegmentOutput"},"type":"array","title":"Target Segments"},"aligned_segments":{"items":{"$ref":"#/components/schemas/AlignedSegment"},"type":"array","title":"Aligned Segments"},"metadata":{"anyOf":[{"$ref":"#/components/schemas/AnnotationMetadata"},{"type":"null"}]},"id":{"$ref":"#/components/schemas/NonEmptyStr"}},"additionalProperties":false,"type":"object","required":["target_id","target_segments","aligned_segments","id"],"title":"AlignmentOutput"},"AnnotationMetadata":{"properties":{},"additionalProperties":false,"type":"object","title":"AnnotationMetadata"},"AnnotationRequestInput":{"properties":{"segmentation":{"anyOf":[{"$ref":"#/components/schemas/SegmentationInput"},{"type":"null"}]},"alignment":{"anyOf":[{"$ref":"#/components/schemas/AlignmentInput"},{"type":"null"}]},"pagination":{"anyOf":[{"$ref":"#/components/schemas/PaginationInput"},{"type":"null"}]},"bibliographic_metadata":{"anyOf":[{"items":{"$ref":"#/components/schemas/BibliographicMetadataInput"},"type":"array","minItems":1},{"type":"null"}],"title":"Bibliographic Metadata"},"durchen_notes":{"anyOf":[{"items":{"$ref":"#/components/schemas/NoteInput"},"type":"array","minItems":1},{"type":"null"}],"title":"Durchen Notes"}},"additionalProperties":false,"type":"object","title":"AnnotationRequestInput"},"AnnotationRequestOutput":{"properties":{"segmentations":{"anyOf":[{"items":{"$ref":"#/components/schemas/SegmentationOutput"},"type":"array"},{"type":"null"}],"title":"Segmentations"},"alignments":{"anyOf":[{"items":{"$ref":"#/components/schemas/AlignmentOutput"},"type":"array"},{"type":"null"}],"title":"Alignments"},"pagination":{"anyOf":[{"$ref":"#/components/schemas/PaginationOutput"},{"type":"null"}]},"bibliographic_metadata":{"anyOf":[{"items":{"$ref":"#/components/schemas/BibliographicMetadataOutput"},"type":"array"},{"type":"null"}],"title":"Bibliographic Metadata"},"durchen_notes":{"anyOf":[{"items":{"$ref":"#/components/schemas/NoteOutput"},"type":"array"},{"type":"null"}],"title":"Durchen Notes"}},"additionalProperties":false,"type":"object","title":"AnnotationRequestOutput"},"AnnotationType":{"type":"string","enum":["segmentation","alignment","pagination","version","bibliography","table_of_contents","durchen","search_segmentation"],"title":"AnnotationType"},"ApplicationCreateRequest":{"properties":{"name":{"$ref":"#/components/schemas/NonEmptyStr"}},"additionalProperties":false,"type":"object","required":["name"],"title":"ApplicationCreateRequest"},"ApplicationResponse":{"properties":{"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"ApplicationResponse"},"BibliographicMetadataInput":{"properties":{"span":{"$ref":"#/components/schemas/Span"},"type":{"$ref":"#/components/schemas/BibliographyType"},"metadata":{"anyOf":[{"$ref":"#/components/schemas/AnnotationMetadata"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["span","type"],"title":"BibliographicMetadataInput"},"BibliographicMetadataOutput":{"properties":{"span":{"$ref":"#/components/schemas/Span"},"type":{"$ref":"#/components/schemas/BibliographyType"},"metadata":{"anyOf":[{"$ref":"#/components/schemas/AnnotationMetadata"},{"type":"null"}]},"id":{"$ref":"#/components/schemas/NonEmptyStr"}},"additionalProperties":false,"type":"object","required":["span","type","id"],"title":"BibliographicMetadataOutput"},"BibliographyType":{"type":"string","enum":["colophon","incipit","alt_incipit","alt_title","person","title","author"],"title":"BibliographyType"},"CategoryInput":{"properties":{"title":{"$ref":"#/components/schemas/LocalizedString"},"description":{"anyOf":[{"$ref":"#/components/schemas/LocalizedString"},{"type":"null"}]},"parent_id":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["title"],"title":"CategoryInput"},"CategoryOutput":{"properties":{"title":{"$ref":"#/components/schemas/LocalizedString"},"description":{"anyOf":[{"$ref":"#/components/schemas/LocalizedString"},{"type":"null"}]},"parent_id":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"id":{"$ref":"#/components/schemas/NonEmptyStr"},"children":{"items":{"type":"string"},"type":"array","title":"Children"}},"additionalProperties":false,"type":"object","required":["title","id"],"title":"CategoryOutput"},"ContentOperation":{"oneOf":[{"$ref":"#/components/schemas/InsertOperation"},{"$ref":"#/components/schemas/DeleteOperation"},{"$ref":"#/components/schemas/ReplaceOperation"}],"title":"ContentOperation","description":"Content operation wrapper using discriminated union.","discriminator":{"propertyName":"type","mapping":{"delete":"#/components/schemas/DeleteOperation","insert":"#/components/schemas/InsertOperation","replace":"#/components/schemas/ReplaceOperation"}}},"ContributionInput":{"properties":{"person_id":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"person_bdrc_id":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"role":{"$ref":"#/components/schemas/ContributorRole"}},"additionalProperties":false,"type":"object","required":["role"],"title":"ContributionInput"},"ContributionOutput":{"properties":{"person_id":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"person_bdrc_id":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"role":{"$ref":"#/components/schemas/ContributorRole"},"person_name":{"anyOf":[{"$ref":"#/components/schemas/LocalizedString"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["role"],"title":"ContributionOutput"},"ContributorRole":{"type":"string","enum":["translator","reviser","author","scholar"],"title":"ContributorRole"},"DeleteOperation":{"properties":{"type":{"type":"string","const":"delete","title":"Type"},"start":{"type":"integer","minimum":0.0,"title":"Start","description":"Start position for DELETE operation"},"end":{"type":"integer","minimum":1.0,"title":"End","description":"End position for DELETE operation"}},"additionalProperties":false,"type":"object","required":["type","start","end"],"title":"DeleteOperation"},"EditionInput":{"properties":{"bdrc":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"wiki":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"type":{"$ref":"#/components/schemas/EditionType"},"source":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"colophon":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"incipit_title":{"anyOf":[{"$ref":"#/components/schemas/LocalizedString"},{"type":"null"}]},"alt_incipit_titles":{"anyOf":[{"items":{"$ref":"#/components/schemas/LocalizedString"},"type":"array"},{"type":"null"}],"title":"Alt Incipit Titles"}},"additionalProperties":false,"type":"object","required":["type"],"title":"EditionInput"},"EditionOutput":{"properties":{"bdrc":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"wiki":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"type":{"$ref":"#/components/schemas/EditionType"},"source":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"colophon":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"incipit_title":{"anyOf":[{"$ref":"#/components/schemas/LocalizedString"},{"type":"null"}]},"alt_incipit_titles":{"anyOf":[{"items":{"$ref":"#/components/schemas/LocalizedString"},"type":"array"},{"type":"null"}],"title":"Alt Incipit Titles"},"id":{"$ref":"#/components/schemas/NonEmptyStr"},"text_id":{"$ref":"#/components/schemas/NonEmptyStr"}},"additionalProperties":false,"type":"object","required":["type","id","text_id"],"title":"EditionOutput"},"EditionRequestModel":{"properties":{"metadata":{"$ref":"#/components/schemas/EditionInput"},"pagination":{"anyOf":[{"$ref":"#/components/schemas/PaginationInput"},{"type":"null"}]},"segmentation":{"anyOf":[{"$ref":"#/components/schemas/SegmentationInput"},{"type":"null"}]},"content":{"$ref":"#/components/schemas/NonEmptyStr"}},"additionalProperties":false,"type":"object","required":["metadata","content"],"title":"EditionRequestModel"},"EditionType":{"type":"string","enum":["diplomatic","critical","collated"],"title":"EditionType"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"IdResponse":{"properties":{"id":{"type":"string","title":"Id"}},"type":"object","required":["id"],"title":"IdResponse"},"IdsResponse":{"properties":{"ids":{"items":{"type":"string"},"type":"array","title":"Ids"}},"type":"object","required":["ids"],"title":"IdsResponse"},"InsertOperation":{"properties":{"type":{"type":"string","const":"insert","title":"Type"},"position":{"type":"integer","minimum":0.0,"title":"Position","description":"Position for INSERT operation"},"text":{"type":"string","minLength":1,"title":"Text","description":"Text to insert"}},"additionalProperties":false,"type":"object","required":["type","position","text"],"title":"InsertOperation"},"LanguageCreateRequest":{"properties":{"code":{"$ref":"#/components/schemas/NonEmptyStr"},"name":{"$ref":"#/components/schemas/NonEmptyStr"}},"additionalProperties":false,"type":"object","required":["code","name"],"title":"LanguageCreateRequest"},"LanguageResponse":{"properties":{"code":{"type":"string","title":"Code"},"name":{"type":"string","title":"Name"}},"type":"object","required":["code","name"],"title":"LanguageResponse"},"LicenseType":{"type":"string","enum":["cc0","public","cc-by","cc-by-sa","cc-by-nd","cc-by-nc","cc-by-nc-sa","cc-by-nc-nd","copyrighted","unknown"],"title":"LicenseType"},"LocalizedString":{"additionalProperties":{"$ref":"#/components/schemas/NonEmptyStr"},"type":"object","minProperties":1,"title":"LocalizedString"},"NonEmptyStr":{"type":"string","minLength":1},"NoteInput":{"properties":{"span":{"$ref":"#/components/schemas/Span"},"text":{"$ref":"#/components/schemas/NonEmptyStr"},"metadata":{"anyOf":[{"$ref":"#/components/schemas/AnnotationMetadata"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["span","text"],"title":"NoteInput"},"NoteOutput":{"properties":{"span":{"$ref":"#/components/schemas/Span"},"text":{"$ref":"#/components/schemas/NonEmptyStr"},"metadata":{"anyOf":[{"$ref":"#/components/schemas/AnnotationMetadata"},{"type":"null"}]},"id":{"$ref":"#/components/schemas/NonEmptyStr"}},"additionalProperties":false,"type":"object","required":["span","text","id"],"title":"NoteOutput"},"Page":{"properties":{"lines":{"items":{"$ref":"#/components/schemas/Span"},"type":"array","minItems":1,"title":"Lines"},"reference":{"$ref":"#/components/schemas/NonEmptyStr"}},"additionalProperties":false,"type":"object","required":["lines","reference"],"title":"Page"},"PaginationInput":{"properties":{"volume":{"$ref":"#/components/schemas/Volume"},"metadata":{"anyOf":[{"$ref":"#/components/schemas/AnnotationMetadata"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["volume"],"title":"PaginationInput"},"PaginationOutput":{"properties":{"volume":{"$ref":"#/components/schemas/Volume"},"metadata":{"anyOf":[{"$ref":"#/components/schemas/AnnotationMetadata"},{"type":"null"}]},"id":{"$ref":"#/components/schemas/NonEmptyStr"}},"additionalProperties":false,"type":"object","required":["volume","id"],"title":"PaginationOutput"},"PersonInput":{"properties":{"bdrc":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"wiki":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"name":{"$ref":"#/components/schemas/LocalizedString"},"alt_names":{"anyOf":[{"items":{"$ref":"#/components/schemas/LocalizedString"},"type":"array"},{"type":"null"}],"title":"Alt Names"}},"additionalProperties":false,"type":"object","required":["name"],"title":"PersonInput"},"PersonOutput":{"properties":{"bdrc":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"wiki":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"name":{"$ref":"#/components/schemas/LocalizedString"},"alt_names":{"anyOf":[{"items":{"$ref":"#/components/schemas/LocalizedString"},"type":"array"},{"type":"null"}],"title":"Alt Names"},"id":{"$ref":"#/components/schemas/NonEmptyStr"}},"additionalProperties":false,"type":"object","required":["name","id"],"title":"PersonOutput"},"PersonPatch":{"properties":{"bdrc":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"wiki":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"name":{"anyOf":[{"$ref":"#/components/schemas/LocalizedString"},{"type":"null"}]},"alt_names":{"anyOf":[{"items":{"$ref":"#/components/schemas/LocalizedString"},"type":"array"},{"type":"null"}],"title":"Alt Names"}},"additionalProperties":false,"type":"object","title":"PersonPatch"},"ReplaceOperation":{"properties":{"type":{"type":"string","const":"replace","title":"Type"},"start":{"type":"integer","minimum":0.0,"title":"Start","description":"Start position for REPLACE operation"},"end":{"type":"integer","minimum":1.0,"title":"End","description":"End position for REPLACE operation"},"text":{"type":"string","minLength":1,"title":"Text","description":"Replacement text"}},"additionalProperties":false,"type":"object","required":["type","start","end","text"],"title":"ReplaceOperation"},"SearchResponse":{"properties":{"query":{"type":"string","title":"Query"},"results":{"items":{"$ref":"#/components/schemas/SearchResult"},"type":"array","title":"Results"},"count":{"type":"integer","title":"Count"}},"additionalProperties":false,"type":"object","required":["query","results","count"],"title":"SearchResponse"},"SearchResult":{"properties":{"id":{"$ref":"#/components/schemas/NonEmptyStr"},"distance":{"type":"number","title":"Distance"},"entity":{"additionalProperties":true,"type":"object","title":"Entity"},"segmentation_ids":{"items":{"$ref":"#/components/schemas/NonEmptyStr"},"type":"array","title":"Segmentation Ids"}},"additionalProperties":false,"type":"object","required":["id","distance","entity"],"title":"SearchResult"},"SegmentInput":{"properties":{"lines":{"items":{"$ref":"#/components/schemas/Span"},"type":"array","minItems":1,"title":"Lines"}},"additionalProperties":false,"type":"object","required":["lines"],"title":"SegmentInput"},"SegmentOutput":{"properties":{"lines":{"items":{"$ref":"#/components/schemas/Span"},"type":"array","minItems":1,"title":"Lines"},"id":{"$ref":"#/components/schemas/NonEmptyStr"},"edition_id":{"$ref":"#/components/schemas/NonEmptyStr"},"text_id":{"$ref":"#/components/schemas/NonEmptyStr"},"tag_ids":{"items":{"type":"string"},"type":"array","title":"Tag Ids"}},"additionalProperties":false,"type":"object","required":["lines","id","edition_id","text_id"],"title":"SegmentOutput"},"SegmentationInput":{"properties":{"segments":{"items":{"$ref":"#/components/schemas/SegmentInput"},"type":"array","title":"Segments"},"metadata":{"anyOf":[{"$ref":"#/components/schemas/AnnotationMetadata"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["segments"],"title":"SegmentationInput"},"SegmentationOutput":{"properties":{"segments":{"items":{"$ref":"#/components/schemas/SegmentOutput"},"type":"array","title":"Segments"},"metadata":{"anyOf":[{"$ref":"#/components/schemas/AnnotationMetadata"},{"type":"null"}]},"id":{"$ref":"#/components/schemas/NonEmptyStr"}},"additionalProperties":false,"type":"object","required":["segments","id"],"title":"SegmentationOutput"},"SegmentsQueryParams":{"properties":{"search_type":{"type":"string","title":"Search Type","description":"Type of segment search","default":"semantic"},"limit":{"type":"integer","maximum":100.0,"minimum":1.0,"title":"Limit","description":"Maximum number of results","default":10},"return_text":{"type":"boolean","title":"Return Text","description":"Include text content","default":true},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title","description":"Filter by title"}},"additionalProperties":false,"type":"object","title":"SegmentsQueryParams"},"Span":{"properties":{"start":{"type":"integer","minimum":0.0,"title":"Start","description":"Start character position (inclusive)"},"end":{"type":"integer","minimum":1.0,"title":"End","description":"End character position (exclusive)"}},"additionalProperties":false,"type":"object","required":["start","end"],"title":"Span"},"TagInput":{"properties":{"title":{"$ref":"#/components/schemas/LocalizedString"},"description":{"anyOf":[{"$ref":"#/components/schemas/LocalizedString"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["title"],"title":"TagInput"},"TagOutput":{"properties":{"title":{"$ref":"#/components/schemas/LocalizedString"},"description":{"anyOf":[{"$ref":"#/components/schemas/LocalizedString"},{"type":"null"}]},"id":{"$ref":"#/components/schemas/NonEmptyStr"}},"additionalProperties":false,"type":"object","required":["title","id"],"title":"TagOutput"},"TextInput":{"properties":{"bdrc":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"wiki":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"date":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"title":{"$ref":"#/components/schemas/LocalizedString"},"alt_titles":{"anyOf":[{"items":{"$ref":"#/components/schemas/LocalizedString"},"type":"array"},{"type":"null"}],"title":"Alt Titles"},"language":{"$ref":"#/components/schemas/NonEmptyStr"},"commentary_of":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"translation_of":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"category_id":{"$ref":"#/components/schemas/NonEmptyStr"},"license":{"$ref":"#/components/schemas/LicenseType","default":"public"},"contributions":{"items":{"anyOf":[{"$ref":"#/components/schemas/ContributionInput"},{"$ref":"#/components/schemas/AIContribution"}]},"type":"array","title":"Contributions"},"tag_ids":{"items":{"$ref":"#/components/schemas/NonEmptyStr"},"type":"array","title":"Tag Ids"}},"additionalProperties":false,"type":"object","required":["title","language","category_id","contributions"],"title":"TextInput"},"TextOutput":{"properties":{"bdrc":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"wiki":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"date":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"title":{"$ref":"#/components/schemas/LocalizedString"},"alt_titles":{"anyOf":[{"items":{"$ref":"#/components/schemas/LocalizedString"},"type":"array"},{"type":"null"}],"title":"Alt Titles"},"language":{"$ref":"#/components/schemas/NonEmptyStr"},"commentary_of":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"translation_of":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"category_id":{"$ref":"#/components/schemas/NonEmptyStr"},"license":{"$ref":"#/components/schemas/LicenseType","default":"public"},"id":{"$ref":"#/components/schemas/NonEmptyStr"},"contributions":{"items":{"anyOf":[{"$ref":"#/components/schemas/ContributionOutput"},{"$ref":"#/components/schemas/AIContribution"}]},"type":"array","title":"Contributions"},"commentaries":{"items":{"type":"string"},"type":"array","title":"Commentaries"},"translations":{"items":{"type":"string"},"type":"array","title":"Translations"},"editions":{"items":{"type":"string"},"type":"array","title":"Editions"},"tag_ids":{"items":{"type":"string"},"type":"array","title":"Tag Ids"}},"additionalProperties":false,"type":"object","required":["title","language","category_id","id","contributions"],"title":"TextOutput"},"TextPatch":{"properties":{"bdrc":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"wiki":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"date":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"title":{"anyOf":[{"$ref":"#/components/schemas/LocalizedString"},{"type":"null"}]},"alt_titles":{"anyOf":[{"items":{"$ref":"#/components/schemas/LocalizedString"},"type":"array"},{"type":"null"}],"title":"Alt Titles"},"language":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"category_id":{"anyOf":[{"$ref":"#/components/schemas/NonEmptyStr"},{"type":"null"}]},"license":{"anyOf":[{"$ref":"#/components/schemas/LicenseType"},{"type":"null"}]},"tag_ids":{"anyOf":[{"items":{"$ref":"#/components/schemas/NonEmptyStr"},"type":"array"},{"type":"null"}],"title":"Tag Ids"}},"additionalProperties":false,"type":"object","title":"TextPatch"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"Volume":{"properties":{"index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Index"},"pages":{"items":{"$ref":"#/components/schemas/Page"},"type":"array","minItems":1,"title":"Pages"},"metadata":{"anyOf":[{"$ref":"#/components/schemas/AnnotationMetadata"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["pages"],"title":"Volume"}},"securitySchemes":{"APIKeyHeader":{"type":"apiKey","in":"header","name":"X-API-Key"}}}} \ No newline at end of file diff --git a/backend/cataloger/controller/openpecha_api/persons.py b/backend/cataloger/controller/openpecha_api/persons.py new file mode 100644 index 00000000..05ea7ba4 --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/persons.py @@ -0,0 +1,76 @@ +from typing import Any, Dict, Optional + +import requests +from fastapi import HTTPException + +from cataloger.controller.openpecha_api.base import openpecha_headers, openpecha_url + + +def _map_create_person(payload: Dict[str, Any]) -> Dict[str, Any]: + name = payload.get("name") or {} + if isinstance(name, dict): + name = {k: v for k, v in name.items() if v} + alt_names = [] + for a in payload.get("alt_names") or []: + if isinstance(a, dict): + loc = {k: v for k, v in a.items() if v} + if loc: + alt_names.append(loc) + out: Dict[str, Any] = {"name": name} + if alt_names: + out["alt_names"] = alt_names + if payload.get("bdrc"): + out["bdrc"] = payload["bdrc"] + if payload.get("wiki"): + out["wiki"] = payload["wiki"] + return out + + +def list_persons( + *, + limit: int = 100, + offset: int = 0, + name: Optional[str] = None, + bdrc: Optional[str] = None, + wiki: Optional[str] = None, +) -> Any: + params: Dict[str, Any] = { + "limit": min(limit, 100), + "offset": offset, + "name": name, + "bdrc": bdrc, + "wiki": wiki, + } + params = {k: v for k, v in params.items() if v is not None} + response = requests.get( + openpecha_url("persons"), + params=params, + headers=openpecha_headers(), + ) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + return response.json() + + +def get_person(person_id: str) -> Any: + response = requests.get( + openpecha_url("persons", person_id), + headers=openpecha_headers(), + ) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + return response.json() + + +def create_person(payload: Dict[str, Any]) -> Any: + body = _map_create_person(payload) + response = requests.post( + openpecha_url("persons"), + json=body, + headers={**openpecha_headers(), "Content-Type": "application/json"}, + ) + if response.status_code != 201: + raise HTTPException(status_code=response.status_code, detail=response.text) + data = response.json() + pid = data.get("id") + return {"message": "Person created successfully", "_id": pid, "id": pid} diff --git a/backend/cataloger/controller/openpecha_api/segments.py b/backend/cataloger/controller/openpecha_api/segments.py new file mode 100644 index 00000000..b74de23b --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/segments.py @@ -0,0 +1,39 @@ +from typing import Any + +import requests +from fastapi import HTTPException + +from cataloger.controller.openpecha_api.base import json_headers, openpecha_url + + +def update_segment_content(segment_id: str, content: str, *, timeout: int = 30) -> Any: + """v2 OpenAPI documents GET only; try PATCH for compatibility with cataloger UX.""" + url = openpecha_url("segments", segment_id, "content") + try: + response = requests.patch( + url, + headers=json_headers(), + json={"content": content}, + timeout=timeout, + ) + if response.status_code in (200, 204): + return response.json() if response.content else {"ok": True} + response = requests.put( + url, + headers=json_headers(), + json={"content": content}, + timeout=timeout, + ) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + return response.json() if response.content else {"ok": True} + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) diff --git a/backend/cataloger/controller/openpecha_api/texts.py b/backend/cataloger/controller/openpecha_api/texts.py new file mode 100644 index 00000000..479c70f0 --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/texts.py @@ -0,0 +1,276 @@ +from typing import Any, Dict, List, Optional + +import requests +from fastapi import HTTPException + +from cataloger.controller.openpecha_api.base import ( + json_headers, + openpecha_headers, + openpecha_url, +) +from cataloger.controller.openpecha_api.v2_adapters import ( + create_text_payload_from_legacy, + edition_metadata_from_legacy, + edition_output_to_instance_list_item, + patch_text_payload_from_legacy, + segmentation_from_legacy_annotation_list, + text_output_to_legacy, + wrap_text_list, + bibliographic_request_from_legacy, +) + + +def list_texts( + *, + limit: int = 30, + offset: int = 0, + language: Optional[str] = None, + author: Optional[str] = None, + text_type: Optional[str] = None, + title: Optional[str] = None, + category_id: Optional[str] = None, + x_application: Optional[str] = None, +) -> Any: + params: Dict[str, Any] = { + "limit": min(limit, 100), + "offset": offset, + "language": language, + "author_id": author, + "title": title, + "category_id": category_id, + } + params = {k: v for k, v in params.items() if v is not None} + headers = openpecha_headers(x_application=x_application) + try: + response = requests.get(openpecha_url("texts"), params=params, headers=headers, timeout=60) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + raw = response.json() + wrapped = wrap_text_list(raw, limit=limit, offset=offset) + if text_type and isinstance(wrapped.get("results"), list): + wrapped["results"] = [r for r in wrapped["results"] if r.get("type") == text_type] + wrapped["count"] = len(wrapped["results"]) + return wrapped + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + +def create_text(payload: Dict[str, Any], *, x_application: Optional[str] = None) -> Any: + try: + body = create_text_payload_from_legacy(payload) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + headers = json_headers(x_application=x_application) + try: + response = requests.post( + openpecha_url("texts"), + json=body, + headers=headers, + timeout=60, + ) + if response.status_code != 201: + raise HTTPException(status_code=response.status_code, detail=response.text) + data = response.json() + eid = data.get("id") + return {"message": "Text created successfully", "id": eid} + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + +def get_text(text_id: str, *, x_application: Optional[str] = None) -> Any: + headers = openpecha_headers(x_application=x_application) + try: + response = requests.get( + openpecha_url("texts", text_id), + headers=headers, + timeout=60, + ) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + return text_output_to_legacy(response.json()) + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + +def update_text(text_id: str, payload: Dict[str, Any], *, x_application: Optional[str] = None) -> Any: + body = patch_text_payload_from_legacy(payload) + if not body: + return get_text(text_id, x_application=x_application) + headers = json_headers(x_application=x_application) + try: + response = requests.patch( + openpecha_url("texts", text_id), + json=body, + headers=headers, + timeout=60, + ) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + return text_output_to_legacy(response.json()) + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + +def list_instances_for_text(text_id: str) -> Any: + headers = openpecha_headers() + try: + response = requests.get( + openpecha_url("texts", text_id, "editions"), + headers=headers, + timeout=60, + ) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + data = response.json() + editions = data if isinstance(data, list) else [] + return [edition_output_to_instance_list_item(e) for e in editions if isinstance(e, dict)] + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + +def _post_edition_annotations(edition_id: str, ann: Dict[str, Any]) -> None: + if not ann: + return + headers = json_headers() + r = requests.post( + openpecha_url("editions", edition_id, "annotations"), + json=ann, + headers=headers, + timeout=120, + ) + if r.status_code not in (200, 201): + raise HTTPException(status_code=r.status_code, detail=r.text) + + +def create_instance_for_text(text_id: str, payload: Dict[str, Any], *, timeout: int = 120) -> Any: + metadata = edition_metadata_from_legacy(payload.get("metadata") or {}) + seg = segmentation_from_legacy_annotation_list(payload.get("annotation") or []) + body: Dict[str, Any] = { + "metadata": metadata, + "content": payload.get("content") or "", + } + if seg: + body["segmentation"] = seg + headers = json_headers() + try: + response = requests.post( + openpecha_url("texts", text_id, "editions"), + json=body, + headers=headers, + timeout=timeout, + ) + if response.status_code != 201: + raise HTTPException(status_code=response.status_code, detail=response.text) + data = response.json() + edition_id = data.get("id") + if not edition_id: + raise HTTPException(status_code=502, detail="OpenPecha v2 did not return edition id") + + bib = bibliographic_request_from_legacy(payload.get("biblography_annotation")) + ann_post: Dict[str, Any] = {} + if bib: + ann_post["bibliographic_metadata"] = bib + if ann_post: + _post_edition_annotations(edition_id, ann_post) + + return {"message": "Instance created successfully", "id": edition_id} + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + +def fetch_edition_metadata(edition_id: str, *, timeout: Optional[int] = None) -> Dict[str, Any]: + kw: Dict[str, Any] = {"headers": openpecha_headers()} + if timeout is not None: + kw["timeout"] = timeout + r = requests.get(openpecha_url("editions", edition_id, "metadata"), **kw) + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=r.text) + return r.json() + + +def fetch_edition_content(edition_id: str, *, timeout: Optional[int] = None) -> str: + kw: Dict[str, Any] = {"headers": openpecha_headers()} + if timeout is not None: + kw["timeout"] = timeout + r = requests.get(openpecha_url("editions", edition_id, "content"), **kw) + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=r.text) + # v2 returns raw JSON string + try: + return r.json() + except Exception: + return r.text + + +def fetch_edition_annotations( + edition_id: str, + *, + types: Optional[List[str]] = None, + timeout: Optional[int] = None, +) -> Dict[str, Any]: + params = {} + if types: + params["type"] = types + kw: Dict[str, Any] = {"headers": openpecha_headers(), "params": params} + if timeout is not None: + kw["timeout"] = timeout + r = requests.get(openpecha_url("editions", edition_id, "annotations"), **kw) + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=r.text) + return r.json() diff --git a/backend/cataloger/controller/openpecha_api/v2_adapters.py b/backend/cataloger/controller/openpecha_api/v2_adapters.py new file mode 100644 index 00000000..81cf12ad --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/v2_adapters.py @@ -0,0 +1,309 @@ +"""Map OpenPecha API v2 JSON to legacy cataloger shapes expected by routers.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +LICENSE_ALIASES = { + "cc0": "cc0", + "public": "public", + "cc-by": "cc-by", + "cc_by": "cc-by", + "cc-by-sa": "cc-by-sa", + "cc-by-nd": "cc-by-nd", + "cc-by-nc": "cc-by-nc", + "cc-by-nc-sa": "cc-by-nc-sa", + "cc-by-nc-nd": "cc-by-nc-nd", + "copyrighted": "copyrighted", + "unknown": "unknown", +} + + +def normalize_license(value: Optional[str]) -> str: + if not value: + return "public" + k = value.strip().lower().replace(" ", "-").replace("_", "-") + return LICENSE_ALIASES.get(k, "unknown") + + +def infer_text_type(text_out: Dict[str, Any]) -> str: + if text_out.get("translation_of"): + return "translation" + if text_out.get("commentary_of"): + return "commentary" + return "root" + + +def text_output_to_legacy(text_out: Dict[str, Any]) -> Dict[str, Any]: + print(text_out) + target = text_out.get("translation_of") or text_out.get("commentary_of") + return { + "id": text_out["id"], + "type": infer_text_type(text_out), + "title": text_out.get("title") or {}, + "language": text_out.get("language", ""), + "target": target, + "contributions": text_out.get("contributions") or [], + "date": text_out.get("date"), + "bdrc": text_out.get("bdrc"), + "wiki": text_out.get("wiki"), + "category_id": text_out.get("category_id"), + "created_at": text_out.get("created_at"), + "updated_at": text_out.get("updated_at"), + } + + +def wrap_text_list(raw: Any, *, limit: int, offset: int) -> Dict[str, Any]: + if isinstance(raw, dict) and "results" in raw: + return raw + rows = raw if isinstance(raw, list) else [] + legacy = [text_output_to_legacy(r) if isinstance(r, dict) else r for r in rows] + return {"results": legacy, "count": len(legacy)} + + +def create_text_payload_from_legacy(body: Dict[str, Any]) -> Dict[str, Any]: + """Map cataloger CreateText to v2 TextInput.""" + title = body.get("title") or {} + if not title: + raise ValueError("title is required") + category_id = body.get("category_id") + if not category_id: + raise ValueError("category_id is required for OpenPecha v2") + contributions = body.get("contributions") or [] + mapped_contribs: List[Dict[str, Any]] = [] + for c in contributions: + if not isinstance(c, dict): + continue + if c.get("ai_id"): + mapped_contribs.append({"ai_id": c["ai_id"], "role": c.get("role", "author")}) + else: + entry: Dict[str, Any] = {"role": c.get("role", "author")} + if c.get("person_id"): + entry["person_id"] = c["person_id"] + if c.get("person_bdrc_id"): + entry["person_bdrc_id"] = c["person_bdrc_id"] + mapped_contribs.append(entry) + out: Dict[str, Any] = { + "title": title, + "language": body.get("language", ""), + "category_id": category_id, + "contributions": mapped_contribs, + "license": normalize_license(body.get("license")), + } + if body.get("bdrc") is not None: + out["bdrc"] = body["bdrc"] + if body.get("wiki") is not None: + out["wiki"] = body["wiki"] + if body.get("date") is not None: + out["date"] = body["date"] + if body.get("alt_titles"): + out["alt_titles"] = body["alt_titles"] + t = body.get("type") + tgt = body.get("target") + if t == "translation" and tgt: + out["translation_of"] = tgt + elif t == "commentary" and tgt: + out["commentary_of"] = tgt + return out + + +def patch_text_payload_from_legacy(body: Dict[str, Any]) -> Dict[str, Any]: + """Map cataloger UpdateText to v2 TextPatch.""" + out: Dict[str, Any] = {} + for k in ("bdrc", "wiki", "date", "title", "language", "category_id"): + if k in body and body[k] is not None: + out[k] = body[k] + if body.get("license") is not None: + out["license"] = normalize_license(body["license"]) + if body.get("contributions") is not None: + out["contributions"] = body["contributions"] + alt = body.get("alt_title") + if alt: + out["alt_titles"] = alt + return out + + +def edition_output_to_instance_list_item(ed: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": ed["id"], + "type": ed.get("type", ""), + "source": ed.get("source"), + "bdrc": ed.get("bdrc"), + "wiki": ed.get("wiki"), + "colophon": ed.get("colophon"), + "incipit_title": ed.get("incipit_title"), + "alt_incipit_titles": ed.get("alt_incipit_titles") or [], + } + + +def annotation_bundle_to_legacy_refs(bundle: Dict[str, Any]) -> List[Dict[str, str]]: + refs: List[Dict[str, str]] = [] + for key, typ in ( + ("segmentations", "segmentation"), + ("alignments", "alignment"), + ("pagination", "pagination"), + ): + val = bundle.get(key) + if isinstance(val, list): + for item in val: + if isinstance(item, dict) and item.get("id"): + refs.append({"annotation_id": item["id"], "type": typ}) + elif isinstance(val, dict) and val.get("id"): + refs.append({"annotation_id": val["id"], "type": typ}) + for item in bundle.get("bibliographic_metadata") or []: + if isinstance(item, dict) and item.get("id"): + refs.append({"annotation_id": item["id"], "type": "bibliography"}) + for item in bundle.get("durchen_notes") or []: + if isinstance(item, dict) and item.get("id"): + refs.append({"annotation_id": item["id"], "type": "durchen"}) + return refs + + +def bibliography_items_from_bundle(bundle: Dict[str, Any]) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for item in bundle.get("bibliographic_metadata") or []: + if not isinstance(item, dict): + continue + span = item.get("span") or {} + btype = item.get("type") + if span and btype: + out.append({"span": span, "type": btype}) + return out + + +def build_legacy_instance_view( + edition_meta: Dict[str, Any], + content: str, + ann_bundle: Dict[str, Any], +) -> Dict[str, Any]: + eid = edition_meta.get("id", "") + return { + "content": content, + "metadata": { + "id": eid, + "type": edition_meta.get("type", ""), + "copyright": "", + "bdrc": edition_meta.get("bdrc"), + "wiki": edition_meta.get("wiki"), + "colophon": edition_meta.get("colophon"), + "incipit_title": edition_meta.get("incipit_title"), + "alt_incipit_titles": edition_meta.get("alt_incipit_titles"), + }, + "annotations": annotation_bundle_to_legacy_refs(ann_bundle), + "biblography_annotation": bibliography_items_from_bundle(ann_bundle), + "alignment_targets": [], + } + + +def segmentation_from_legacy_annotation_list( + annotation: List[Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + spans: List[Dict[str, Any]] = [] + for item in annotation: + if not isinstance(item, dict): + continue + if "alignment_index" in item or item.get("type") == "alignment": + continue + sp = item.get("span") + if isinstance(sp, dict) and "start" in sp and "end" in sp: + spans.append(sp) + if not spans: + return None + segments = [{"lines": [sp]} for sp in spans] + return {"segments": segments} + + +def edition_metadata_from_legacy(meta: Dict[str, Any]) -> Dict[str, Any]: + etype = meta.get("type") or "diplomatic" + if etype not in ("diplomatic", "critical", "collated"): + etype = "diplomatic" + out: Dict[str, Any] = {"type": etype} + for k in ("bdrc", "wiki", "source", "colophon", "incipit_title", "alt_incipit_titles"): + if meta.get(k) is not None: + out[k] = meta[k] + return out + + +def bibliographic_request_from_legacy( + items: Optional[List[Dict[str, Any]]], +) -> Optional[List[Dict[str, Any]]]: + if not items: + return None + mapped: List[Dict[str, Any]] = [] + for b in items: + if not isinstance(b, dict): + continue + span = b.get("span") + btype = b.get("type") + if isinstance(span, dict) and btype: + mapped.append({"span": span, "type": btype}) + return mapped or None + + +def alignment_output_to_legacy_data(a: Dict[str, Any]) -> Dict[str, Any]: + target_annotation: List[Dict[str, Any]] = [] + for i, seg in enumerate(a.get("target_segments") or []): + if not isinstance(seg, dict): + continue + lines = seg.get("lines") or [] + if lines: + target_annotation.append({"span": lines[0], "index": i}) + alignment_annotation: List[Dict[str, Any]] = [] + for i, al in enumerate(a.get("aligned_segments") or []): + if not isinstance(al, dict): + continue + lines = al.get("lines") or [] + if lines: + alignment_annotation.append( + { + "span": lines[0], + "index": i, + "alignment_index": al.get("alignment_indices") or [], + } + ) + return { + "target_annotation": target_annotation, + "alignment_annotation": alignment_annotation, + } + + +def segmentation_output_to_legacy_data(s: Dict[str, Any]) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for seg in s.get("segments") or []: + if not isinstance(seg, dict): + continue + lines = seg.get("lines") or [] + if lines: + out.append({"span": lines[0]}) + return out + + +def alignment_request_from_legacy_segments( + target_annotation: Optional[List[Dict[str, Any]]], + alignment_annotation: Optional[List[Dict[str, Any]]], + source_edition_id: str, +) -> Optional[Dict[str, Any]]: + """Build v2 AlignmentInput; source_edition_id is the edition being translated from (route param).""" + if not target_annotation or not alignment_annotation: + return None + target_segments = [] + for t in target_annotation: + if isinstance(t, dict) and t.get("span"): + target_segments.append({"lines": [t["span"]]}) + aligned = [] + for a in alignment_annotation: + if not isinstance(a, dict) or not a.get("span"): + continue + aligned.append( + { + "lines": [a["span"]], + "alignment_indices": a.get("alignment_index") or [], + } + ) + if not target_segments or not aligned: + return None + return { + "target_id": source_edition_id, + "target_segments": target_segments, + "aligned_segments": aligned, + } diff --git a/backend/cataloger/routers/aligner_data.py b/backend/cataloger/routers/aligner_data.py index ba24076a..d4f534d2 100644 --- a/backend/cataloger/routers/aligner_data.py +++ b/backend/cataloger/routers/aligner_data.py @@ -1,15 +1,18 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from typing import List, Optional, Dict, Any -import requests -import os -from dotenv import load_dotenv -load_dotenv(override=True) +from cataloger.controller.openpecha_api.annotations import ( + get_annotation as openpecha_get_annotation, +) +from cataloger.controller.openpecha_api.instances import ( + get_instance as openpecha_get_instance, + list_related_instances as openpecha_list_related_instances, +) router = APIRouter() -API_ENDPOINT = os.getenv("OPENPECHA_ENDPOINT") +_INSTANCE_QUERY = {"annotation": "true", "content": "true"} class Span(BaseModel): @@ -99,171 +102,146 @@ def reconstruct_segments( def prepare_data(source_instance_id: str, target_instance_id: str) -> Dict[str, Any]: """Prepare data by fetching instances, checking related instances first for alignment, then falling back to segmentation annotations if no alignment exists""" - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - try: - # Fetch source instance - source_response = requests.get( - f"{API_ENDPOINT}/instances/{source_instance_id}", - params={"annotation": "true", "content": "true"}, - timeout=30 + source_instance = openpecha_get_instance( + source_instance_id, + query_params=_INSTANCE_QUERY, + timeout=30, ) - if source_response.status_code != 200: - raise HTTPException( - status_code=source_response.status_code, - detail=f"Error fetching source instance: {source_response.text}" - ) - source_instance = source_response.json() - - # Fetch target instance - target_response = requests.get( - f"{API_ENDPOINT}/instances/{target_instance_id}", - params={"annotation": "true", "content": "true"}, - timeout=30 + except HTTPException as e: + raise HTTPException( + status_code=e.status_code, + detail=f"Error fetching source instance: {e.detail}", + ) from e + + try: + target_instance = openpecha_get_instance( + target_instance_id, + query_params=_INSTANCE_QUERY, + timeout=30, ) - if target_response.status_code != 200: - raise HTTPException( - status_code=target_response.status_code, - detail=f"Error fetching target instance: {target_response.text}" - ) - target_instance = target_response.json() - - source_text = source_instance.get("content", "") - target_text = target_instance.get("content", "") - - has_alignment = False - annotation_data = None - - # Step 1: Check for related instance relationship first - # Check if target is in source's related instances (or vice versa) - related_response = requests.get( - f"{API_ENDPOINT}/instances/{source_instance_id}/related", - timeout=30 + except HTTPException as e: + raise HTTPException( + status_code=e.status_code, + detail=f"Error fetching target instance: {e.detail}", + ) from e + + source_text = source_instance.get("content", "") + target_text = target_instance.get("content", "") + + has_alignment = False + annotation_data = None + + # Step 1: Check for related instance relationship first + # Check if target is in source's related instances (or vice versa) + try: + related_instances = openpecha_list_related_instances( + source_instance_id, + timeout=30, ) - - alignment_ann_id = None - if related_response.status_code == 200: - related_instances = related_response.json() - # Handle both list and dict with results key - if isinstance(related_instances, dict) and "results" in related_instances: - related_instances = related_instances["results"] - - # Check if target_instance_id is in related instances and has annotation - for related in related_instances: - if isinstance(related, dict): - related_id = related.get("instance_id") - if related_id == target_instance_id: - # Found relationship, check for annotation - alignment_ann_id = related.get("annotation") - if alignment_ann_id: - break - - # Step 2: If no annotation from related check, check alignment_targets field - if not alignment_ann_id: - alignment_targets = source_instance.get("alignment_targets", []) - if isinstance(alignment_targets, list) and target_instance_id in alignment_targets: - # Check source annotations for alignment type - source_annotations = source_instance.get("annotations", []) - for ann in source_annotations: - if ann.get("type") == "alignment": - alignment_ann_id = ann.get("annotation_id") + except HTTPException: + related_instances = None + + alignment_ann_id = None + if related_instances is not None: + if isinstance(related_instances, dict) and "results" in related_instances: + related_instances = related_instances["results"] + + for related in related_instances: + if isinstance(related, dict): + related_id = related.get("instance_id") + if related_id == target_instance_id: + alignment_ann_id = related.get("annotation") + if alignment_ann_id: break - - # Step 3: If still no alignment annotation ID, check annotations directly - if not alignment_ann_id: + + # Step 2: If no annotation from related check, check alignment_targets field + if not alignment_ann_id: + alignment_targets = source_instance.get("alignment_targets", []) + if isinstance(alignment_targets, list) and target_instance_id in alignment_targets: source_annotations = source_instance.get("annotations", []) for ann in source_annotations: if ann.get("type") == "alignment": alignment_ann_id = ann.get("annotation_id") break - - # Step 4: Fetch alignment annotation if found - if alignment_ann_id: - alignment_response = requests.get( - f"{API_ENDPOINT}/annotations/{alignment_ann_id}", - timeout=30 - ) - if alignment_response.status_code == 200: - alignment_data = alignment_response.json() - annotation_data = alignment_data.get("data") - if ( - annotation_data - and isinstance(annotation_data.get("target_annotation"), list) - and isinstance(annotation_data.get("alignment_annotation"), list) - and len(annotation_data.get("target_annotation", [])) > 0 - and len(annotation_data.get("alignment_annotation", [])) > 0 - ): - has_alignment = True - - # Step 5: Fetch segmentation annotations if no alignment exists - source_segmentation_data = None - target_segmentation_data = None - - if not has_alignment: - # Find segmentation annotation for source - source_annotations = source_instance.get("annotations", []) - source_seg_ann_ref = None - for ann in source_annotations: - if ann.get("type") == "segmentation": - source_seg_ann_ref = ann - break - - if source_seg_ann_ref: - source_seg_ann_id = source_seg_ann_ref.get("annotation_id") - if source_seg_ann_id: - source_seg_response = requests.get( - f"{API_ENDPOINT}/annotations/{source_seg_ann_id}", - timeout=30 + + # Step 3: If still no alignment annotation ID, check annotations directly + if not alignment_ann_id: + source_annotations = source_instance.get("annotations", []) + for ann in source_annotations: + if ann.get("type") == "alignment": + alignment_ann_id = ann.get("annotation_id") + break + + # Step 4: Fetch alignment annotation if found + if alignment_ann_id: + try: + alignment_data = openpecha_get_annotation(alignment_ann_id, timeout=30) + except HTTPException: + alignment_data = None + if alignment_data is not None: + annotation_data = alignment_data.get("data") + if ( + annotation_data + and isinstance(annotation_data.get("target_annotation"), list) + and isinstance(annotation_data.get("alignment_annotation"), list) + and len(annotation_data.get("target_annotation", [])) > 0 + and len(annotation_data.get("alignment_annotation", [])) > 0 + ): + has_alignment = True + + # Step 5: Fetch segmentation annotations if no alignment exists + source_segmentation_data = None + target_segmentation_data = None + + if not has_alignment: + source_annotations = source_instance.get("annotations", []) + source_seg_ann_ref = None + for ann in source_annotations: + if ann.get("type") == "segmentation": + source_seg_ann_ref = ann + break + + if source_seg_ann_ref: + source_seg_ann_id = source_seg_ann_ref.get("annotation_id") + if source_seg_ann_id: + try: + source_seg_data = openpecha_get_annotation( + source_seg_ann_id, timeout=30 ) - if source_seg_response.status_code == 200: - source_seg_data = source_seg_response.json() - source_segmentation_data = source_seg_data.get("data") - - # Find segmentation annotation for target - target_annotations = target_instance.get("annotations", []) - target_seg_ann_ref = None - for ann in target_annotations: - if ann.get("type") == "segmentation": - target_seg_ann_ref = ann - break - - if target_seg_ann_ref: - target_seg_ann_id = target_seg_ann_ref.get("annotation_id") - if target_seg_ann_id: - target_seg_response = requests.get( - f"{API_ENDPOINT}/annotations/{target_seg_ann_id}", - timeout=30 + except HTTPException: + source_seg_data = None + if source_seg_data is not None: + source_segmentation_data = source_seg_data.get("data") + + target_annotations = target_instance.get("annotations", []) + target_seg_ann_ref = None + for ann in target_annotations: + if ann.get("type") == "segmentation": + target_seg_ann_ref = ann + break + + if target_seg_ann_ref: + target_seg_ann_id = target_seg_ann_ref.get("annotation_id") + if target_seg_ann_id: + try: + target_seg_data = openpecha_get_annotation( + target_seg_ann_id, timeout=30 ) - if target_seg_response.status_code == 200: - target_seg_data = target_seg_response.json() - target_segmentation_data = target_seg_data.get("data") - - return { - "source_text": source_text, - "target_text": target_text, - "has_alignment": has_alignment, - "annotation_id": alignment_ann_id, - "annotation": annotation_data, - "source_segmentation_data": source_segmentation_data, - "target_segmentation_data": target_segmentation_data, - } - - except HTTPException: - raise - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) + except HTTPException: + target_seg_data = None + if target_seg_data is not None: + target_segmentation_data = target_seg_data.get("data") + + return { + "source_text": source_text, + "target_text": target_text, + "has_alignment": has_alignment, + "annotation_id": alignment_ann_id, + "annotation": annotation_data, + "source_segmentation_data": source_segmentation_data, + "target_segmentation_data": target_segmentation_data, + } @router.get("/prepare-alignment-data/{source_instance_id}/{target_instance_id}") @@ -278,12 +256,6 @@ async def prepare_alignment_data( This endpoint replicates the logic from the frontend useEffect hook that loads texts from URL parameters and prepares them for display. """ - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - try: # Prepare data (fetch instances, annotations, etc.) prepared_data = prepare_data(source_instance_id, target_instance_id) diff --git a/backend/cataloger/routers/annotation.py b/backend/cataloger/routers/annotation.py index 36fe2a78..5f2697d3 100644 --- a/backend/cataloger/routers/annotation.py +++ b/backend/cataloger/routers/annotation.py @@ -1,17 +1,16 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from typing import List, Optional, Union, Literal -import requests -import os from fast_antx.core import transfer -from dotenv import load_dotenv -load_dotenv(override=True) +from cataloger.controller.openpecha_api.annotations import ( + create_annotation_for_instance as openpecha_create_annotation_for_instance, + get_annotation as openpecha_get_annotation, + update_annotation_body as openpecha_update_annotation_body, +) router = APIRouter() -API_ENDPOINT = os.getenv("OPENPECHA_ENDPOINT") - class Span(BaseModel): start: int @@ -124,10 +123,7 @@ class AnnotationResponse(BaseModel): @router.get("/{annotation_id}") async def get_annotation(annotation_id: str): """Get annotation by ID""" - response = requests.get(f"{API_ENDPOINT}/annotations/{annotation_id}") - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() + return openpecha_get_annotation(annotation_id) @@ -135,59 +131,24 @@ async def get_annotation(annotation_id: str): @router.put("/{annotation_id}/annotation") async def update_annotation(annotation_id: str, annotation: UpdateAnnotation): """Update an annotation by ID""" - response = requests.put( - f"{API_ENDPOINT}/annotations/{annotation_id}/annotation", - json=annotation.dict() - ) - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() + return openpecha_update_annotation_body(annotation_id, annotation.dict()) @router.post("/{instance_id}/annotation") async def create_annotation(instance_id: str, annotation: CreateAnnotation): """Create an annotation for a specific instance""" - response = requests.post( - f"{API_ENDPOINT}/annotations/{instance_id}/annotation", - json=annotation.dict() - ) - if response.status_code != 201: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() + return openpecha_create_annotation_for_instance(instance_id, annotation.dict()) @router.post("/clean-annotation", status_code=201) async def clean_annotation(request: CleanAnnotationRequest): - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - + text_content = request.text.replace("\n", "") try: - # function takes text, samplet text - annotation_list = [] - text_content = request.text.replace('\n', '') - # use the annotation from sample text to generate the annotation for the new text - # sample text is the text thats being used to get the line breaks, base text is the text thats being annotated - try: - annotation_list = generate_clean_annotation(text_content, request.sample_text) - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Error generating clean annotation: {str(e)}" - ) - # return the annoation list - return annotation_list - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: + return generate_clean_annotation(text_content, request.sample_text) + except Exception as e: raise HTTPException( status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" + detail=f"Error generating clean annotation: {str(e)}", ) diff --git a/backend/cataloger/routers/category.py b/backend/cataloger/routers/category.py index 6120bda5..a7cc147a 100644 --- a/backend/cataloger/routers/category.py +++ b/backend/cataloger/routers/category.py @@ -1,16 +1,14 @@ -from fastapi import APIRouter, HTTPException, Query, Body +from fastapi import APIRouter, Query, Body from pydantic import BaseModel from typing import List, Optional -import requests -import os -from dotenv import load_dotenv -load_dotenv(override=True) +from cataloger.controller.openpecha_api.categories import ( + create_category as openpecha_create_category, + list_categories as openpecha_list_categories, +) router = APIRouter() -API_ENDPOINT = os.getenv("OPENPECHA_ENDPOINT") - class Category(BaseModel): id: str @@ -23,98 +21,41 @@ class Category(BaseModel): async def get_categories( application: Optional[str] = Query(None, description="Application filter (e.g., webuddhist)"), language: Optional[str] = Query(None, description="Language filter (e.g., bo, en)"), - parent_id: Optional[str] = Query(None, description="Parent category ID for subcategories") + parent_id: Optional[str] = Query(None, description="Parent category ID for subcategories"), ): """ Get categories with optional filters for application, language, and parent category. - + - **application**: Filter by application (e.g., "webuddhist") - **language**: Filter by language (e.g., "bo" for Tibetan, "en" for English) - **parent_id**: Get subcategories of a specific parent category """ - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - # Build query parameters - params = {} - if application: - params["application"] = application - if language: - params["language"] = language - if parent_id: - params["parent_id"] = parent_id - - url = f"{API_ENDPOINT}/categories" - response = requests.get(url, params=params, timeout=30) - - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - - return response.json() - - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=502, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Internal server error: {str(e)}" - ) + return openpecha_list_categories( + application=application, + language=language, + parent_id=parent_id, + ) @router.post("", tags=["categories"]) async def create_category( - application: str = Body(..., embed=True, description="Application identifier"), - title: dict = Body(..., embed=True, description="Title in different languages, e.g. {'en': 'Literature', 'bo': 'རྩོམ་རིག', 'zh': ''}"), - parent: Optional[str] = Body(None, embed=True, description="Parent category ID, or null for root category"), - ): - """ - Create a new category in the OpenPecha API. - - - **application**: Application identifier (e.g., "webuddhist") - - **title**: Dictionary of translations for the category title (e.g., {"en": "Literature", "bo": "..."} ) - - **parent**: Optional. Parent category ID, or null for root category. - """ - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) + application: str = Body(..., embed=True, description="Application identifier"), + title: dict = Body( + ..., + embed=True, + description="Title in different languages, e.g. {'en': 'Literature', 'bo': 'རྩོམ་རིག', 'zh': ''}", + ), + parent: Optional[str] = Body( + None, + embed=True, + description="Parent category ID, or null for root category", + ), +): + """ + Create a new category in the OpenPecha API. - try: - payload = { - "application": application, - "title": title, - "parent": parent, - } - url = f"{API_ENDPOINT}/categories" - response = requests.post(url, json=payload, timeout=30) - if response.status_code not in (200, 201): - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=502, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Internal server error: {str(e)}" - ) + - **application**: Application identifier (e.g., "webuddhist") + - **title**: Dictionary of translations for the category title (e.g., {"en": "Literature", "bo": "..."} ) + - **parent**: Optional. Parent category ID, or null for root category. + """ + return openpecha_create_category(application=application, title=title, parent=parent) diff --git a/backend/cataloger/routers/person.py b/backend/cataloger/routers/person.py index b916a4bb..865c8cc2 100644 --- a/backend/cataloger/routers/person.py +++ b/backend/cataloger/routers/person.py @@ -1,20 +1,21 @@ -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter from pydantic import BaseModel, Field -from typing import List, Optional, Dict -import requests -import os -from dotenv import load_dotenv +from typing import List, Optional -load_dotenv() +from cataloger.controller.openpecha_api.persons import ( + create_person as openpecha_create_person, + get_person as openpecha_get_person, + list_persons as openpecha_list_persons, +) router = APIRouter() -API_ENDPOINT = os.getenv("OPENPECHA_ENDPOINT") class PersonName(BaseModel): en: Optional[str] = None bo: Optional[str] = None + class AltNames(BaseModel): en: Optional[str] = None bo: Optional[str] = None @@ -29,22 +30,25 @@ class Person(BaseModel): created_at: Optional[str] = None updated_at: Optional[str] = None + class PersonResponse(BaseModel): results: List[Person] count: int limit: int offset: int + class CreatePerson(BaseModel): name: PersonName alt_names: List[AltNames] = [] bdrc: Optional[str] = "" wiki: Optional[str] = "" + class CreatePersonResponse(BaseModel): message: str id: str = Field(alias="_id") - + class Config: populate_by_name = True @@ -54,28 +58,15 @@ async def get_persons( limit: int = 100, offset: int = 0, ): - params = { - "limit": limit, - "offset": offset, - } - - response = requests.get(f"{API_ENDPOINT}/persons", params=params) - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() + return openpecha_list_persons(limit=limit, offset=offset) + @router.get("/{id}", response_model=Person) async def get_person(id: str): - response = requests.get(f"{API_ENDPOINT}/persons/{id}") - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() + return openpecha_get_person(id) + @router.post("", response_model=CreatePersonResponse, status_code=201) async def create_person(person: CreatePerson): - # Convert to dict, excluding None values payload = person.model_dump(exclude_none=True) - response = requests.post(f"{API_ENDPOINT}/persons", json=payload) - if response.status_code != 201: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() + return openpecha_create_person(payload) diff --git a/backend/cataloger/routers/segments.py b/backend/cataloger/routers/segments.py index 090a5419..8ebd236f 100644 --- a/backend/cataloger/routers/segments.py +++ b/backend/cataloger/routers/segments.py @@ -1,15 +1,12 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter from pydantic import BaseModel -import requests -import os -from dotenv import load_dotenv -load_dotenv(override=True) +from cataloger.controller.openpecha_api.segments import ( + update_segment_content as openpecha_update_segment_content, +) router = APIRouter() -API_ENDPOINT = os.getenv("OPENPECHA_ENDPOINT") - class UpdateSegmentContentRequest(BaseModel): content: str @@ -18,33 +15,4 @@ class UpdateSegmentContentRequest(BaseModel): @router.put("/{segment_id}/content") async def update_segment_content(segment_id: str, request: UpdateSegmentContentRequest): """Update segment content by segment ID""" - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - url = f"{API_ENDPOINT}/segments/{segment_id}/content" - headers = { - "accept": "application/json", - "Content-Type": "application/json" - } - try: - response = requests.put( - url, - headers=headers, - json={"content": request.content}, - timeout=30 - ) - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) + return openpecha_update_segment_content(segment_id, request.content) diff --git a/backend/cataloger/routers/text.py b/backend/cataloger/routers/text.py index bdccea77..b1e1d219 100644 --- a/backend/cataloger/routers/text.py +++ b/backend/cataloger/routers/text.py @@ -1,16 +1,26 @@ -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Query from pydantic import BaseModel, Field from typing import List, Optional, Dict, Any, Literal -import requests import os from dotenv import load_dotenv - -load_dotenv( override=True) +from cataloger.controller.openpecha_api.instances import ( + get_instance as openpecha_get_instance, + update_instance as openpecha_update_instance, +) +from cataloger.controller.openpecha_api.texts import ( + create_instance_for_text as openpecha_create_instance_for_text, + create_text as openpecha_create_text, + get_text as openpecha_get_text, + list_instances_for_text as openpecha_list_instances_for_text, + list_texts as openpecha_list_texts, + update_text as openpecha_update_text, +) + +load_dotenv(override=True) router = APIRouter() -API_ENDPOINT = os.getenv("OPENPECHA_ENDPOINT") TRANSLATION_BACKEND_URL = os.getenv("TRANSLATION_BACKEND_URL") class Contribution(BaseModel): @@ -34,6 +44,7 @@ class Text(BaseModel): date: Optional[str] = None bdrc: Optional[str] = None wiki: Optional[str] = None + category_id: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None @@ -169,236 +180,53 @@ async def get_texts( ), title: Optional[str] = None, ): - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - params = { - "limit": limit, - "offset": offset, - "language": language, - "author": author, - "type": type, - "title": title, - } - params = {k: v for k, v in params.items() if v is not None} - - url = f"{API_ENDPOINT}/texts" - response = requests.get(url, params=params) - - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) + return openpecha_list_texts( + limit=limit, + offset=offset, + language=language, + author=author, + text_type=type, + title=title, + ) @router.post("", status_code=201) async def create_text(text: CreateText): - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - # Convert to dict, excluding None values - payload = text.model_dump(exclude_none=True) - response = requests.post(f"{API_ENDPOINT}/texts", json=payload) - - if response.status_code != 201: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) + payload = text.model_dump(exclude_none=True) + return openpecha_create_text(payload) @router.get("/{id}", response_model=Text) async def get_text(id: str): - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - response = requests.get(f"{API_ENDPOINT}/texts/{id}") - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) + return openpecha_get_text(id) @router.put("/{id}") async def update_text(id: str, text: UpdateText): - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - response = requests.put(f"{API_ENDPOINT}/texts/{id}", json=text.model_dump(exclude_none=True)) - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) + return openpecha_update_text(id, text.model_dump(exclude_none=True)) @router.get("/{id}/instances") async def get_instances(id: str): - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - url = f"{API_ENDPOINT}/texts/{id}/instances" - response = requests.get(url) - - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - - data = response.json() - - # Handle both array and {results: [...], count: ...} formats - if isinstance(data, dict) and "results" in data: - return data["results"] - elif isinstance(data, list): - return data - else: - # If it's neither format, return empty list - return [] - except HTTPException: - raise - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) + data = openpecha_list_instances_for_text(id) + if isinstance(data, dict) and "results" in data: + return data["results"] + if isinstance(data, list): + return data + return [] @router.post("/{id}/instances", status_code=201) async def create_instance(id: str, instance: CreateInstance): - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - payload = instance.model_dump(exclude_none=True) - - - payload.pop("user", None) - response = requests.post(f"{API_ENDPOINT}/texts/{id}/instances", json=payload,timeout=120) - - if response.status_code != 201: - raise HTTPException(status_code=response.status_code, detail=response.text) - - - - return response.json() - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) + payload = instance.model_dump(exclude_none=True) + payload.pop("user", None) + return openpecha_create_instance_for_text(id, payload) @router.put("/instances/{instance_id}", status_code=200) async def update_instance(instance_id: str, instance: UpdateInstance): - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - payload = instance.model_dump(exclude_none=True) - response = requests.put(f"{API_ENDPOINT}/instances/{instance_id}", json=payload) - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) + payload = instance.model_dump(exclude_none=True) + return openpecha_update_instance(instance_id, payload) @router.get("/instances/{instance_id}") async def get_instance(instance_id: str, annotation: bool = True): - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - params = {"annotation": str(annotation).lower(),"content": True} - response = requests.get(f"{API_ENDPOINT}/instances/{instance_id}", params=params) - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) + return openpecha_get_instance(instance_id, annotation=annotation, content=True) diff --git a/backend/cataloger/routers/tokenize.py b/backend/cataloger/routers/tokenize.py index 0ce813f8..eb31ec1c 100644 --- a/backend/cataloger/routers/tokenize.py +++ b/backend/cataloger/routers/tokenize.py @@ -1,17 +1,12 @@ from typing_extensions import Literal -from fastapi import APIRouter, HTTPException, BackgroundTasks +from fastapi import APIRouter, BackgroundTasks from pydantic import BaseModel -from dotenv import load_dotenv -import os -import requests from botok.tokenizers.sentencetokenizer import sentence_tokenizer from botok.tokenizers.wordtokenizer import WordTokenizer from botok.config import Config from pathlib import Path -load_dotenv(override=True) router = APIRouter() -API_ENDPOINT = os.getenv("OPENPECHA_ENDPOINT") class TokenizeRequest(BaseModel): @@ -21,21 +16,8 @@ class TokenizeRequest(BaseModel): @router.post("") async def tokenize(request: TokenizeRequest): - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - tokenized_text = tokenize_text(request.text, request.type) - return tokenized_text - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - + return tokenize_text(request.text, request.type) + config = Config(dialect_name="general", base_path=Path("config_botok")) diff --git a/backend/cataloger/routers/translation.py b/backend/cataloger/routers/translation.py index df07ed6a..4fd86a38 100644 --- a/backend/cataloger/routers/translation.py +++ b/backend/cataloger/routers/translation.py @@ -1,16 +1,22 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter from pydantic import BaseModel, Field from typing import List, Optional, Dict, Any -import requests import os from dotenv import load_dotenv +from cataloger.controller.openpecha_api.instances import ( + create_commentary as openpecha_create_commentary, + create_translation as openpecha_create_translation, + list_related_instances as openpecha_list_related_instances, +) +from cataloger.controller.openpecha_api.texts import ( + list_instances_for_text as openpecha_list_instances_for_text, +) load_dotenv(override=True) router = APIRouter() -API_ENDPOINT = os.getenv("OPENPECHA_ENDPOINT") TRANSLATION_BACKEND_URL = os.getenv("TRANSLATION_BACKEND_URL") @@ -122,103 +128,34 @@ class RelatedInstance(BaseModel): @router.get("/{text_id}/instances") async def get_text_instances(text_id: str): """Get all instances for a specific text""" - response = requests.get(f"{API_ENDPOINT}/texts/{text_id}/instances") - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() + return openpecha_list_instances_for_text(text_id) @router.post("/{instance_id}/translation", status_code=201) async def create_translation(instance_id: str, translation: CreateTranslation): """Create a translation for a specific instance""" - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - payload = translation.model_dump(exclude_none=True) - payload.pop("user", None) - - response = requests.post( - f"{API_ENDPOINT}/instances/{instance_id}/translation", - json=payload, - timeout=30 - ) - - if response.status_code != 201: - raise HTTPException(status_code=response.status_code, detail=response.text) - - response_data = response.json() - - - return response_data - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) + payload = translation.model_dump(exclude_none=True) + payload.pop("user", None) + return openpecha_create_translation(instance_id, payload) @router.post("/{instance_id}/commentary", status_code=201) async def create_commentary(instance_id: str, commentary: CreateCommentary): """Create a commentary for a specific instance""" - if not API_ENDPOINT: - raise HTTPException( - status_code=500, - detail="OPENPECHA_ENDPOINT environment variable is not set" - ) - - try: - # Convert to dict, excluding None values - payload = commentary.model_dump(exclude_none=True) - # Extract user before sending to OpenPecha API (user is only for our backend) - user = payload.pop("user", None) - response = requests.post( - f"{API_ENDPOINT}/instances/{instance_id}/commentary", - json=payload, - timeout=30 - ) - if response.status_code != 201: - raise HTTPException(status_code=response.status_code, detail=response.text) - - response_data = response.json() - - return response_data - except requests.exceptions.Timeout: - raise HTTPException( - status_code=504, - detail="Request to OpenPecha API timed out after 30 seconds" - ) - except requests.exceptions.RequestException as e: - raise HTTPException( - status_code=500, - detail=f"Error connecting to OpenPecha API: {str(e)}" - ) + payload = commentary.model_dump(exclude_none=True) + payload.pop("user", None) + return openpecha_create_commentary(instance_id, payload) @router.get("/{instance_id}/related") async def get_related_instances(instance_id: str, type: Optional[str] = None): """Get all instances related to a specific instance - + Args: instance_id: The ID of the instance to get related instances for type: Optional filter by relationship type (root, commentary, translation) """ - params = {} - if type: - params["type"] = type - - response = requests.get(f"{API_ENDPOINT}/instances/{instance_id}/related", params=params) - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) - return response.json() + return openpecha_list_related_instances(instance_id, relationship_type=type) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 219a5759..53e49f8d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,7 +8,6 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') const serverUrl = env.VITE_SERVER_URL || 'http://localhost:8000' - const openpechaUrl =env.VITE_OPENPECHA_URL || "" const bdrcServerUrl = env.VITE_BDRC_BACKEND_URL || 'http://localhost:8000' return { @@ -41,12 +40,6 @@ export default defineConfig(({ mode }) => { changeOrigin: true, secure: false, rewrite: (path) => path.replace(/^\/bdrc\/api/, ''), - }, - '/openpecha/api':{ - target: openpechaUrl, - changeOrigin: true, - secure: false, - rewrite: (path) => path.replace(/^\/openpecha\/api/, ''), } }, }, From 0a046f686f5d38b0c34d72cebff2a50fa53509ba Mon Sep 17 00:00:00 2001 From: tenkus47 Date: Fri, 10 Apr 2026 20:17:44 +0530 Subject: [PATCH 02/14] refactor: update category and text handling in backend and frontend - Introduced new language and role routers in the backend to enhance API capabilities. - Removed deprecated enum router and related functions to streamline the codebase. - Updated text fetching logic to include category filtering, improving data retrieval efficiency. - Refactored frontend components to integrate category selection and improve user experience. - Cleaned up unused imports and variables across various files for better maintainability. --- backend/cataloger/controller/enum.py | 7 -- .../controller/openpecha_api/categories.py | 2 - .../controller/openpecha_api/language.py | 34 +++++++++ .../controller/openpecha_api/roles.py | 33 +++++++++ .../controller/openpecha_api/texts.py | 7 +- .../controller/openpecha_api/v2_adapters.py | 8 +-- backend/cataloger/routers/category.py | 7 +- backend/cataloger/routers/enum.py | 13 ---- backend/cataloger/routers/language.py | 9 +++ backend/cataloger/routers/role.py | 10 +++ backend/cataloger/routers/text.py | 7 +- backend/main.py | 5 +- frontend/src/api/category.ts | 25 ++++--- frontend/src/api/language.ts | 35 ++++++++++ frontend/src/api/role.ts | 35 ++++++++++ frontend/src/api/texts.ts | 4 +- .../components/MultilevelCategorySelector.tsx | 27 +++++-- frontend/src/components/TextFilter.tsx | 62 ++++++++-------- frontend/src/components/TextList.tsx | 3 +- frontend/src/components/TextListCard.tsx | 49 ++----------- frontend/src/hooks/useCategories.ts | 23 +++--- frontend/src/hooks/useEnum.ts | 16 ++--- frontend/src/hooks/useTexts.ts | 2 +- frontend/src/pages/Text.tsx | 21 +++--- frontend/src/types/text.ts | 23 +++--- .../src/utils/normalizeOpenpechaCategories.ts | 70 +++++++++++++++++++ 26 files changed, 359 insertions(+), 178 deletions(-) delete mode 100644 backend/cataloger/controller/enum.py create mode 100644 backend/cataloger/controller/openpecha_api/language.py create mode 100644 backend/cataloger/controller/openpecha_api/roles.py delete mode 100644 backend/cataloger/routers/enum.py create mode 100644 backend/cataloger/routers/language.py create mode 100644 backend/cataloger/routers/role.py create mode 100644 frontend/src/api/language.ts create mode 100644 frontend/src/api/role.ts create mode 100644 frontend/src/utils/normalizeOpenpechaCategories.ts diff --git a/backend/cataloger/controller/enum.py b/backend/cataloger/controller/enum.py deleted file mode 100644 index eb57f3bb..00000000 --- a/backend/cataloger/controller/enum.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Re-export enum OpenPecha client for backward compatibility.""" - -from cataloger.controller.openpecha_api import enums - - -def get_enum(type: str): - return enums.get_enum(type) diff --git a/backend/cataloger/controller/openpecha_api/categories.py b/backend/cataloger/controller/openpecha_api/categories.py index 8752350f..a1f1a20d 100644 --- a/backend/cataloger/controller/openpecha_api/categories.py +++ b/backend/cataloger/controller/openpecha_api/categories.py @@ -57,8 +57,6 @@ def list_categories( if response.status_code != 200: raise HTTPException(status_code=response.status_code, detail=response.text) raw = response.json() - if isinstance(raw, list): - return [_category_v2_to_legacy_row(x) for x in raw if isinstance(x, dict)] return raw except requests.exceptions.Timeout: raise HTTPException( diff --git a/backend/cataloger/controller/openpecha_api/language.py b/backend/cataloger/controller/openpecha_api/language.py new file mode 100644 index 00000000..e42e0468 --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/language.py @@ -0,0 +1,34 @@ +from typing import Any + +import requests +from fastapi import HTTPException + +from cataloger.controller.openpecha_api.base import ( + openpecha_headers, + openpecha_url, +) + + +def get_language() -> Any: + + url = openpecha_url("languages") + headers = openpecha_headers() + try: + response = requests.get(url, headers=headers, timeout=30) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + return response.json() + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to language API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=502, + detail=f"Error connecting to language API: {str(e)}", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") diff --git a/backend/cataloger/controller/openpecha_api/roles.py b/backend/cataloger/controller/openpecha_api/roles.py new file mode 100644 index 00000000..1048cbd2 --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/roles.py @@ -0,0 +1,33 @@ +from typing import Any + +import requests +from fastapi import HTTPException + +from cataloger.controller.openpecha_api.base import ( + openpecha_headers, + openpecha_url, +) + + +def get_roles() -> Any: + url = openpecha_url("roles") + headers = openpecha_headers() + try: + response = requests.get(url, headers=headers, timeout=30) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + return response.json() + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to roles API timed out after 30 seconds", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=502, + detail=f"Error connecting to roles API: {str(e)}", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") diff --git a/backend/cataloger/controller/openpecha_api/texts.py b/backend/cataloger/controller/openpecha_api/texts.py index 479c70f0..4c37283e 100644 --- a/backend/cataloger/controller/openpecha_api/texts.py +++ b/backend/cataloger/controller/openpecha_api/texts.py @@ -26,7 +26,6 @@ def list_texts( offset: int = 0, language: Optional[str] = None, author: Optional[str] = None, - text_type: Optional[str] = None, title: Optional[str] = None, category_id: Optional[str] = None, x_application: Optional[str] = None, @@ -46,11 +45,7 @@ def list_texts( if response.status_code != 200: raise HTTPException(status_code=response.status_code, detail=response.text) raw = response.json() - wrapped = wrap_text_list(raw, limit=limit, offset=offset) - if text_type and isinstance(wrapped.get("results"), list): - wrapped["results"] = [r for r in wrapped["results"] if r.get("type") == text_type] - wrapped["count"] = len(wrapped["results"]) - return wrapped + return raw except requests.exceptions.Timeout: raise HTTPException( status_code=504, diff --git a/backend/cataloger/controller/openpecha_api/v2_adapters.py b/backend/cataloger/controller/openpecha_api/v2_adapters.py index 81cf12ad..2591ffdd 100644 --- a/backend/cataloger/controller/openpecha_api/v2_adapters.py +++ b/backend/cataloger/controller/openpecha_api/v2_adapters.py @@ -26,12 +26,7 @@ def normalize_license(value: Optional[str]) -> str: return LICENSE_ALIASES.get(k, "unknown") -def infer_text_type(text_out: Dict[str, Any]) -> str: - if text_out.get("translation_of"): - return "translation" - if text_out.get("commentary_of"): - return "commentary" - return "root" + def text_output_to_legacy(text_out: Dict[str, Any]) -> Dict[str, Any]: @@ -39,7 +34,6 @@ def text_output_to_legacy(text_out: Dict[str, Any]) -> Dict[str, Any]: target = text_out.get("translation_of") or text_out.get("commentary_of") return { "id": text_out["id"], - "type": infer_text_type(text_out), "title": text_out.get("title") or {}, "language": text_out.get("language", ""), "target": target, diff --git a/backend/cataloger/routers/category.py b/backend/cataloger/routers/category.py index a7cc147a..f4eccaee 100644 --- a/backend/cataloger/routers/category.py +++ b/backend/cataloger/routers/category.py @@ -10,14 +10,9 @@ router = APIRouter() -class Category(BaseModel): - id: str - parent: Optional[str] = None - title: str - has_child: bool -@router.get("", response_model=List[Category]) +@router.get("") async def get_categories( application: Optional[str] = Query(None, description="Application filter (e.g., webuddhist)"), language: Optional[str] = Query(None, description="Language filter (e.g., bo, en)"), diff --git a/backend/cataloger/routers/enum.py b/backend/cataloger/routers/enum.py deleted file mode 100644 index db6cfe48..00000000 --- a/backend/cataloger/routers/enum.py +++ /dev/null @@ -1,13 +0,0 @@ -from fastapi import APIRouter, Query -from dotenv import load_dotenv -from cataloger.controller.enum import get_enum -load_dotenv(override=True) - -router = APIRouter() - -@router.get("") -def get_enum_router( - type: str = Query(..., description="Enum type (e.g., 'language', 'role')") -): - return get_enum(type) - diff --git a/backend/cataloger/routers/language.py b/backend/cataloger/routers/language.py new file mode 100644 index 00000000..39ea8cea --- /dev/null +++ b/backend/cataloger/routers/language.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter, HTTPException + +from cataloger.controller.openpecha_api.language import get_language + +router = APIRouter() + +@router.get("/languages") +async def languages(): + return get_language() \ No newline at end of file diff --git a/backend/cataloger/routers/role.py b/backend/cataloger/routers/role.py new file mode 100644 index 00000000..4c09edee --- /dev/null +++ b/backend/cataloger/routers/role.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from cataloger.controller.openpecha_api.roles import get_roles + +router = APIRouter() + + +@router.get("/roles") +async def roles(): + return get_roles() diff --git a/backend/cataloger/routers/text.py b/backend/cataloger/routers/text.py index b1e1d219..70f3386c 100644 --- a/backend/cataloger/routers/text.py +++ b/backend/cataloger/routers/text.py @@ -174,19 +174,16 @@ async def get_texts( offset: int = 0, language: Optional[str] = None, author: Optional[str] = None, - type: Optional[Literal["root", "commentary", "translation", "translation_source", "none"]] = Query( - None, - description="Filter by text type" - ), title: Optional[str] = None, + category_id: Optional[str] = Query(None, description="Filter by category id"), ): return openpecha_list_texts( limit=limit, offset=offset, language=language, author=author, - text_type=type, title=title, + category_id=category_id, ) @router.post("", status_code=201) diff --git a/backend/main.py b/backend/main.py index 52503475..5d0b7170 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.trustedhost import TrustedHostMiddleware import uvicorn -from cataloger.routers import ai, person, text, translation, annotation, bdrc, category, enum, tokenize, aligner_data, admin, segments +from cataloger.routers import ai, person, text, translation, annotation, bdrc, category, language, role, tokenize, aligner_data, admin, segments from outliner.routers import router as outliner_router from settings.routers import ( tenant_router, @@ -77,7 +77,8 @@ async def dispatch(self, request: Request, call_next): app.include_router(annotation.router, prefix="/v2/annotations", tags=["annotation"]) app.include_router(segments.router, prefix="/segments", tags=["segments"]) app.include_router(category.router, prefix="/v2/categories", tags=["category"]) -app.include_router(enum.router, prefix="/v2/enum", tags=["enum"]) +app.include_router(language.router, prefix="/v2/language", tags=["language"]) +app.include_router(role.router, prefix="/v2/role", tags=["role"]) app.include_router(tokenize.router, prefix="/tokenize", tags=["tokenize"]) app.include_router(aligner_data.router, prefix="/aligner-data", tags=["aligner-data"]) app.include_router(admin.router, prefix="/admin", tags=["admin"]) diff --git a/frontend/src/api/category.ts b/frontend/src/api/category.ts index ecedd462..c5c0c9d9 100644 --- a/frontend/src/api/category.ts +++ b/frontend/src/api/category.ts @@ -1,24 +1,28 @@ -interface Category { - id: string; - parent: string | null; - title: string; - has_child: boolean; -} +import { + normalizeOpenpechaCategoryList, + type NormalizedCategory, +} from '@/utils/normalizeOpenpechaCategories'; + +type Category = NormalizedCategory; interface FetchCategoriesOptions { application?: string; language?: string; + parent_id?: string | null; } const API_URL = '/api'; export const fetchCategories = async (options: FetchCategoriesOptions = {}): Promise => { - const { application = 'webuddhist', language = 'bo' } = options; - + const { application = 'webuddhist', language = 'bo', parent_id } = options; + const queryParams = new URLSearchParams(); queryParams.append('application', application); queryParams.append('language', language); - + if (parent_id) { + queryParams.append('parent_id', parent_id); + } + const response = await fetch( `${API_URL}/v2/categories?${queryParams.toString()}`, { @@ -32,7 +36,8 @@ export const fetchCategories = async (options: FetchCategoriesOptions = {}): Pro throw new Error(`Failed to fetch categories: ${response.status} ${response.statusText}`); } - return response.json(); + const data: unknown = await response.json(); + return normalizeOpenpechaCategoryList(data, language); }; diff --git a/frontend/src/api/language.ts b/frontend/src/api/language.ts new file mode 100644 index 00000000..d3a1c1cc --- /dev/null +++ b/frontend/src/api/language.ts @@ -0,0 +1,35 @@ +import { API_URL } from '@/config/api'; + +export interface LanguageItem { + code: string; + name: string; +} + +export interface LanguagesResponse { + items: LanguageItem[]; +} + +export const fetchLanguage = async (): Promise => { + const response = await fetch(`${API_URL}/v2/language/languages`); + + if (!response.ok) { + const contentType = response.headers.get('content-type'); + let errorMessage = ''; + + if (contentType?.includes('application/json')) { + try { + const errorData = await response.json(); + errorMessage = + errorData.detail || errorData.message || errorData.error || ''; + } catch { + // ignore parse errors + } + } + + throw new Error( + errorMessage || `Unable to load languages (${response.status}).`, + ); + } + + return response.json(); +}; diff --git a/frontend/src/api/role.ts b/frontend/src/api/role.ts new file mode 100644 index 00000000..6ca2d117 --- /dev/null +++ b/frontend/src/api/role.ts @@ -0,0 +1,35 @@ +import { API_URL } from '@/config/api'; + +export interface RoleItem { + name: string; + description?: string; +} + +export interface RolesResponse { + items: RoleItem[]; +} + +export const fetchRole = async (): Promise => { + const response = await fetch(`${API_URL}/v2/role/roles`); + + if (!response.ok) { + const contentType = response.headers.get('content-type'); + let errorMessage = ''; + + if (contentType?.includes('application/json')) { + try { + const errorData = await response.json(); + errorMessage = + errorData.detail || errorData.message || errorData.error || ''; + } catch { + // ignore parse errors + } + } + + throw new Error( + errorMessage || `Unable to load roles (${response.status}).`, + ); + } + + return response.json(); +}; diff --git a/frontend/src/api/texts.ts b/frontend/src/api/texts.ts index 9ae14658..c1561c86 100644 --- a/frontend/src/api/texts.ts +++ b/frontend/src/api/texts.ts @@ -76,15 +76,15 @@ const handleApiResponse = async (response: Response, customMessages?: { 400?: st }; // Real API function for Texts -export const fetchTexts = async (params?: { limit?: number; offset?: number; language?: string; author?: string; type?: string; title?: string }, signal?: AbortSignal): Promise => { +export const fetchTexts = async (params?: { limit?: number; offset?: number; language?: string; author?: string; type?: string; title?: string; category_id?: string }, signal?: AbortSignal): Promise => { const queryParams = new URLSearchParams(); if (params?.limit) queryParams.append('limit', params.limit.toString()); if (params?.offset) queryParams.append('offset', params.offset.toString()); if (params?.language) queryParams.append('language', params.language); if (params?.author) queryParams.append('author', params.author); - if (params?.type && params.type !== 'none') queryParams.append('type', params.type); if (params?.title) queryParams.append('title', params.title); + if (params?.category_id) queryParams.append('category_id', params.category_id); const queryString = queryParams.toString(); const url = queryString ? `${API_URL}/text?${queryString}` : `${API_URL}/text`; diff --git a/frontend/src/components/MultilevelCategorySelector.tsx b/frontend/src/components/MultilevelCategorySelector.tsx index a30b5d95..8f3bf2ed 100644 --- a/frontend/src/components/MultilevelCategorySelector.tsx +++ b/frontend/src/components/MultilevelCategorySelector.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useCategories } from '@/hooks/useCategories'; import type { Category } from '@/hooks/useCategories'; import { ChevronRight, Loader2, Check, Home } from 'lucide-react'; @@ -15,10 +15,14 @@ interface MultilevelCategorySelectorProps { onCategorySelect: (categoryId: string, path: CategoryLevel[]) => void; selectedCategoryId?: string; error?: boolean; + /** Omit required marker and create button (e.g. text list filters) */ + filterMode?: boolean; } export const MultilevelCategorySelector: React.FC = ({ onCategorySelect, + selectedCategoryId, + filterMode = false, }) => { const { t } = useTranslation(); const [navigationPath, setNavigationPath] = useState([]); @@ -28,6 +32,15 @@ export const MultilevelCategorySelector: React.FC(null); const { categories, isLoading, error } = useCategories(currentParentId); + + useEffect(() => { + if (!selectedCategoryId) { + setNavigationPath([]); + setCurrentParentId(null); + setSelectedCategory(null); + } + }, [selectedCategoryId]); + // Handle category badge click const handleCategoryClick = (category: Category) => { if (category.has_child) { @@ -69,15 +82,15 @@ export const MultilevelCategorySelector: React.FC
- + {!filterMode && } {/* Breadcrumb Navigation */} {navigationPath.length > 0 && ( @@ -106,9 +119,9 @@ export const MultilevelCategorySelector: React.FC +
{isLoading && (
diff --git a/frontend/src/components/TextFilter.tsx b/frontend/src/components/TextFilter.tsx index be37590f..47e54058 100644 --- a/frontend/src/components/TextFilter.tsx +++ b/frontend/src/components/TextFilter.tsx @@ -11,12 +11,14 @@ import { } from "@/components/ui/select"; import { getLanguageLabel } from "@/utils/getLanguageLabel"; import { Input } from './ui/input'; +import { MultilevelCategorySelector } from '@/components/MultilevelCategorySelector'; type Param = { title: string; - type: string; language: string; author: string; + categoryId: string; + categoryTitle: string; }; type PropTextFilter = { @@ -25,13 +27,6 @@ type PropTextFilter = { readonly clearSearch: () => void; }; -const TYPE_OPTIONS = [ - { value: "none", label: "All Types" }, - { value: "root", label: "Root" }, - { value: "commentary", label: "Commentary" }, - { value: "translation", label: "Translation" }, - { value: "translation_source", label: "Translation Source" }, -]; function TextFilter({ param, setParam, clearSearch }: PropTextFilter) { const { t } = useTranslation(); @@ -54,7 +49,10 @@ function TextFilter({ param, setParam, clearSearch }: PropTextFilter) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [textSearch]); - const hasActiveFilters = textSearch || param.language || (param.type && param.type !== "none"); + const hasActiveFilters = + textSearch || + param.language || + !!param.categoryId; const handleClearAll = () => { setTextSearch(""); @@ -104,26 +102,6 @@ function TextFilter({ param, setParam, clearSearch }: PropTextFilter) {
- - {/* Type Filter */} -
- -
- {/* Clear All Button */} {hasActiveFilters && ( )} - {param.type && param.type !== "none" && ( - - Type: {TYPE_OPTIONS.find(opt => opt.value === param.type)?.label || param.type} + + {param.categoryId && ( + + {t("category.category")}: {param.categoryTitle || param.categoryId} diff --git a/frontend/src/components/TextList.tsx b/frontend/src/components/TextList.tsx index 97dc27d9..b1701ac9 100644 --- a/frontend/src/components/TextList.tsx +++ b/frontend/src/components/TextList.tsx @@ -9,6 +9,7 @@ import { import TextListCard from './TextListCard'; const TextList = ({texts}: {texts: OpenPechaText[]}) => { + console.log(texts) return (
@@ -22,8 +23,8 @@ const TextList = ({texts}: {texts: OpenPechaText[]}) => { Text - Type Language + Commentaries/Translations Contributors diff --git a/frontend/src/components/TextListCard.tsx b/frontend/src/components/TextListCard.tsx index 503db43d..bed7bea0 100644 --- a/frontend/src/components/TextListCard.tsx +++ b/frontend/src/components/TextListCard.tsx @@ -1,17 +1,12 @@ import { Link } from 'react-router-dom'; import { useState } from 'react'; import { Book, Globe, Users, Loader2 } from 'lucide-react'; -import type { OpenPechaText } from '@/types/text'; import type { PersonName } from '@/types/person'; import { Badge } from './ui/badge'; import { TableRow, TableCell } from './ui/table'; import { useTranslation } from 'react-i18next'; import { getLanguageColor, getLanguageLabel } from '@/utils/getLanguageLabel'; - - - - -import { getBdrclink } from '@/lib/bdrclink'; +import type { OpenPechaText } from '@/types/text'; interface TextListCardProps { text: OpenPechaText; @@ -35,29 +30,8 @@ const TextListCard = ({ text }: TextListCardProps) => { return text.title?.[text.language] || t('textsPage.untitled'); } - const getTypeLabel = (type: string): string => { - if (type === "none") return ""; - const labels: Record = { - root: t('textsPage.rootText'), - translation: t('textsPage.translation'), - commentary: t('textsPage.commentary'), - translation_source:"Source", - none:"-" - }; - return labels[type] || type; - }; - + - - const getTypeColor = (type: string): string => { - const colors: Record = { - root: 'bg-purple-100 text-purple-800', - translation: 'bg-green-100 text-green-800', - commentary: 'bg-yellow-100 text-yellow-800', - translation_source: 'bg-blue-100 text-blue-800' - }; - return colors[type] || 'bg-gray-100 text-gray-800'; - }; return ( @@ -87,19 +61,6 @@ const TextListCard = ({ text }: TextListCardProps) => { )} - - - {/* Type Column */} - - - {getTypeLabel(text.type) && ( - - - {getTypeLabel(text.type)} - - )} - - {/* Language Column */} {text.language && ( @@ -109,7 +70,11 @@ const TextListCard = ({ text }: TextListCardProps) => { )} - + + {(text.translations.length>0 || text.commentaries.length>0) && "source"} + {text.translation_of && "Translation"} + {text.commentary_of && "Commentary"} + {/* Contributors Column */} {text.contributions && text.contributions.length > 0 ? ( diff --git a/frontend/src/hooks/useCategories.ts b/frontend/src/hooks/useCategories.ts index 6aceb557..e0e2a034 100644 --- a/frontend/src/hooks/useCategories.ts +++ b/frontend/src/hooks/useCategories.ts @@ -1,13 +1,12 @@ import { useQuery } from '@tanstack/react-query'; import { API_URL } from '@/config/api'; import { useTranslation } from 'react-i18next'; +import { + normalizeOpenpechaCategoryList, + type NormalizedCategory, +} from '@/utils/normalizeOpenpechaCategories'; -export interface Category { - id: string; - parent: string | null; - title: string; - has_child: boolean; -} +export type Category = NormalizedCategory; interface UseCategoriesResult { categories: Category[]; @@ -16,6 +15,13 @@ interface UseCategoriesResult { refetch: () => void; } +function queryErrorToMessage(err: unknown): string | null { + if (!err) return null; + if (err instanceof Error) return err.message; + if (typeof err === 'string') return err; + return 'An error occurred'; +} + // Fetch function for categories const fetchCategories = async (parentId: string | null, language: string = 'bo'): Promise => { const params = new URLSearchParams(); @@ -32,7 +38,8 @@ const fetchCategories = async (parentId: string | null, language: string = 'bo') throw new Error(`Failed to fetch categories: ${response.statusText}`); } - return response.json(); + const data: unknown = await response.json(); + return normalizeOpenpechaCategoryList(data, language); }; export const useCategories = (parentId: string | null = null): UseCategoriesResult => { @@ -54,7 +61,7 @@ export const useCategories = (parentId: string | null = null): UseCategoriesResu return { categories, isLoading, - error: error ? (error as Error).message : null, + error: queryErrorToMessage(error), refetch: () => { refetch(); }, diff --git a/frontend/src/hooks/useEnum.ts b/frontend/src/hooks/useEnum.ts index c4527be1..01c1c1c7 100644 --- a/frontend/src/hooks/useEnum.ts +++ b/frontend/src/hooks/useEnum.ts @@ -1,13 +1,11 @@ import { useQuery } from '@tanstack/react-query'; -import { fetchEnums } from '@/api/texts'; +import { fetchLanguage } from '@/api/language'; +import { fetchRole } from '@/api/role'; export const useLanguage = () => { return useQuery({ - queryKey: ['enums', 'language'], - queryFn: () => fetchEnums('language'), - select: (original) => { - return original.items - }, + queryKey: ['language', 'list'], + queryFn: () => fetchLanguage(), staleTime: 5 * 60 * 1000, // 5 minutes }); }; @@ -15,11 +13,11 @@ export const useLanguage = () => { export const useRole = () => { return useQuery({ - queryKey: ['enums', 'role'], - queryFn: () => fetchEnums('role'), + queryKey: ['role', 'list'], + queryFn: () => fetchRole(), select: (original) => { return original.items }, staleTime: 5 * 60 * 1000, // 5 minutes }); -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useTexts.ts b/frontend/src/hooks/useTexts.ts index 1173e9a7..51281548 100644 --- a/frontend/src/hooks/useTexts.ts +++ b/frontend/src/hooks/useTexts.ts @@ -21,8 +21,8 @@ export const useTexts = (params?: { offset?: number; language?: string; author?: string; - type?: "root" | "commentary" | "translation" | "translation_source" | "none"; title?: string; + category_id?: string; }) => { return useQuery({ diff --git a/frontend/src/pages/Text.tsx b/frontend/src/pages/Text.tsx index a23efc8e..3fb2cb96 100644 --- a/frontend/src/pages/Text.tsx +++ b/frontend/src/pages/Text.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { useTexts } from "@/hooks/useTexts"; import type { OpenPechaText } from "@/types/text"; import { Button } from "@/components/ui/button"; @@ -11,15 +11,15 @@ import { Skeleton } from "@/components/ui/skeleton"; const TextsPage = () => { const { t } = useTranslation(); - const navigate = useNavigate(); const [offset, setOffset] = useState(0); const LIMIT = 30; // Fixed limit of 30 const OFFSET_STEP = 30; // Offset increment/decrement step const [param, setParam] = useState({ title: "", - type: "none", language: "", - author: "" + author: "", + categoryId: "", + categoryTitle: "", }); // Search state const [foundText, setFoundText] = useState(null); @@ -32,11 +32,15 @@ const TextsPage = () => { title: param.title.trim() || undefined, language: param.language || undefined, author: param.author || undefined, - type: param.type as "root" | "commentary" | "translation" | "translation_source" | "none" | undefined - }), [offset, param.title, param.language, param.author, param.type]); + category_id: param.categoryId.trim() || undefined, + }), [offset, param.title, param.language, param.author, param.type, param.categoryId]); const { data: texts = [], isLoading, error, refetch } = useTexts(paginationParams); + useEffect(() => { + setOffset(0); + }, [param.categoryId]); + @@ -68,9 +72,10 @@ const TextsPage = () => { setFoundText(null); setParam({ title: "", - type: "none", language: "", - author: "" + author: "", + categoryId: "", + categoryTitle: "", }); setOffset(0); }; diff --git a/frontend/src/types/text.ts b/frontend/src/types/text.ts index c52b4c3f..c9e3011e 100644 --- a/frontend/src/types/text.ts +++ b/frontend/src/types/text.ts @@ -16,17 +16,24 @@ export interface Title { [language: string]: string; } +// Revised according to the new sample data interface export interface OpenPechaText { - alt_titles: AltTitle[]; - bdrc: string; - contributions: Contribution[]; + bdrc: string | null; + wiki: string | null; date: string | null; - id: string; - language: string; - target: string | null; title: Title; - type: string; - wiki: string | null; + alt_titles: AltTitle[] | null; + language: string; + commentary_of: string | null; + translation_of: string | null; + category_id: string; + license: string; + id: string; + contributions: Contribution[]; + commentaries: string[]; + translations: string[]; + editions: string[]; + tag_ids: string[]; } export interface Span { diff --git a/frontend/src/utils/normalizeOpenpechaCategories.ts b/frontend/src/utils/normalizeOpenpechaCategories.ts new file mode 100644 index 00000000..1493e419 --- /dev/null +++ b/frontend/src/utils/normalizeOpenpechaCategories.ts @@ -0,0 +1,70 @@ +export interface NormalizedCategory { + id: string; + parent: string | null; + title: string; + has_child: boolean; +} + +function asCategoryId(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return ''; +} + +function languageBase(language: string): string { + return language.split('-')[0]?.toLowerCase() || 'bo'; +} + +function pickLocalizedTitle(title: unknown, language: string): string { + if (typeof title === 'string') return title; + if (title && typeof title === 'object' && !Array.isArray(title)) { + const t = title as Record; + const code = languageBase(language); + if (t[code]?.trim()) return t[code]; + if (t.en?.trim()) return t.en; + if (t.bo?.trim()) return t.bo; + const first = Object.values(t).find((v) => typeof v === 'string' && v.trim()); + return first ?? ''; + } + return ''; +} + +export function normalizeOpenpechaCategory( + raw: Record, + language: string +): NormalizedCategory { + const id = asCategoryId(raw.id); + const parent = + (raw.parent_id as string | null | undefined) ?? + (raw.parent as string | null | undefined) ?? + null; + + if (typeof raw.title === 'string' && typeof raw.has_child === 'boolean') { + return { + id, + parent, + title: raw.title, + has_child: raw.has_child, + }; + } + + const children = raw.children; + const hasChild = Array.isArray(children) && children.length > 0; + + return { + id, + parent, + title: pickLocalizedTitle(raw.title, language), + has_child: hasChild, + }; +} + +export function normalizeOpenpechaCategoryList( + data: unknown, + language: string +): NormalizedCategory[] { + if (!Array.isArray(data)) return []; + return data.map((item) => + normalizeOpenpechaCategory(item as Record, language) + ); +} From 149b6787645ee3a6e0f338aaf7bec9a5aefdcc25 Mon Sep 17 00:00:00 2001 From: tenkus47 Date: Mon, 20 Apr 2026 11:07:21 +0530 Subject: [PATCH 03/14] refactor: rename instance references to editions across backend and frontend - Updated router paths in the backend to replace 'instances' with 'editions' for consistency. - Refactored API response structures to use 'edition_id' instead of 'instance_id' in various controllers and models. - Modified frontend components and API calls to align with the new edition terminology, enhancing clarity in data handling. - Cleaned up related imports and adjusted navigation links to reflect the changes in terminology, improving user experience. --- .../controller/openpecha_api/instances.py | 6 +- backend/cataloger/routers/aligner_data.py | 10 ++-- backend/cataloger/routers/annotation.py | 8 +-- backend/cataloger/routers/text.py | 38 ++++++------ backend/cataloger/routers/translation.py | 60 ++++++------------- backend/main.py | 2 +- frontend/src/App.tsx | 21 ++++--- frontend/src/api/annotation.ts | 12 ++-- frontend/src/api/instances.ts | 36 ++--------- frontend/src/api/text.ts | 14 ++--- frontend/src/api/texts.ts | 38 ++++++------ frontend/src/app/BreadCrumb.tsx | 6 +- .../components/RelatedInstancesPanel.tsx | 17 +++--- .../components/TargetSelectionPanel.tsx | 15 +++-- .../Aligner/components/TextNavigationBar.tsx | 4 +- .../components/UnifiedSelectionPanel.tsx | 9 +-- .../Aligner/hooks/useAlignmentSegmentation.ts | 9 ++- .../Aligner/hooks/useRelatedInstances.ts | 2 + .../components/Aligner/hooks/useTextData.ts | 10 ++-- frontend/src/components/FormsubmitSection.tsx | 2 +- frontend/src/components/InstanceCard.tsx | 8 +-- frontend/src/components/TextCard.tsx | 7 ++- frontend/src/components/TextInstanceCard.tsx | 3 +- frontend/src/components/TextListCard.tsx | 31 +++++++--- .../components/textCreation/TextCreation.tsx | 6 +- frontend/src/hooks/useTextData.ts | 10 ++-- frontend/src/hooks/useTexts.ts | 6 +- frontend/src/i18n/locales/en/translation.json | 3 +- frontend/src/pages/CreateCommentary.tsx | 19 +++--- frontend/src/pages/CreateTranslation.tsx | 19 +++--- .../src/pages/{Instance.tsx => Edition.tsx} | 28 ++++----- .../{TextInstances.tsx => TextEditions.tsx} | 8 ++- frontend/src/pages/UpdateAnnotation.tsx | 6 +- frontend/src/types/text.ts | 18 +++--- frontend/src/utils/links.ts | 24 ++++++++ 35 files changed, 262 insertions(+), 253 deletions(-) rename frontend/src/pages/{Instance.tsx => Edition.tsx} (88%) rename frontend/src/pages/{TextInstances.tsx => TextEditions.tsx} (98%) create mode 100644 frontend/src/utils/links.ts diff --git a/backend/cataloger/controller/openpecha_api/instances.py b/backend/cataloger/controller/openpecha_api/instances.py index d594be4a..2eee63d2 100644 --- a/backend/cataloger/controller/openpecha_api/instances.py +++ b/backend/cataloger/controller/openpecha_api/instances.py @@ -208,7 +208,7 @@ def _create_linked_text_and_edition( return { "message": "Created successfully", - "instance_id": new_edition_id, + "edition_id": new_edition_id, "text_id": new_text_id, } @@ -266,9 +266,9 @@ def _related_edition_to_legacy(ed: Dict[str, Any], relationship: str = "related" except HTTPException: t = {} return { - "instance_id": ed.get("id"), + "edition_id": ed.get("id"), "metadata": { - "instance_type": ed.get("type", ""), + "edition_type": ed.get("type", ""), "copyright": "", "text_id": text_id, "title": t.get("title") or {}, diff --git a/backend/cataloger/routers/aligner_data.py b/backend/cataloger/routers/aligner_data.py index d4f534d2..9431af72 100644 --- a/backend/cataloger/routers/aligner_data.py +++ b/backend/cataloger/routers/aligner_data.py @@ -149,7 +149,7 @@ def prepare_data(source_instance_id: str, target_instance_id: str) -> Dict[str, for related in related_instances: if isinstance(related, dict): - related_id = related.get("instance_id") + related_id = related.get("edition_id") or related.get("instance_id") if related_id == target_instance_id: alignment_ann_id = related.get("annotation") if alignment_ann_id: @@ -244,10 +244,10 @@ def prepare_data(source_instance_id: str, target_instance_id: str) -> Dict[str, } -@router.get("/prepare-alignment-data/{source_instance_id}/{target_instance_id}") +@router.get("/prepare-alignment-data/{source_edition_id}/{target_edition_id}") async def prepare_alignment_data( - source_instance_id: str, - target_instance_id: str + source_edition_id: str, + target_edition_id: str ) -> PreparedDataResponse: """ Prepare alignment data by loading texts, checking for alignments, @@ -258,7 +258,7 @@ async def prepare_alignment_data( """ try: # Prepare data (fetch instances, annotations, etc.) - prepared_data = prepare_data(source_instance_id, target_instance_id) + prepared_data = prepare_data(source_edition_id, target_edition_id) source_text = prepared_data["source_text"] target_text = prepared_data["target_text"] diff --git a/backend/cataloger/routers/annotation.py b/backend/cataloger/routers/annotation.py index 5f2697d3..d9609fd6 100644 --- a/backend/cataloger/routers/annotation.py +++ b/backend/cataloger/routers/annotation.py @@ -133,10 +133,10 @@ async def update_annotation(annotation_id: str, annotation: UpdateAnnotation): """Update an annotation by ID""" return openpecha_update_annotation_body(annotation_id, annotation.dict()) -@router.post("/{instance_id}/annotation") -async def create_annotation(instance_id: str, annotation: CreateAnnotation): - """Create an annotation for a specific instance""" - return openpecha_create_annotation_for_instance(instance_id, annotation.dict()) +@router.post("/{edition_id}/annotation") +async def create_annotation(edition_id: str, annotation: CreateAnnotation): + """Create an annotation on a specific edition.""" + return openpecha_create_annotation_for_instance(edition_id, annotation.dict()) diff --git a/backend/cataloger/routers/text.py b/backend/cataloger/routers/text.py index 70f3386c..7e6fdeb9 100644 --- a/backend/cataloger/routers/text.py +++ b/backend/cataloger/routers/text.py @@ -104,7 +104,7 @@ class BibliographyAnnotation(BaseModel): description="Annotation type: citation, reference, title, colophon, incipit, person" ) -class InstanceMetadata(BaseModel): +class EditionMetadata(BaseModel): id: str type: str copyright: str @@ -122,13 +122,13 @@ class InstanceMetadata(BaseModel): description="Alternative incipit titles in multiple languages" ) -class Instance(BaseModel): +class Edition(BaseModel): content: Optional[str] = None - metadata: Optional[InstanceMetadata] = None + metadata: Optional[EditionMetadata] = None annotations: Optional[List[Annotation]] = None biblography_annotation: Optional[List[BibliographyAnnotation]] = None -class InstanceListItem(BaseModel): +class EditionListItem(BaseModel): id: str type: str source: Optional[str] = None @@ -146,21 +146,21 @@ class InstanceListItem(BaseModel): description="Alternative incipit titles in multiple languages" ) -class CreateInstance(BaseModel): +class CreateEdition(BaseModel): metadata: Dict[str, Any] annotation: List[Dict[str, Any]] biblography_annotation: Optional[List[BibliographyAnnotation]] = None content: str user: Optional[str] = None -class UpdateInstance(BaseModel): +class UpdateEdition(BaseModel): metadata: Dict[str, Any] annotation: List[Dict[str, Any]] biblography_annotation: Optional[List[BibliographyAnnotation]] = None content: str user: Optional[str] = None -class CreateInstanceResponse(BaseModel): +class CreateEditionResponse(BaseModel): message: str id: str @@ -200,8 +200,8 @@ async def get_text(id: str): async def update_text(id: str, text: UpdateText): return openpecha_update_text(id, text.model_dump(exclude_none=True)) -@router.get("/{id}/instances") -async def get_instances(id: str): +@router.get("/{id}/editions") +async def get_editions(id: str): data = openpecha_list_instances_for_text(id) if isinstance(data, dict) and "results" in data: return data["results"] @@ -209,21 +209,21 @@ async def get_instances(id: str): return data return [] -@router.post("/{id}/instances", status_code=201) -async def create_instance(id: str, instance: CreateInstance): - payload = instance.model_dump(exclude_none=True) +@router.post("/{id}/editions", status_code=201) +async def create_edition(id: str, edition: CreateEdition): + payload = edition.model_dump(exclude_none=True) payload.pop("user", None) return openpecha_create_instance_for_text(id, payload) -@router.put("/instances/{instance_id}", status_code=200) -async def update_instance(instance_id: str, instance: UpdateInstance): - payload = instance.model_dump(exclude_none=True) - return openpecha_update_instance(instance_id, payload) +@router.put("/editions/{edition_id}", status_code=200) +async def update_edition(edition_id: str, edition: UpdateEdition): + payload = edition.model_dump(exclude_none=True) + return openpecha_update_instance(edition_id, payload) -@router.get("/instances/{instance_id}") -async def get_instance(instance_id: str, annotation: bool = True): - return openpecha_get_instance(instance_id, annotation=annotation, content=True) +@router.get("/editions/{edition_id}") +async def get_edition(edition_id: str, annotation: bool = True): + return openpecha_get_instance(edition_id, annotation=annotation, content=True) diff --git a/backend/cataloger/routers/translation.py b/backend/cataloger/routers/translation.py index 4fd86a38..9948954a 100644 --- a/backend/cataloger/routers/translation.py +++ b/backend/cataloger/routers/translation.py @@ -9,10 +9,6 @@ create_translation as openpecha_create_translation, list_related_instances as openpecha_list_related_instances, ) -from cataloger.controller.openpecha_api.texts import ( - list_instances_for_text as openpecha_list_instances_for_text, -) - load_dotenv(override=True) router = APIRouter() @@ -86,30 +82,19 @@ class CreateCommentary(BaseModel): class TranslationResponse(BaseModel): message: str - instance_id: str + edition_id: str text_id: str -class InstanceListItem(BaseModel): - id: str - type: str - source: Optional[str] = None - bdrc: Optional[str] = None - wiki: Optional[str] = None - colophon: Optional[str] = None - incipit_title: Optional[Dict[str, str]] = None - alt_incipit_titles: List[Dict[str, str]] = [] - - class Contribution(BaseModel): person_id: str person_name: Optional[str] = None role: str -class RelatedInstanceMetadata(BaseModel): - instance_type: str +class RelatedEditionMetadata(BaseModel): + edition_type: str copyright: str text_id: str title: Dict[str, str] @@ -118,44 +103,37 @@ class RelatedInstanceMetadata(BaseModel): contributions: List[Contribution] = [] -class RelatedInstance(BaseModel): - instance_id: str - metadata: RelatedInstanceMetadata +class RelatedEdition(BaseModel): + edition_id: str + metadata: RelatedEditionMetadata annotation: Optional[str] = None relationship: str -@router.get("/{text_id}/instances") -async def get_text_instances(text_id: str): - """Get all instances for a specific text""" - return openpecha_list_instances_for_text(text_id) - - - -@router.post("/{instance_id}/translation", status_code=201) -async def create_translation(instance_id: str, translation: CreateTranslation): - """Create a translation for a specific instance""" +@router.post("/{edition_id}/translation", status_code=201) +async def create_translation(edition_id: str, translation: CreateTranslation): + """Create a translation from a source edition.""" payload = translation.model_dump(exclude_none=True) payload.pop("user", None) - return openpecha_create_translation(instance_id, payload) + return openpecha_create_translation(edition_id, payload) -@router.post("/{instance_id}/commentary", status_code=201) -async def create_commentary(instance_id: str, commentary: CreateCommentary): - """Create a commentary for a specific instance""" +@router.post("/{edition_id}/commentary", status_code=201) +async def create_commentary(edition_id: str, commentary: CreateCommentary): + """Create a commentary from a source edition.""" payload = commentary.model_dump(exclude_none=True) payload.pop("user", None) - return openpecha_create_commentary(instance_id, payload) + return openpecha_create_commentary(edition_id, payload) -@router.get("/{instance_id}/related") -async def get_related_instances(instance_id: str, type: Optional[str] = None): - """Get all instances related to a specific instance +@router.get("/{edition_id}/related") +async def get_related_editions(edition_id: str, type: Optional[str] = None): + """Editions related to the given edition (same text family). Args: - instance_id: The ID of the instance to get related instances for + edition_id: Source edition id type: Optional filter by relationship type (root, commentary, translation) """ - return openpecha_list_related_instances(instance_id, relationship_type=type) + return openpecha_list_related_instances(edition_id, relationship_type=type) diff --git a/backend/main.py b/backend/main.py index 5d0b7170..ece9a54a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -73,7 +73,7 @@ async def dispatch(self, request: Request, call_next): app.include_router(person.router, prefix="/person", tags=["person"]) app.include_router(text.router, prefix="/text", tags=["text"]) app.include_router(bdrc.router, prefix="/bdrc", tags=["bdrc"]) -app.include_router(translation.router, prefix="/instances", tags=["translation"]) +app.include_router(translation.router, prefix="/editions", tags=["translation"]) app.include_router(annotation.router, prefix="/v2/annotations", tags=["annotation"]) app.include_router(segments.router, prefix="/segments", tags=["segments"]) app.include_router(category.router, prefix="/v2/categories", tags=["category"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6382232a..4533512a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,15 @@ -import { Routes, Route, useLocation,Link } from 'react-router-dom'; +import { Routes, Route, useLocation } from 'react-router-dom'; import { useEffect, lazy, Suspense } from 'react'; import { useAuth0 } from '@auth0/auth0-react'; import { Navigation, ProtectedRoute } from '@app'; import { getUserByEmail, createUser } from './api/settings'; import OutlinerAdminLayout from './features/outliner/components/OutlinerAdminLayout'; -import { useUser } from './hooks/useUser'; // Lazy load page components const TextsPage = lazy(() => import('./pages/Text')); const PersonsPage = lazy(() => import('./pages/Person')); -const TextInstances = lazy(() => import('./pages/TextInstances')); -const Instance = lazy(() => import('./pages/Instance')); +const TextEditions = lazy(() => import('./pages/TextEditions')); +const Edition = lazy(() => import('./pages/Edition')); const Index = lazy(() => import('./pages/Index')); const Create = lazy(() => import('./pages/Create')); const CreateTranslation = lazy(() => import('./pages/CreateTranslation')); @@ -134,27 +133,27 @@ function App() { } /> - - + } /> - - + } /> - } /> - } /> - diff --git a/frontend/src/api/annotation.ts b/frontend/src/api/annotation.ts index e9613802..fdee8107 100644 --- a/frontend/src/api/annotation.ts +++ b/frontend/src/api/annotation.ts @@ -118,17 +118,17 @@ export interface PreparedDataResponse { } /** - * Fetch prepared alignment data for source and target instances - * @param source_instance_id - The ID of the source instance - * @param target_instance_id - The ID of the target instance + * Fetch prepared alignment data for source and target editions + * @param source_edition_id - The ID of the source edition + * @param target_edition_id - The ID of the target edition * @returns PreparedDataResponse containing texts, alignment status, and segmentation data */ export const fetchPreparedAlignmentData = async ( - source_instance_id: string, - target_instance_id: string + source_edition_id: string, + target_edition_id: string ): Promise => { const response = await fetch( - `${API_URL}/aligner-data/prepare-alignment-data/${source_instance_id}/${target_instance_id}`, + `${API_URL}/aligner-data/prepare-alignment-data/${source_edition_id}/${target_edition_id}`, { headers: { 'accept': 'application/json', diff --git a/frontend/src/api/instances.ts b/frontend/src/api/instances.ts index 31bb30b2..bcbed2c6 100644 --- a/frontend/src/api/instances.ts +++ b/frontend/src/api/instances.ts @@ -1,39 +1,12 @@ -interface RelatedInstance { - // Support both API response formats - id?: string; - instance_id?: string; - type?: string; - incipit_title?: Record; - alt_incipit_titles?: Record; - content?: string; - annotations?: Array<{ - annotation_id: string; - type: string; - }>; - // New API response format - metadata?: { - instance_type?: string; - copyright?: string; - text_id?: string; - title?: Record; - alt_titles?: Array>; - language?: string; - contributions?: Array<{ - person_id: string; - role: string; - }>; - }; - annotation?: string; - relationship?: string; -} +import type { RelatedInstance } from '@/types/text'; const API_URL = '/api'; -export const fetchRelatedInstances = async (instanceId: string): Promise => { +export const fetchRelatedInstances = async (editionId: string): Promise => { const response = await fetch( - `${API_URL}/instances/${instanceId}/related`, + `${API_URL}/editions/${editionId}/related`, { headers: { 'accept': 'application/json', @@ -42,7 +15,7 @@ export const fetchRelatedInstances = async (instanceId: string): Promise => { - const response = await fetch(`${API_URL}/text/${id}/instances`); + const response = await fetch(`${API_URL}/text/${id}/editions`); const data = await response.json(); return Array.isArray(data) ? data : [data]; }; export const fetchInstance = async (id: string): Promise => { - const response = await fetch(`${API_URL}/text/instances/${id}?annotations=true`); + const response = await fetch(`${API_URL}/text/editions/${id}`); return response.json(); }; @@ -247,7 +247,7 @@ export const createTextInstance = async (textId: string, instanceData: { }>; content: string; }): Promise => { - const response = await fetch(`${API_URL}/text/${textId}/instances`, { + const response = await fetch(`${API_URL}/text/${textId}/editions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -299,12 +299,12 @@ export const createCommentary = async (instanceId: string, commentaryData: { copyright: string; }): Promise<{ text_id: string; - instance_id: string; + edition_id: string; segmentation_annotation_id?: string; target_annotation_id?: string; alignment_annotation_id?: string; }> => { - const response = await fetch(`${API_URL}/instances/${instanceId}/commentary`, { + const response = await fetch(`${API_URL}/editions/${instanceId}/commentary`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -353,12 +353,12 @@ export const createTranslation = async (instanceId: string, translationData: { copyright: string; }): Promise<{ text_id: string; - instance_id: string; + edition_id: string; segmentation_annotation_id?: string; target_annotation_id?: string; alignment_annotation_id?: string; }> => { - const response = await fetch(`${API_URL}/instances/${instanceId}/translation`, { + const response = await fetch(`${API_URL}/editions/${instanceId}/translation`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/api/texts.ts b/frontend/src/api/texts.ts index c1561c86..844d1f06 100644 --- a/frontend/src/api/texts.ts +++ b/frontend/src/api/texts.ts @@ -162,36 +162,36 @@ export const createText = async (textData: any): Promise => { export const fetchTextInstances = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/text/${id}/instances`); + const response = await fetch(`${API_URL}/text/${id}/editions`); return await handleApiResponse(response, { - 404: 'Text instances not found. The text may not exist or has no instances yet.' + 404: 'Text editions not found. The text may not exist or has no editions yet.' }); } catch (error) { if (error instanceof Error) { throw error; } - throw new Error('Unable to load text instances. Please check your connection and try again.'); + throw new Error('Unable to load text editions. Please check your connection and try again.'); } }; export const fetchInstance = async (id: string): Promise => { try { - const response = await fetch(`${API_URL}/text/instances/${id}`); + const response = await fetch(`${API_URL}/text/editions/${id}`); return await handleApiResponse(response, { - 404: 'Instance not found. It may have been deleted or the link is incorrect.' + 404: 'Edition not found. It may have been deleted or the link is incorrect.' }); } catch (error) { if (error instanceof Error) { throw error; } - throw new Error('Unable to load instance details. Please check your connection and try again.'); + throw new Error('Unable to load edition details. Please check your connection and try again.'); } }; // Real API function for creating text instances export const createTextInstance = async (textId: string, instanceData: any, user: string): Promise => { try { - const response = await fetch(`${API_URL}/text/${textId}/instances`, { + const response = await fetch(`${API_URL}/text/${textId}/editions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -200,14 +200,14 @@ export const createTextInstance = async (textId: string, instanceData: any, user }); return await handleApiResponse(response, { - 400: 'Invalid instance data. Please check all required fields and try again.', - 404: 'Text not found. Cannot create instance for non-existent text.' + 400: 'Invalid edition data. Please check all required fields and try again.', + 404: 'Text not found. Cannot create edition for non-existent text.' }); } catch (error) { if (error instanceof Error) { throw error; } - throw new Error('Unable to create text instance. Please check your connection and try again.'); + throw new Error('Unable to create text edition. Please check your connection and try again.'); } }; @@ -237,7 +237,7 @@ export const fetchTextByBdrcId = async (bdrcId: string): Promise => { try { - const response = await fetch(`${API_URL}/instances/${instanceId}/translation`, { + const response = await fetch(`${API_URL}/editions/${instanceId}/translation`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -257,7 +257,7 @@ export const createTranslation = async (instanceId: string, translationData: any export const createCommentary = async (instanceId: string, commentaryData: any, user: string): Promise => { try { - const response = await fetch(`${API_URL}/instances/${instanceId}/commentary`, { + const response = await fetch(`${API_URL}/editions/${instanceId}/commentary`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -304,7 +304,7 @@ export const updateInstance = async (textId: string, instanceId: string, instanc delete instanceData.biblography_annotation; } - const response = await fetch(`${API_URL}/text/instances/${instanceId}`, { + const response = await fetch(`${API_URL}/text/editions/${instanceId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -313,14 +313,14 @@ export const updateInstance = async (textId: string, instanceId: string, instanc }); return await handleApiResponse(response, { - 400: 'Invalid instance data. Please check all required fields and try again.', - 404: 'Instance not found. It may have been deleted or the link is incorrect.' + 400: 'Invalid edition data. Please check all required fields and try again.', + 404: 'Edition not found. It may have been deleted or the link is incorrect.' }); } catch (error) { if (error instanceof Error) { throw error; } - throw new Error('Unable to update instance. Please check your connection and try again.'); + throw new Error('Unable to update edition. Please check your connection and try again.'); } }; @@ -360,14 +360,14 @@ export const fetchEnums = async (type: string): Promise => { export const fetchRelatedInstances = async (instanceId: string): Promise => { try { - const response = await fetch(`${API_URL}/instances/${instanceId}/related`); + const response = await fetch(`${API_URL}/editions/${instanceId}/related`); return await handleApiResponse(response, { - 404: 'Related instances not found. The instance may not exist or has no related instances.' + 404: 'Related editions not found. The edition may not exist or has no related editions.' }); } catch (error) { if (error instanceof Error) { throw error; } - throw new Error('Unable to load related instances. Please check your connection and try again.'); + throw new Error('Unable to load related editions. Please check your connection and try again.'); } }; \ No newline at end of file diff --git a/frontend/src/app/BreadCrumb.tsx b/frontend/src/app/BreadCrumb.tsx index c90ba9d9..78aef556 100644 --- a/frontend/src/app/BreadCrumb.tsx +++ b/frontend/src/app/BreadCrumb.tsx @@ -52,10 +52,10 @@ const BreadCrumb: React.FC = ({ if (params.text_id && textname) { breadcrumbs.push({ label: textname, - href: `/texts/${params.text_id}/instances`, + href: `/texts/${params.text_id}/editions`, }); - if (pathSegments.includes('instances')) { - if (params.instance_id && instancename) { + if (pathSegments.includes('editions')) { + if (params.edition_id && instancename) { breadcrumbs.push({ label: instancename, icon: , diff --git a/frontend/src/components/Aligner/components/RelatedInstancesPanel.tsx b/frontend/src/components/Aligner/components/RelatedInstancesPanel.tsx index 9965e3ec..e26dee73 100644 --- a/frontend/src/components/Aligner/components/RelatedInstancesPanel.tsx +++ b/frontend/src/components/Aligner/components/RelatedInstancesPanel.tsx @@ -3,11 +3,14 @@ import { useRelatedInstances, type RelatedInstance } from '../hooks/useRelatedIn import { getLanguageFromCode } from '../utils/languageUtils'; import { RefreshCw } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; +import { editionIdFromRelated } from '@/utils/links'; interface RelatedInstanceResponse { - instance_id: string; + edition_id?: string; + instance_id?: string; metadata: { - instance_type: string; + edition_type?: string; + instance_type?: string; copyright: string; text_id: string; title: Record; @@ -43,13 +46,13 @@ export function RelatedInstancesPanel({ const getInstanceTitle = (instance: RelatedInstance): string => { - const isNewFormat = 'instance_id' in instance && 'metadata' in instance; + const isNewFormat = ('edition_id' in instance || 'instance_id' in instance) && 'metadata' in instance; if (isNewFormat) { const apiInstance = instance as RelatedInstanceResponse; const titleObj = apiInstance.metadata.title; const firstTitleKey = titleObj ? Object.keys(titleObj)[0] : undefined; - return (firstTitleKey && titleObj[firstTitleKey]) || `Instance ${instance.instance_id || instance.id}`; + return (firstTitleKey && titleObj[firstTitleKey]) || `Edition ${editionIdFromRelated(apiInstance)}`; } else { const titleObj = instance.incipit_title; const altTitlesObj = instance.alt_incipit_titles; @@ -60,12 +63,12 @@ export function RelatedInstancesPanel({ return (firstTitleKey && titleObjTyped?.[firstTitleKey]) || (firstAltTitleKey && altTitlesObjTyped?.[firstAltTitleKey]) || - `Instance ${instance.instance_id || instance.id}`; + `Edition ${editionIdFromRelated(instance)}`; } }; const getInstanceMetadata = (instance: RelatedInstance) => { - const isNewFormat = 'instance_id' in instance && 'metadata' in instance; + const isNewFormat = ('edition_id' in instance || 'instance_id' in instance) && 'metadata' in instance; if (isNewFormat) { const apiInstance = instance as RelatedInstanceResponse; @@ -203,7 +206,7 @@ return (
{availableTargetInstances.map((instance) => { - const instanceId = instance.instance_id || instance.id; + const instanceId = editionIdFromRelated(instance); const isSelected = selectedTargetInstanceId === instanceId; const title = getInstanceTitle(instance); diff --git a/frontend/src/components/Aligner/components/TargetSelectionPanel.tsx b/frontend/src/components/Aligner/components/TargetSelectionPanel.tsx index 21120afe..5e655443 100644 --- a/frontend/src/components/Aligner/components/TargetSelectionPanel.tsx +++ b/frontend/src/components/Aligner/components/TargetSelectionPanel.tsx @@ -9,12 +9,15 @@ import { applySegmentation, generateFileSegmentation, extractInstanceSegmentatio import { reconstructSegments } from '../utils/generateAnnotation'; import LoadingOverlay from './LoadingOverlay'; import { CATALOGER_URL } from '../../../config'; +import { editionIdFromRelated } from '@/utils/links'; // Interface for the API response structure interface RelatedInstanceResponse { - instance_id: string; + edition_id?: string; + instance_id?: string; metadata: { - instance_type: string; + edition_type?: string; + instance_type?: string; copyright: string; text_id: string; title: Record; @@ -259,7 +262,7 @@ function TargetSelectionPanel() { // Determine and set target type based on metadata const selectedInstance = relatedInstances.find(instance => - (instance.instance_id || instance.id) === selectedInstanceId + editionIdFromRelated(instance) === selectedInstanceId ); const targetType = determineTargetType(selectedInstance || null); setTargetType(targetType); @@ -349,7 +352,7 @@ function TargetSelectionPanel() { // Find the selected instance from the related instances const selectedInstance = relatedInstances.find(instance => - (instance.instance_id || instance.id) === instanceId + editionIdFromRelated(instance) === instanceId ); if (selectedInstance) { @@ -542,8 +545,8 @@ function TargetSelectionPanel() { if (!instance) return null; // Handle both API response formats - const instanceId = instance.instance_id || instance.id; - const isNewFormat = 'instance_id' in instance && 'metadata' in instance; + const instanceId = editionIdFromRelated(instance); + const isNewFormat = ('edition_id' in instance || 'instance_id' in instance) && 'metadata' in instance; let title: string; let instanceType: string; diff --git a/frontend/src/components/Aligner/components/TextNavigationBar.tsx b/frontend/src/components/Aligner/components/TextNavigationBar.tsx index e34c833f..9ed305bd 100644 --- a/frontend/src/components/Aligner/components/TextNavigationBar.tsx +++ b/frontend/src/components/Aligner/components/TextNavigationBar.tsx @@ -77,7 +77,7 @@ function TextNavigationBar() { const titleKeys = Object.keys(titleObj); if (titleKeys.length > 0) { const title = titleObj[titleKeys[0]]; - const instanceType = (metadata.instance_type as string) || 'Instance'; + const instanceType = (metadata.edition_type as string) || (metadata.instance_type as string) || 'Edition'; const language = metadata.language ? ` (${metadata.language as string})` : ''; return `${instanceType} - ${title}${language}`; } @@ -101,7 +101,7 @@ function TextNavigationBar() { } // Fallback to instance type and ID - const instanceType = (metadata?.instance_type as string) || (metadata?.type as string) || (data.type as string) || 'Instance'; + const instanceType = (metadata?.edition_type as string) || (metadata?.instance_type as string) || (metadata?.type as string) || (data.type as string) || 'Edition'; return `${instanceType} ${instanceId}`; }; diff --git a/frontend/src/components/Aligner/components/UnifiedSelectionPanel.tsx b/frontend/src/components/Aligner/components/UnifiedSelectionPanel.tsx index d1301fd4..8a558d99 100644 --- a/frontend/src/components/Aligner/components/UnifiedSelectionPanel.tsx +++ b/frontend/src/components/Aligner/components/UnifiedSelectionPanel.tsx @@ -17,6 +17,7 @@ import { fetchInstance, } from "../../../api/text"; import { fetchRelatedInstances } from "../../../api/instances"; +import { editionIdFromRelated } from "@/utils/links"; import { applySegmentation, generateFileSegmentation, @@ -194,7 +195,7 @@ function UnifiedSelectionPanel() { const actionType = action === "create-translation" ? "translation" : "commentary"; - const url = `${CATALOGER_URL}/texts/${sourceTextId}/instances/${sourceInstanceId}/${actionType}`; + const url = `${CATALOGER_URL}/texts/${sourceTextId}/editions/${sourceInstanceId}/${actionType}`; window.open(url, "_blank"); }, [sourceTextId, sourceInstanceId] @@ -427,7 +428,7 @@ function UnifiedSelectionPanel() { try { // Find the selected instance in related instances const selectedInstance = relatedInstances.find( - (instance) => (instance.instance_id || instance.id) === instanceId + (instance) => editionIdFromRelated(instance) === instanceId ); if (!selectedInstance) { @@ -568,7 +569,7 @@ function UnifiedSelectionPanel() { // If it has alignment then do the current workflow const selectedInstance = relatedInstances.find( - (instance) => (instance.instance_id || instance.id) === instanceId + (instance) => editionIdFromRelated(instance) === instanceId ); if (selectedInstance) { @@ -621,7 +622,7 @@ function UnifiedSelectionPanel() { relatedInstances.length > 0 ) { const targetInstance = relatedInstances.find( - (instance) => (instance.instance_id || instance.id) === urlTargetId + (instance) => editionIdFromRelated(instance) === urlTargetId ); if (targetInstance) { diff --git a/frontend/src/components/Aligner/hooks/useAlignmentSegmentation.ts b/frontend/src/components/Aligner/hooks/useAlignmentSegmentation.ts index d9adc0b1..16c1b165 100644 --- a/frontend/src/components/Aligner/hooks/useAlignmentSegmentation.ts +++ b/frontend/src/components/Aligner/hooks/useAlignmentSegmentation.ts @@ -5,6 +5,7 @@ import { applySegmentation, generateFileSegmentation, extractInstanceSegmentatio import { reconstructSegments } from '../utils/generateAnnotation'; import { useTextSelectionStore } from '../../../stores/textSelectionStore'; import type { RelatedInstance } from './useRelatedInstances'; +import { editionIdFromRelated } from '@/utils/links'; // Interface for alignment annotation response interface AlignmentAnnotationData { @@ -34,9 +35,11 @@ interface AlignmentAnnotationResponse { } interface RelatedInstanceResponse { - instance_id: string; + edition_id?: string; + instance_id?: string; metadata: { - instance_type: string; + edition_type?: string; + instance_type?: string; copyright: string; text_id: string; title: Record; @@ -226,7 +229,7 @@ export function useAlignmentSegmentation({ ); const selectedInstance = relatedInstances.find(instance => - (instance.instance_id || instance.id) === selectedTargetInstanceId + editionIdFromRelated(instance) === selectedTargetInstanceId ); const targetType = determineTargetType(selectedInstance || null); setTargetType(targetType); diff --git a/frontend/src/components/Aligner/hooks/useRelatedInstances.ts b/frontend/src/components/Aligner/hooks/useRelatedInstances.ts index 079af91f..b1fd91ca 100644 --- a/frontend/src/components/Aligner/hooks/useRelatedInstances.ts +++ b/frontend/src/components/Aligner/hooks/useRelatedInstances.ts @@ -4,6 +4,7 @@ import { fetchRelatedInstances } from '../../../api/instances'; export interface RelatedInstance { // Support both API response formats id?: string; + edition_id?: string; instance_id?: string; type?: string; incipit_title?: Record; @@ -15,6 +16,7 @@ export interface RelatedInstance { }>; // New API response format metadata?: { + edition_type?: string; instance_type?: string; copyright?: string; text_id?: string; diff --git a/frontend/src/components/Aligner/hooks/useTextData.ts b/frontend/src/components/Aligner/hooks/useTextData.ts index db074701..74412e9b 100644 --- a/frontend/src/components/Aligner/hooks/useTextData.ts +++ b/frontend/src/components/Aligner/hooks/useTextData.ts @@ -12,8 +12,8 @@ export const textKeys = { list: (filters: Record) => [...textKeys.lists(), { filters }] as const, details: () => [...textKeys.all, 'detail'] as const, detail: (id: string) => [...textKeys.details(), id] as const, - instances: (id: string) => [...textKeys.detail(id), 'instances'] as const, - instance: (id: string) => ['instance', id] as const, + editions: (id: string) => [...textKeys.detail(id), 'editions'] as const, + edition: (id: string) => ['edition', id] as const, annotation: (id: string) => ['annotation', id] as const, }; @@ -100,7 +100,7 @@ export const useTextTitleSearch = (searchQuery: string, debounceMs: number = 500 */ export const useTextInstances = (textId: string | null) => { return useQuery({ - queryKey: textKeys.instances(textId || "textId"), + queryKey: textKeys.editions(textId || "textId"), queryFn: () => textId ? fetchTextInstances(textId) : null, enabled: !!textId, staleTime: 10 * 60 * 1000, // 10 minutes @@ -117,7 +117,7 @@ export const useTextInstances = (textId: string | null) => { */ export const useInstance = (instanceId: string | null) => { return useQuery({ - queryKey: textKeys.instance(instanceId || "instanceId"), + queryKey: textKeys.edition(instanceId || "instanceId"), queryFn: () => instanceId ? fetchInstance(instanceId) : null, enabled: !!instanceId && instanceId !== '', staleTime: 10 * 60 * 1000, // 10 minutes @@ -492,7 +492,7 @@ export const useLoadTextContent = () => { }, onSuccess: (data) => { // Cache the text instance data - queryClient.setQueryData(textKeys.instance(data.instanceId), (oldData: OpenPechaTextInstance | undefined) => { + queryClient.setQueryData(textKeys.edition(data.instanceId), (oldData: OpenPechaTextInstance | undefined) => { if (oldData) return oldData; return { id: data.instanceId, diff --git a/frontend/src/components/FormsubmitSection.tsx b/frontend/src/components/FormsubmitSection.tsx index 2c6cb6e2..25dacb84 100644 --- a/frontend/src/components/FormsubmitSection.tsx +++ b/frontend/src/components/FormsubmitSection.tsx @@ -17,7 +17,7 @@ function FormsubmitSection({ const { t } = useTranslation(); const params = useParams(); -const instance_id = params.instance_id as string; +const instance_id = params.edition_id as string; const disabled = isSubmitting || disableSubmit; return (
{onCancel && ( diff --git a/frontend/src/components/InstanceCard.tsx b/frontend/src/components/InstanceCard.tsx index 7bfcbcfd..e3404b90 100644 --- a/frontend/src/components/InstanceCard.tsx +++ b/frontend/src/components/InstanceCard.tsx @@ -18,7 +18,7 @@ interface InstanceCardProps { const InstanceCard: React.FC = ({ instance }) => { const { t } = useTranslation(); const navigate = useNavigate(); - const { text_id, instance_id } = useParams(); + const { text_id, edition_id } = useParams(); const [expandedAnnotations, setExpandedAnnotations] = useState([]); const { data: permission,isFetching:isFetchingPermission } = usePermission(); const isAdmin=permission?.role === "admin"; @@ -140,7 +140,7 @@ const InstanceCard: React.FC = ({ instance }) => {
+ {edition_id && instance.content && ( +
+ +
+ )} + {/* Content Text with Line Breaks Applied */} {instance.content && (
- {/* Loading State for Annotation */} - {segmentationAnnotationId && isLoadingAnnotation && ( + {/* Loading State for Annotation (skip when showing a saved segmentation from the list) */} + {!pickedSegmentationId && segmentationAnnotationId && isLoadingAnnotation && (
{t('instance.loadingContent')} @@ -193,7 +259,7 @@ const InstanceCard: React.FC = ({ instance }) => { )} {/* Error State for Annotation */} - {segmentationAnnotationId && annotationError && ( + {!pickedSegmentationId && segmentationAnnotationId && annotationError && (
@@ -206,8 +272,10 @@ const InstanceCard: React.FC = ({ instance }) => {
)} - {/* Content Display - Only show after annotation is loaded or if no annotation exists */} - {(!segmentationAnnotationId || !isLoadingAnnotation) && ( + {/* Content Display - saved list needs no annotation fetch; otherwise wait for annotation */} + {(pickedSegmentationId || + !segmentationAnnotationId || + !isLoadingAnnotation) && ( )}
diff --git a/frontend/src/hooks/useTexts.ts b/frontend/src/hooks/useTexts.ts index 870fec9f..36c0734a 100644 --- a/frontend/src/hooks/useTexts.ts +++ b/frontend/src/hooks/useTexts.ts @@ -2,13 +2,16 @@ import { createText, createTextInstance, fetchAnnotation, + fetchEditionSegmentations, fetchInstance, fetchRelatedInstances, fetchText, fetchTextInstances, fetchTexts, + postEditionSegmentations, updateInstance, updateText, + type EditionSegmentationsPayload, } from "@/api/texts"; import { updateSegmentContent } from "@/api/segments"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -63,6 +66,16 @@ export const useInstance = (id: string) => { }); }; +export const useEditionSegmentations = (editionId: string) => { + return useQuery({ + queryKey: ["editionSegmentations", editionId], + queryFn: () => fetchEditionSegmentations(editionId), + enabled: !!editionId, + staleTime: 60 * 1000, + retry: 1, + }); +}; + export const useAnnnotation = (id: string) => { return useQuery({ queryKey: ["annotation", id], @@ -169,4 +182,23 @@ export const useUpdateText = () => { } }); }; + +export const usePostEditionSegmentations = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + editionId, + payload, + }: { + editionId: string; + payload: EditionSegmentationsPayload; + }) => postEditionSegmentations(editionId, payload), + onSuccess: (_, { editionId }) => { + queryClient.invalidateQueries({ queryKey: ["edition", editionId] }); + queryClient.invalidateQueries({ queryKey: ["editionSegmentations", editionId] }); + queryClient.invalidateQueries({ queryKey: ["annotation"] }); + }, + }); +}; diff --git a/frontend/src/pages/UpdateAnnotation.tsx b/frontend/src/pages/UpdateAnnotation.tsx index 5fb3c601..32dad944 100644 --- a/frontend/src/pages/UpdateAnnotation.tsx +++ b/frontend/src/pages/UpdateAnnotation.tsx @@ -3,9 +3,10 @@ import { useParams, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { AlertCircle, X, FileText, Code, ArrowLeft, Loader2 } from "lucide-react"; import type { InstanceCreationFormRef } from "@/components/InstanceCreationForm"; -import { useText, useInstance, useAnnnotation, useUpdateText } from "@/hooks/useTexts"; +import { useText, useInstance, useAnnnotation, useUpdateText, usePostEditionSegmentations } from "@/hooks/useTexts"; import { useTranslation } from "react-i18next"; -import { SegmentUpdateWrapper } from "@/components/segmentUpdate"; +import { Textarea } from "@/components/ui/textarea"; +import type { EditionSegmentationsPayload } from "@/api/texts"; import type { OpenPechaText, Title as TitleType } from "@/types/text"; import Title from "@/components/formComponent/Title"; import AlternativeTitle from "@/components/formComponent/AlternativeTitle"; @@ -18,11 +19,59 @@ import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels'; import { SkeletonLarger } from "@/components/ui/skeleton"; +function reconstructContentWithSegmentation( + content: string, + annotationData: unknown +): string { + if (!content) return ""; + + const segmentationAnnotations = (annotationData as { data?: unknown[] })?.data; + if ( + !segmentationAnnotations || + !Array.isArray(segmentationAnnotations) || + segmentationAnnotations.length === 0 + ) { + return content; + } + + type Ann = { span?: { start: number; end: number } }; + const sortedAnnotations = [...(segmentationAnnotations as Ann[])].sort( + (a, b) => (a.span?.start || 0) - (b.span?.start || 0) + ); + + const lines = sortedAnnotations.map((annotation) => { + if (!annotation.span) return ""; + return content.substring(annotation.span.start, annotation.span.end); + }); + + return lines.join("\n"); +} + +function buildSegmentationsPayload( + fullContent: string, + editorText: string +): EditionSegmentationsPayload { + const parts = editorText.split("\n"); + if (parts.join("") !== fullContent) { + throw new Error( + "The text must match the edition content exactly; use line breaks only to mark segment boundaries." + ); + } + let offset = 0; + const segments = parts.map((part) => { + const lines = [{ start: offset, end: offset + part.length }]; + offset += part.length; + return { lines }; + }); + return { segments, metadata: {} }; +} + const UpdateAnnotation = () => { const { text_id, edition_id } = useParams(); const navigate = useNavigate(); const { t } = useTranslation(); const instanceFormRef = useRef(null); + const postSegmentations = usePostEditionSegmentations(); // Fetch text and instance data const { data: text, isLoading: textLoading ,isRefetching: textRefetching} = useText(text_id || ""); @@ -45,48 +94,33 @@ const UpdateAnnotation = () => { } = useAnnnotation(segmentationAnnotationId); // State - const [editedContent, setEditedContent] = useState(""); const [error, setError] = useState(null); const [activePanel, setActivePanel] = useState<"form" | "editor">("form"); const [isInitialized, setIsInitialized] = useState(false); + const [segmentEditorValue, setSegmentEditorValue] = useState(""); + const [segmentEditorReady, setSegmentEditorReady] = useState(false); - - - // Helper function to reconstruct content with segmentation line breaks - const reconstructContentWithSegmentation = ( - content: string, - annotationData: any - ): string => { - if (!content) return ""; - - // Check if we have segmentation annotations - const segmentationAnnotations = annotationData?.data; - if ( - !segmentationAnnotations || - !Array.isArray(segmentationAnnotations) || - segmentationAnnotations.length === 0 - ) { - // No segmentation, return content as-is - return content; - } + useEffect(() => { + setSegmentEditorReady(false); + }, [edition_id, segmentationAnnotationId]); - // Sort annotations by span start position - const sortedAnnotations = [...segmentationAnnotations].sort( - (a: any, b: any) => { - return (a.span?.start || 0) - (b.span?.start || 0); - } + useEffect(() => { + if (!instance || segmentEditorReady) return; + if (segmentationAnnotationId && annotationLoading) return; + const raw = instance.content ?? ""; + setSegmentEditorValue( + reconstructContentWithSegmentation(raw, annotationData) ); - - // Extract each segment and join with newlines - const lines = sortedAnnotations.map((annotation: any) => { - if (!annotation.span) return ""; - return content.substring(annotation.span.start, annotation.span.end); - }); - - return lines.join("\n"); - }; + setSegmentEditorReady(true); + }, [ + instance, + annotationData, + annotationLoading, + segmentationAnnotationId, + segmentEditorReady, + ]); // Initialize forms with existing data useEffect(() => { @@ -96,16 +130,6 @@ const UpdateAnnotation = () => { instanceFormRef.current && (annotationData || !segmentationAnnotationId) // Wait for annotation if it exists ) { - // Initialize instance form and content with segmentation - if (instance.content) { - // Reconstruct content with line breaks from segmentation annotations - const formattedContent = reconstructContentWithSegmentation( - instance.content, - annotationData - ); - setEditedContent(formattedContent); - } - // Set instance metadata if (instance.metadata) { const meta = instance.metadata; @@ -254,7 +278,7 @@ const UpdateAnnotation = () => { {/* Editor Header */}
-
+
- +
+

+ Line breaks mark segment boundaries. The characters between breaks must match the edition text exactly. +

{/* Editor */} @@ -274,10 +333,20 @@ const UpdateAnnotation = () => { {isFetching ? ( ) : ( - +
+