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 deleted file mode 100644 index e8d8e227..00000000 --- a/backend/cataloger/controller/enum.py +++ /dev/null @@ -1,46 +0,0 @@ -import requests -from fastapi import HTTPException -import os -from dotenv import load_dotenv - -load_dotenv(override=True) - -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 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..4383473f --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/annotations.py @@ -0,0 +1,115 @@ +from typing import Any, Callable, Dict, List, Literal, 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() + + + +annotationTypes = Literal["alignments", "segmentations", "paginations", "bibliographic", "durchens"] +def delete_annotation(type: annotationTypes, annotation_id: str) -> Any: + url = openpecha_url(type, annotation_id) + r = requests.delete(url, headers=openpecha_headers(), timeout=60) + if r.status_code not in (200, 204): + raise HTTPException(status_code=r.status_code, detail=r.text) + return {"status": "deleted", "annotation_id": annotation_id} \ No newline at end of file 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..a1f1a20d --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/categories.py @@ -0,0 +1,111 @@ +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() + 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..e32982ee --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/instances.py @@ -0,0 +1,430 @@ +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_annotation, + 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( + edition_id: str, + *, + query_params: Optional[Dict[str, Any]] = None, + annotation: bool = True, + content: Any = True, +) -> Any: + _ = query_params + fetch_ann = annotation is not False + fetch_body = content is not False + try: + meta = fetch_edition_metadata(edition_id) + text_content = "" + if fetch_body: + text_content = fetch_edition_content(edition_id) + ann_bundle: Dict[str, Any] = {} + if fetch_ann: + ann_bundle = fetch_edition_annotation(edition_id=edition_id, type="segmentations") + 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", + "edition_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 { + "edition_id": ed.get("id"), + "metadata": { + "edition_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 get_edition_segmentations( + edition_id: str, + *, + timeout: int = 120, +) -> Any: + """GET /v2/editions/{id}/segmentations on OpenPecha.""" + try: + r = requests.get( + openpecha_url("editions", edition_id, "segmentations"), + headers=openpecha_headers(), + timeout=timeout, + ) + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=r.text) + try: + return r.json() + except ValueError: + return [] + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + +def post_edition_segmentations( + edition_id: str, + payload: Dict[str, Any] +) -> Any: + """POST /v2/editions/{id}/segmentations on OpenPecha.""" + try: + r = requests.post( + openpecha_url("editions", edition_id, "segmentations"), + json=payload, + headers=json_headers() + ) + if r.status_code not in (200, 201, 204): + raise HTTPException(status_code=r.status_code, detail=r.text) + if r.status_code == 204 or not (r.text or "").strip(): + return {"status": "ok"} + ct = (r.headers.get("content-type") or "").lower() + if "application/json" in ct: + try: + return r.json() + except ValueError: + return {"status": "ok", "raw": r.text} + return {"status": "ok", "raw": r.text} + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out", + ) + except requests.exceptions.RequestException as e: + raise HTTPException( + status_code=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + + +def openpecha_post_edition_alignments(edition_id: str, payload: Dict[str, Any]) -> Any: + try: + r = requests.post( + openpecha_url("editions", edition_id, "alignments"), + json=payload, + headers=json_headers() + ) + return r.json() + except HTTPException: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out", + ) +def openpecha_get_edition_alignments(edition_id: str) -> Any: + try: + r = requests.get( + openpecha_url("editions", edition_id, "alignments"), + headers=openpecha_headers() + ) + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=r.text) + return r.json() + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out", + ) + + +def update_instance_content(edition_id: str, content: str,start:int, end:int) -> Any: + try: + r = requests.patch( + openpecha_url("editions", edition_id, "content"), + json={"text": content, "start": start, "end": end,"type":"replace"}, + headers=json_headers(), + ) + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=r.text) + return r.json() + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out", + ) + + +def list_related_instances( + edition_id: str +) -> Any: + response = requests.get( + openpecha_url("editions", edition_id, "related"), + headers=openpecha_headers(), + ) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + data = response.json() + return data + + +def delete_edition(edition_id:str): + try: + r = requests.delete( + openpecha_url("editions", edition_id), + headers=openpecha_headers() + ) + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail=r.text) + return {"detail": "Edition deleted successfully"} + except HTTPException: + raise + except requests.exceptions.Timeout: + raise HTTPException( + status_code=504, + detail="Request to OpenPecha API timed out", + ) \ No newline at end of file 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/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/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/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..ddf749bd --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/texts.py @@ -0,0 +1,270 @@ +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 ( + edition_metadata_from_legacy, + edition_output_to_instance_list_item, + patch_text_payload_from_legacy, + segmentation_from_legacy_annotation_list, + text_output_to_legacy, + bibliographic_request_from_legacy, +) + + +def list_texts( + *, + limit: int = 30, + offset: int = 0, + language: Optional[str] = None, + author: 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) + raw = response.json() + 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=500, + detail=f"Error connecting to OpenPecha API: {str(e)}", + ) + + +def create_text(payload: Dict[str, Any], *, x_application: Optional[str] = None) -> Any: + + headers = json_headers(x_application=x_application) + try: + response = requests.post( + openpecha_url("texts"), + json=payload, + headers=headers, + ) + 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 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: Optional[Dict[str, Any]] = None + _seg_in = payload.get("segmentation") + if isinstance(_seg_in, dict) and _seg_in.get("segments"): + s: Dict[str, Any] = {"segments": _seg_in.get("segments") or []} + m = _seg_in.get("metadata") + if isinstance(m, dict) and m: + s["metadata"] = m + seg = s + if seg is None: + 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 + pag = payload.get("pagination") + if isinstance(pag, dict) and pag: + body["pagination"] = pag + 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) -> Dict[str, Any]: + kw: Dict[str, Any] = {"headers": openpecha_headers()} + r = requests.get(openpecha_url("editions", edition_id), **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) -> str: + kw: Dict[str, Any] = {"headers": openpecha_headers()} + + 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 + + +from typing import Literal, List + +AnnotationType = Literal["segmentations", "alignments", "paginations", "bibliographic","pagination", "durchens"] + +def fetch_edition_annotation( + edition_id: str, + type: AnnotationType = None, +) -> Dict[str, Any]: + if type is None: + raise HTTPException(status_code=400, detail="type is required") + r = requests.get(openpecha_url("editions", edition_id, type)) + 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..4a4f0462 --- /dev/null +++ b/backend/cataloger/controller/openpecha_api/v2_adapters.py @@ -0,0 +1,258 @@ +"""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 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"], + "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 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": ann_bundle, + } + + +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 deleted file mode 100644 index ba24076a..00000000 --- a/backend/cataloger/routers/aligner_data.py +++ /dev/null @@ -1,362 +0,0 @@ -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) - -router = APIRouter() - -API_ENDPOINT = os.getenv("OPENPECHA_ENDPOINT") - - -class Span(BaseModel): - start: int - end: int - - -class PreparedDataResponse(BaseModel): - source_text: str - target_text: str - has_alignment: bool - annotation_id: Optional[str] = None - annotation: Optional[Dict[str, Any]] = None - source_segmentation: Optional[List[Dict[str, Any]]] = None - target_segmentation: Optional[List[Dict[str, Any]]] = None - - -def apply_segmentation(text: str, segmentation_data: List[Dict[str, Any]]) -> str: - """Apply segmentation to text by inserting newlines at segment boundaries""" - if not segmentation_data or len(segmentation_data) == 0: - return text - - # Sort annotations by span start position - sorted_annotations = sorted( - segmentation_data, - key=lambda x: x.get("span", {}).get("start", 0) - ) - - # Extract each segment and join with newlines - segments = [] - for annotation in sorted_annotations: - span = annotation.get("span", {}) - if not span: - continue - start = span.get("start", 0) - end = span.get("end", len(text)) - if start < len(text) and end <= len(text) and start < end: - segments.append(text[start:end]) - - return "\n".join(segments) if segments else text - - -def reconstruct_segments( - target_annotation: List[Dict[str, Any]], - alignment_annotation: List[Dict[str, Any]], - source_text: str, - target_text: str -) -> tuple[List[str], List[str]]: - """Reconstruct segments from alignment annotations""" - # Sort target annotations by index, fallback to span start if index missing - sorted_target = sorted( - target_annotation, - key=lambda x: x.get("index", x.get("span", {}).get("start", 0)) - ) - - # Sort alignment annotations by index, fallback to span start if index missing - sorted_alignment = sorted( - alignment_annotation, - key=lambda x: x.get("index", x.get("span", {}).get("start", 0)) - ) - - # Reconstruct source segments from alignment annotations - source_segments = [] - for align_ann in sorted_alignment: - span = align_ann.get("span", {}) - if not span: - continue - start = span.get("start", 0) - end = span.get("end", len(source_text)) - if start < len(source_text) and end <= len(source_text) and start < end: - source_segments.append(source_text[start:end]) - - # Reconstruct target segments from target annotations - target_segments = [] - for target_ann in sorted_target: - span = target_ann.get("span", {}) - if not span: - continue - start = span.get("start", 0) - end = span.get("end", len(target_text)) - if start < len(target_text) and end <= len(target_text) and start < end: - target_segments.append(target_text[start:end]) - - return source_segments, target_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 - ) - 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 - ) - 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 - ) - - 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") - break - - # 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: - 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 - ) - 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 - ) - 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)}" - ) - - -@router.get("/prepare-alignment-data/{source_instance_id}/{target_instance_id}") -async def prepare_alignment_data( - source_instance_id: str, - target_instance_id: str -) -> PreparedDataResponse: - """ - Prepare alignment data by loading texts, checking for alignments, - and applying segmentation or reconstructing segments. - - 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) - - source_text = prepared_data["source_text"] - target_text = prepared_data["target_text"] - has_alignment = prepared_data["has_alignment"] - annotation_id = prepared_data.get("annotation_id") - annotation_data = prepared_data.get("annotation") - - - if has_alignment and annotation_data: - # Reconstruct segments from alignment annotations - target_annotation = annotation_data.get("target_annotation", []) - alignment_annotation = annotation_data.get("alignment_annotation", []) - - if ( - target_annotation - and isinstance(target_annotation, list) - and alignment_annotation - and isinstance(alignment_annotation, list) - ): - source_segments, target_segments = reconstruct_segments( - target_annotation, - alignment_annotation, - source_text, - target_text - ) - segmented_source_text = "\n".join(source_segments) - segmented_target_text = "\n".join(target_segments) - else: - # Apply segmentation - source_segmentation = prepared_data.get("source_segmentation_data") - target_segmentation = prepared_data.get("target_segmentation_data") - - if ( - source_segmentation - and isinstance(source_segmentation, list) - and len(source_segmentation) > 0 - ): - segmented_source_text = apply_segmentation(source_text, source_segmentation) - - if ( - target_segmentation - and isinstance(target_segmentation, list) - and len(target_segmentation) > 0 - ): - segmented_target_text = apply_segmentation(target_text, target_segmentation) - - # Get segmentation data for response - source_segmentation = None - target_segmentation = None - - if not has_alignment or not annotation_data: - # If no alignment annotation, return segmentation annotations - source_segmentation = prepared_data.get("source_segmentation_data") - target_segmentation = prepared_data.get("target_segmentation_data") - - return PreparedDataResponse( - source_text=source_text, - target_text=target_text, - has_alignment=has_alignment, - annotation_id=annotation_id, - annotation=annotation_data, - source_segmentation=source_segmentation, - target_segmentation=target_segmentation - ) - - except HTTPException: - raise - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Error preparing alignment data: {str(e)}" - ) - diff --git a/backend/cataloger/routers/alignment.py b/backend/cataloger/routers/alignment.py new file mode 100644 index 00000000..df82243d --- /dev/null +++ b/backend/cataloger/routers/alignment.py @@ -0,0 +1,164 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import List, Optional, Dict, Any + + +from cataloger.controller.openpecha_api.instances import ( + get_instance as openpecha_get_instance, + openpecha_get_edition_alignments, +) +from cataloger.controller.openpecha_api.annotations import delete_annotation as openpecha_delete_annotation + +router = APIRouter() + + +@router.delete("/alignment/{annotation_id}") +async def delete_alignment_annotation(annotation_id: str): + """Delete an alignment annotation on OpenPecha by id.""" + return openpecha_delete_annotation("alignments", annotation_id) + + + +class Span(BaseModel): + start: int + end: int + + +class PreparedDataResponse(BaseModel): + source_text: str + target_text: str + has_alignment: bool + annotation_id: Optional[str] = None + annotation: Optional[Dict[str, Any]] = None + + +def apply_segmentation(text: str, segmentation_data: List[Dict[str, Any]]) -> str: + """Apply segmentation to text by inserting newlines at segment boundaries""" + if not segmentation_data or len(segmentation_data) == 0: + return text + + # Sort annotations by span start position + sorted_annotations = sorted( + segmentation_data, + key=lambda x: x.get("span", {}).get("start", 0) + ) + + # Extract each segment and join with newlines + segments = [] + for annotation in sorted_annotations: + span = annotation.get("span", {}) + if not span: + continue + start = span.get("start", 0) + end = span.get("end", len(text)) + if start < len(text) and end <= len(text) and start < end: + segments.append(text[start:end]) + + return "\n".join(segments) if segments else text + + +def reconstruct_segments( + target_annotation: List[Dict[str, Any]], + alignment_annotation: List[Dict[str, Any]], + source_text: str, + target_text: str +) -> tuple[List[str], List[str]]: + """Reconstruct segments from alignment annotations""" + # Sort target annotations by index, fallback to span start if index missing + sorted_target = sorted( + target_annotation, + key=lambda x: x.get("index", x.get("span", {}).get("start", 0)) + ) + + # Sort alignment annotations by index, fallback to span start if index missing + sorted_alignment = sorted( + alignment_annotation, + key=lambda x: x.get("index", x.get("span", {}).get("start", 0)) + ) + + # Reconstruct source segments from alignment annotations + source_segments = [] + for align_ann in sorted_alignment: + span = align_ann.get("span", {}) + if not span: + continue + start = span.get("start", 0) + end = span.get("end", len(source_text)) + if start < len(source_text) and end <= len(source_text) and start < end: + source_segments.append(source_text[start:end]) + + # Reconstruct target segments from target annotations + target_segments = [] + for target_ann in sorted_target: + span = target_ann.get("span", {}) + if not span: + continue + start = span.get("start", 0) + end = span.get("end", len(target_text)) + if start < len(target_text) and end <= len(target_text) and start < end: + target_segments.append(target_text[start:end]) + + return source_segments, target_segments + + +def prepare_data(aligned_edition_id: str, root_edition_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""" + try: + aligned_instance = openpecha_get_instance( + edition_id=aligned_edition_id + ) + except HTTPException as e: + raise HTTPException( + status_code=e.status_code, + detail=f"Error fetching source instance: {e.detail}", + ) from e + + try: + root_instance = openpecha_get_instance( + edition_id=root_edition_id + ) + except HTTPException as e: + raise HTTPException( + status_code=e.status_code, + detail=f"Error fetching target instance: {e.detail}", + ) from e + root_text = root_instance.get("content", "") + aligned_text = aligned_instance.get("content", "") + try: + alignments_annotations= openpecha_get_edition_alignments(edition_id=aligned_edition_id) + + except HTTPException: + alignments_annotations = None + + + + + + return { + "source_text": aligned_text, + "target_text": root_text, + "has_alignment": alignments_annotations is not None, + "annotation": alignments_annotations, + } + + +@router.get("/{aligned_edition_id}/{root_edition_id}") +async def get_alignment( + aligned_edition_id: str, + root_edition_id: str +) : + + try: + # Prepare data (fetch instances, annotations, etc.) + prepared_data = prepare_data(aligned_edition_id, root_edition_id) + + + return prepared_data + + except HTTPException: + raise HTTPException( + status_code=500, + detail=f"Error preparing alignment data: {str(e)}" + ) + diff --git a/backend/cataloger/routers/annotation.py b/backend/cataloger/routers/annotation.py index 36fe2a78..06d42286 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 @@ -121,13 +120,14 @@ class AnnotationResponse(BaseModel): data: Union[AlignmentAnnotationData, List[SegmentationAnnotationItem], None] = None + + + + @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 +135,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() - -@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_update_annotation_body(annotation_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()) @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..f4eccaee 100644 --- a/backend/cataloger/routers/category.py +++ b/backend/cataloger/routers/category.py @@ -1,120 +1,56 @@ -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 - 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)"), - 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/editions.py b/backend/cataloger/routers/editions.py new file mode 100644 index 00000000..cf3f72dc --- /dev/null +++ b/backend/cataloger/routers/editions.py @@ -0,0 +1,117 @@ +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter +from pydantic import BaseModel, Field + +from cataloger.controller.openpecha_api.instances import ( + get_edition_segmentations as openpecha_get_edition_segmentations, + get_instance as openpecha_get_instance, + list_related_instances, + openpecha_get_edition_alignments, + openpecha_post_edition_alignments, + post_edition_segmentations as openpecha_post_edition_segmentations, + update_instance as openpecha_update_instance, + update_instance_content as openpecha_update_instance_content, + delete_edition as openpecha_delete_edition +) +from cataloger.routers.text import UpdateEdition + +router = APIRouter() + + +class SegmentationLineSpan(BaseModel): + start: int + end: int + + +class SegmentationSegmentBlock(BaseModel): + lines: List[SegmentationLineSpan] + + +class EditionSegmentationsBody(BaseModel): + segments: List[SegmentationSegmentBlock] + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class AlignedSegmentBlock(BaseModel): + lines: List[SegmentationLineSpan] + alignment_indices: List[int] + + +class EditionAlignmentsBody(BaseModel): + target_id: str + target_segments: List[SegmentationSegmentBlock] + aligned_segments: List[AlignedSegmentBlock] + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class UpdateEditionContentBody(BaseModel): + content: str + start: int + end: int + + +@router.put("/{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("/{edition_id}") +async def get_edition(edition_id: str, annotation: bool = True): + return openpecha_get_instance(edition_id, annotation=annotation, content=True) + +@router.get("/{edition_id}/segmentations") +async def list_edition_segmentations(edition_id: str): + return openpecha_get_edition_segmentations(edition_id) + + +@router.post("/{edition_id}/segmentations", status_code=201) +async def create_edition_segmentations( + edition_id: str, + body: EditionSegmentationsBody, +): + return openpecha_post_edition_segmentations( + edition_id, + body.model_dump(exclude_none=True), + ) + +@router.get("/{edition_id}/alignments") +async def get_edition_alignments(edition_id): + return openpecha_get_edition_alignments(edition_id) + + +@router.delete("/{edition_id}", status_code=204) +async def delete_edition(edition_id: str): + return openpecha_delete_edition(edition_id) + +@router.post("/{edition_id}/alignments", status_code=201) +async def create_edition_alignments( + edition_id: str, + body: EditionAlignmentsBody, +): + return openpecha_post_edition_alignments( + edition_id, + body.model_dump(exclude_none=True), + ) + + +@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: + edition_id: Source edition id + type: Optional filter by relationship type (root, commentary, translation) + """ + return list_related_instances(edition_id) + + +@router.put("/{edition_id}/content", status_code=200) +async def update_edition_content( + edition_id: str, + body: UpdateEditionContentBody, +): + return openpecha_update_instance_content( + edition_id, body.content, body.start, body.end + ) \ No newline at end of file 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/person.py b/backend/cataloger/routers/person.py index b916a4bb..a17425ee 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,53 +30,53 @@ 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 @router.get("", response_model=List[Person]) async def get_persons( + name: Optional[str] = None, + bdrc: Optional[str] = None, + wiki: Optional[str] = None, 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() + # Fetch persons with filtering if params supplied. + return openpecha_list_persons( + limit=limit, + offset=offset, + name=name, + bdrc=bdrc, + wiki=wiki, + ) + @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/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/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..b275bdfc 100644 --- a/backend/cataloger/routers/text.py +++ b/backend/cataloger/routers/text.py @@ -1,16 +1,22 @@ -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 +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) +load_dotenv(override=True) router = APIRouter() -API_ENDPOINT = os.getenv("OPENPECHA_ENDPOINT") TRANSLATION_BACKEND_URL = os.getenv("TRANSLATION_BACKEND_URL") class Contribution(BaseModel): @@ -22,21 +28,45 @@ class Contribution(BaseModel): class Text(BaseModel): id: str - type: str title: Dict[str, str] = Field( ..., - example={"bo": "དཔེ་མཚོན་ཞིག", "en": "Example Text"}, + example={"bo": "སྤྱོད་པའི་གླུ།"}, description="Title in multiple languages, keyed by language code" ) language: str - target: Optional[str] = None - contributions: Optional[List[Contribution]] = None - date: Optional[str] = None bdrc: Optional[str] = None wiki: Optional[str] = None + date: Optional[str] = None + alt_titles: Optional[List[Dict[str, str]]] = Field( + default=None, + example=[{"cmg": "yabudal-un dagulal kemegdeküi"}], + description="Alternative titles in multiple languages" + ) + commentary_of: Optional[str] = None + translation_of: Optional[str] = None + category_id: Optional[str] = None + license: Optional[str] = None + contributions: Optional[List[Contribution]] = None + commentaries: Optional[List[str]] = Field( + default=None, + description="List of commentary text IDs" + ) + translations: Optional[List[str]] = Field( + default=None, + description="List of translation text IDs" + ) + editions: Optional[List[str]] = Field( + default=None, + description="List of edition IDs" + ) + tag_ids: Optional[List[str]] = Field( + default=None, + description="List of tag IDs" + ) created_at: Optional[str] = None updated_at: Optional[str] = None + class TextResponse(BaseModel): results: List[Text] count: int @@ -46,25 +76,41 @@ class CreateTextResponse(BaseModel): id: str class CreateText(BaseModel): - type: str title: Dict[str, str] = Field( ..., - example={"bo": "དཔེ་མཚོན་ཞིག", "en": "Example Text"}, + example={"bo": "དཔེ་མཚོན་ཞིག"}, description="Title in multiple languages, keyed by language code" ) - language: str - contributions:List[Contribution] = [] - target: Optional[str] = None - date: Optional[str] = None - bdrc: Optional[str] = None - category_id: Optional[str] = None - alt_titles: List[Dict[str, str]] = Field( - default=[], - example=[{"bo": "མཚན་གཞན", "en": "Alternative Title"}], + alt_titles: Optional[List[Dict[str, str]]] = Field( + default=None, + example=[{"bo": "མཚན་གཞན"}], description="Alternative titles in multiple languages" ) - copyright: Optional[str] = None - license: Optional[str] = None + language: str = Field( + ..., + example="bo", + description="ISO 639-1 or 639-3 language code" + ) + bdrc: Optional[str] = Field(default=None, description="BDRC identifier") + wiki: Optional[str] = Field(default=None, description="Wikidata identifier") + date: Optional[str] = Field(default=None, example="1600", description="Date as string") + commentary_of: Optional[str] = Field(default=None, description="Text ID this is a commentary of") + translation_of: Optional[str] = Field(default=None, description="Text ID this is a translation of") + category_id: str = Field(..., description="Category ID for this text") + license: Optional[str] = Field(default=None, example="public", description="License of the text") + contributions: List[Dict[str, Optional[str]]] = Field( + ..., + example=[ + {"person_id": "string", "person_bdrc_id": "string", "role": "translator"}, + {"ai_id": "string", "role": "translator"} + ], + description="List of contributors with roles. Either 'person_id', 'person_bdrc_id', or 'ai_id', plus 'role'" + ) + tag_ids: Optional[List[str]] = Field( + default=None, + example=["tag1", "tag2"], + description="List of tag IDs for the text" + ) class UpdateText(BaseModel): @@ -76,6 +122,7 @@ class UpdateText(BaseModel): contributions: Optional[List[Contribution]] = None date: Optional[str] = None alt_title: Optional[Dict[str, List[str]]] = None + category_id: Optional[str] = None class Annotation(BaseModel): annotation_id: str @@ -93,7 +140,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 @@ -111,13 +158,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 @@ -135,21 +182,44 @@ class InstanceListItem(BaseModel): description="Alternative incipit titles in multiple languages" ) -class CreateInstance(BaseModel): +class Line(BaseModel): + start: int + end: int + +class Page(BaseModel): + lines: List[Line] + reference: Optional[str] = None + +class Volume(BaseModel): + index: int + pages: List[Page] + metadata: Dict[str, Any] = Field(default_factory=dict) + +class Pagination(BaseModel): + volumes: List[Volume] + metadata: Dict[str, Any] = Field(default_factory=dict) + +class Segment(BaseModel): + lines: List[Line] + +class Segmentation(BaseModel): + segments: List[Segment] + metadata: Dict[str, Any] = Field(default_factory=dict) + +class CreateEdition(BaseModel): metadata: Dict[str, Any] - annotation: List[Dict[str, Any]] - biblography_annotation: Optional[List[BibliographyAnnotation]] = None + pagination: Optional[Pagination] = None + segmentation: Optional[Segmentation] = 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 @@ -163,243 +233,44 @@ 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"), ): - 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, + title=title, + category_id=category_id, + ) @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)}" - ) - -@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)}" - ) - -@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)}" - ) - - -@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)}" - ) - -@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_update_text(id, text.model_dump(exclude_none=True)) + +@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"] + if isinstance(data, list): + return data + return [] + +@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) 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 deleted file mode 100644 index df07ed6a..00000000 --- a/backend/cataloger/routers/translation.py +++ /dev/null @@ -1,224 +0,0 @@ -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel, Field -from typing import List, Optional, Dict, Any -import requests -import os -from dotenv import load_dotenv - - -load_dotenv(override=True) - -router = APIRouter() - -API_ENDPOINT = os.getenv("OPENPECHA_ENDPOINT") -TRANSLATION_BACKEND_URL = os.getenv("TRANSLATION_BACKEND_URL") - - -class Span(BaseModel): - start: int - end: int - - -class Author(BaseModel): - person_id: Optional[str] = None - person_bdrc_id: Optional[str] = None - - -class Segmentation(BaseModel): - span: Span - - -class TargetAnnotation(BaseModel): - span: Span - index: int - - -class AlignmentAnnotation(BaseModel): - span: Span - index: int - alignment_index: List[int] - - -class BibliographyAnnotation(BaseModel): - span: Span - type: str - - -class CreateTranslation(BaseModel): - language: str - content: str - title: str - source: str - alt_titles: Optional[List[str]] = None - author: Optional[Author] = None - segmentation: List[Segmentation] - target_annotation: Optional[List[TargetAnnotation]] = None - alignment_annotation: Optional[List[AlignmentAnnotation]] = None - copyright: str - license: str - biblography_annotation: Optional[List[BibliographyAnnotation]] = None - user: Optional[str] = None - - -class CreateCommentary(BaseModel): - language: str - content: str - title: str - source: str - alt_titles: Optional[List[str]] = None - author: Optional[Author] = None - segmentation: List[Segmentation] - target_annotation: Optional[List[TargetAnnotation]] = None - alignment_annotation: Optional[List[AlignmentAnnotation]] = None - copyright: str - license: str - bdrc: Optional[str] = None - category_id: Optional[str] = None - biblography_annotation: Optional[List[BibliographyAnnotation]] = None - user: Optional[str] = None - - -class TranslationResponse(BaseModel): - message: str - instance_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 - copyright: str - text_id: str - title: Dict[str, str] - alt_titles: List[Dict[str, str]] = [] - language: str - contributions: List[Contribution] = [] - - -class RelatedInstance(BaseModel): - instance_id: str - metadata: RelatedInstanceMetadata - 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""" - 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() - - - -@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)}" - ) - - -@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)}" - ) - - -@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() - - diff --git a/backend/main.py b/backend/main.py index 52503475..fb34db21 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, editions,annotation, bdrc, category, language, role, tokenize, alignment, admin, segments from outliner.routers import router as outliner_router from settings.routers import ( tenant_router, @@ -73,13 +73,14 @@ 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(editions.router, prefix="/editions", tags=["editions"]) 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(alignment.router, prefix="/aligner", tags=["aligner"]) app.include_router(admin.router, prefix="/admin", tags=["admin"]) app.include_router(outliner_router, prefix="/outliner", tags=["outliner"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6382232a..7ddaae05 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,17 @@ -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'; +import { UpdateTextForm } from './features/cataloger/components/UpdateTextForm'; +import CreateEdition from './pages/CreateEdition'; // 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 +135,37 @@ function App() { } /> - - + } /> - - + } /> - + + + } /> + + + + } /> + } /> - } /> - diff --git a/frontend/src/api/annotation.ts b/frontend/src/api/annotation.ts index e9613802..95c0cc30 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 +export const fetchAlignment = async ( + aligned_edition_id: string, + root_edition_id: string ): Promise => { const response = await fetch( - `${API_URL}/aligner-data/prepare-alignment-data/${source_instance_id}/${target_instance_id}`, + `${API_URL}/aligner/${aligned_edition_id}/${root_edition_id}`, { headers: { 'accept': 'application/json', @@ -139,3 +139,25 @@ export const fetchPreparedAlignmentData = async ( return await handleApiResponse(response) as PreparedDataResponse; }; +/** + * Delete an alignment annotation by OpenPecha annotation id. + */ +export const deleteAlignmentAnnotation = async ( + annotationId: string +): Promise<{ status: string; annotation_id: string }> => { + const response = await fetch( + `${API_URL}/aligner/alignment/${encodeURIComponent(annotationId)}`, + { + method: 'DELETE', + headers: { + accept: 'application/json', + }, + } + ); + + return await handleApiResponse(response) as { + status: string; + annotation_id: string; + }; +}; + 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/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}/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..b0952e32 --- /dev/null +++ b/frontend/src/api/role.ts @@ -0,0 +1,21 @@ +import { API_URL } from '@/config/api'; + +export interface RoleItem { + name: string; + description?: string; +} + +export interface RolesResponse { + items: RoleItem[]; +} + +export const fetchRole = async (): Promise => { + return { + items: [ + { name: "translator" }, + { name: "reviser" }, + { name: "author" }, + { name: "scholar" } + ] + }; +}; diff --git a/frontend/src/api/text.ts b/frontend/src/api/text.ts index 2b4b0765..f76ff1bf 100644 --- a/frontend/src/api/text.ts +++ b/frontend/src/api/text.ts @@ -128,13 +128,13 @@ export const createText = async (textData: { }; export const fetchTextInstances = async (id: 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}/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 9ae14658..5a664b01 100644 --- a/frontend/src/api/texts.ts +++ b/frontend/src/api/texts.ts @@ -1,6 +1,14 @@ import { API_URL } from '@/config/api'; -import type { OpenPechaText, OpenPechaTextInstance, OpenPechaTextInstanceListItem, CreateInstanceResponse } from '@/types/text'; +import type { + OpenPechaText, + OpenPechaTextInstance, + OpenPechaTextInstanceListItem, + CreateInstanceResponse, + CreateTextPayload, + CreateTextResponse, + UpdateTextPayload, +} from '@/types/text'; // Helper function to handle API responses with better error messages const handleApiResponse = async (response: Response, customMessages?: { 400?: string; 404?: string; 500?: string }) => { @@ -71,20 +79,23 @@ const handleApiResponse = async (response: Response, customMessages?: { 400?: st if (contentType && contentType.includes('application/json')) { return await response.json(); } else { + if (response.status === 204) { + return null; + } throw new Error('The server returned an invalid response. Please contact support if this persists.'); } }; // 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`; @@ -139,7 +150,9 @@ export const fetchTextsByTitle = async (title: string, signal?: AbortSignal): Pr // Real API function for creating texts -export const createText = async (textData: any): Promise => { +export const createText = async ( + textData: CreateTextPayload +): Promise => { try { const response = await fetch(`${API_URL}/text`, { method: 'POST', @@ -162,36 +175,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}/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 +213,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 +250,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 +270,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', @@ -297,6 +310,37 @@ export const updateAnnotation = async (annotationId: string, annotationData: any } }; +export type UpdateEditionContentPayload = { + content: string; + start: number; + end: number; +}; + +export const updateEditionContent = async ( + editionId: string, + payload: UpdateEditionContentPayload +): Promise => { + try { + const response = await fetch(`${API_URL}/editions/${editionId}/content`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + return await handleApiResponse(response, { + 400: 'Invalid content update. Please check the text 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 edition content. Please check your connection and try again.'); + } +}; + export const updateInstance = async (textId: string, instanceId: string, instanceData: any): Promise => { try { @@ -304,7 +348,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}/editions/${instanceId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -313,18 +357,167 @@ 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 edition. Please check your connection and try again.'); + } +}; + +export const deleteEdition = async (editionId: string): Promise => { + try { + const response = await fetch(`${API_URL}/editions/${editionId}`, { + method: 'DELETE', + }); + await handleApiResponse(response, { + 404: 'Edition not found. It may have already been deleted.', + }); + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error('Unable to delete edition. Please check your connection and try again.'); + } +}; + +export type EditionSegmentationLineSpan = { start: number; end: number }; + +export type EditionSegmentationSegment = { lines: EditionSegmentationLineSpan[] }; + +export type EditionSegmentationsPayload = { + segments: EditionSegmentationSegment[]; + metadata?: Record; +}; + +/** One stored segmentation variant from GET /editions/{id}/segmentations */ +export type EditionSegmentationListItem = { + id: string; + segments: Array<{ + id?: string; + lines: EditionSegmentationLineSpan[]; + }>; +}; + +export const fetchEditionSegmentations = async ( + editionId: string +): Promise => { + try { + const response = await fetch(`${API_URL}/editions/${editionId}/segmentations`); + if (response.status === 404) { + return []; + } + const data = await handleApiResponse(response, { + 404: 'Edition not found.', }); + return Array.isArray(data) ? (data as EditionSegmentationListItem[]) : []; + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error('Unable to load segmentation list. Please check your connection and try again.'); + } +}; + +export type EditionAlignmentTargetSegment = { lines: EditionSegmentationLineSpan[] }; + +export type EditionAlignmentAlignedSegment = { + lines: EditionSegmentationLineSpan[]; + alignment_indices: number[]; +}; + +export type EditionAlignmentsPayload = { + target_id: string; + target_segments: EditionAlignmentTargetSegment[]; + aligned_segments: EditionAlignmentAlignedSegment[]; + metadata?: Record; +}; + +export const postEditionAlignments = async ( + editionId: string, + payload: EditionAlignmentsPayload +): Promise => { + try { + const response = await fetch(`${API_URL}/editions/${editionId}/alignments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + target_id: payload.target_id, + target_segments: payload.target_segments, + aligned_segments: payload.aligned_segments, + metadata: payload.metadata ?? {}, + }), + }); + if (!response.ok) { + return await handleApiResponse(response, { + 400: 'Invalid alignment data. Check line boundaries and try again.', + 404: 'Edition not found.', + }); + } + const raw = await response.text(); + if (!raw.trim()) { + return {}; + } + try { + return JSON.parse(raw) as unknown; + } catch { + return { raw }; + } + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error('Unable to save alignments. Please check your connection and try again.'); + } +}; + +export const postEditionSegmentations = async ( + editionId: string, + payload: EditionSegmentationsPayload +): Promise => { + try { + const response = await fetch(`${API_URL}/editions/${editionId}/segmentations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + segments: payload.segments, + metadata: payload.metadata ?? {}, + }), + }); + if (!response.ok) { + return await handleApiResponse(response, { + 400: 'Invalid segmentation data. Check line boundaries and try again.', + 404: 'Edition not found.', + }); + } + const raw = await response.text(); + if (!raw.trim()) { + return {}; + } + try { + return JSON.parse(raw) as unknown; + } catch { + return { raw }; + } } 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 save segmentations. Please check your connection and try again.'); } }; -export const updateText = async (textId: string, textData: any): Promise => { +export const updateText = async ( + textId: string, + textData: UpdateTextPayload +): Promise => { try { const response = await fetch(`${API_URL}/text/${textId}`, { method: 'PUT', @@ -360,14 +553,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..44a7e21d 100644 --- a/frontend/src/app/BreadCrumb.tsx +++ b/frontend/src/app/BreadCrumb.tsx @@ -10,6 +10,7 @@ import { BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; import { useTranslation } from 'react-i18next'; +import { useEdition, useText } from '@/hooks/useTexts'; interface BreadcrumbItemType { label: string; @@ -28,8 +29,6 @@ interface BreadCrumbProps { const BreadCrumb: React.FC = ({ items, className = '', - textname, - instancename, personname, }) => { const { t, i18n } = useTranslation(); @@ -37,6 +36,13 @@ const BreadCrumb: React.FC = ({ const params = useParams(); const currentLanguage = i18n.language; const isTibetan = currentLanguage === 'bo'; + const text_id = params.text_id; + const edition_id = params.edition_id; + const { data: text, isFetched: isTextFetched } = useText(text_id || ''); + const { data: edition, isFetched: isEditionFetched } = useEdition(edition_id || ''); + const textname = text?.title.tib || text?.title.bo || text?.title.en || "Content"; + + const editiontype=edition?.metadata.type; const generateBreadcrumbs = (): BreadcrumbItemType[] => { const pathSegments = location.pathname.split('/').filter(Boolean); @@ -49,19 +55,19 @@ const BreadCrumb: React.FC = ({ icon: , }); - if (params.text_id && textname) { + if (textname && text_id) { breadcrumbs.push({ label: textname, - href: `/texts/${params.text_id}/instances`, + href: `/texts/${params.text_id}/editions`, + }); + + } + if (params.edition_id &&editiontype) { + breadcrumbs.push({ + label: editiontype, + icon: , }); - if (pathSegments.includes('instances')) { - if (params.instance_id && instancename) { - breadcrumbs.push({ - label: instancename, - icon: , - }); - } - } + } } else if (pathSegments.includes('persons') && personname) { breadcrumbs.push({ diff --git a/frontend/src/components/Aligner/components/AlignmentWorkstation.tsx b/frontend/src/components/Aligner/components/AlignmentWorkstation.tsx index be59136a..a2d75afa 100644 --- a/frontend/src/components/Aligner/components/AlignmentWorkstation.tsx +++ b/frontend/src/components/Aligner/components/AlignmentWorkstation.tsx @@ -10,7 +10,27 @@ import FontSizeSelector from './FontSizeSelector'; import { prepareData } from '../utils/prepare_data'; import { reconstructSegments } from '../utils/generateAnnotation'; import { applySegmentation } from '../../../lib/annotation'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { normalizeAlignmentPayload } from '../utils/normalizeAlignments'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { deleteAlignmentAnnotation } from '../../../api/annotation'; +import { toast } from 'sonner'; +import { Trash2 } from 'lucide-react'; type AnnotationFromBackend = { id?: string | null; @@ -38,25 +58,15 @@ function AlignmentWorkstationContent() { const [sourceFontSize, setSourceFontSize] = useState(20); const [targetFontSize, setTargetFontSize] = useState(20); + const [selectedAlignmentIndex, setSelectedAlignmentIndex] = useState(0); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const queryClient = useQueryClient(); const { selectionHandler, } = useMappingState(); - // Use Zustand store for text state - const { - isSourceLoaded, - isTargetLoaded, - setSourceText, - setTargetText, - setLoadingAnnotations, - setAnnotationsApplied, - setHasAlignment, - setAnnotationData, - setAnnotationId, - sourceTextId, - } = useTextSelectionStore(); - const { data: preparedData, isLoading: isFetchingPreparedData, @@ -72,27 +82,46 @@ function AlignmentWorkstationContent() { }, enabled: !!sourceInstanceId && !!targetInstanceId, }); - const processAlignedData = React.useCallback((data: PreparedData) => { - const sourceText = data.source_text; - const targetText = data.target_text; - const annotationData = data.annotation; - if (annotationData?.target_annotation && annotationData?.alignment_annotation) { - setLoadingAnnotations(true, "Reconstructing segments..."); - setHasAlignment(true); - if ('annotation_id' in data && data.annotation_id) { - setAnnotationId(data.annotation_id); - } - setAnnotationData({ + const deleteAlignmentMutation = useMutation({ + mutationFn: deleteAlignmentAnnotation, + onSuccess: () => { + toast.success('Alignment deleted'); + setDeleteDialogOpen(false); + queryClient.invalidateQueries({ + queryKey: ['preparedData', sourceInstanceId, targetInstanceId], + }); + }, + onError: (err: Error) => { + toast.error(err.message || 'Failed to delete alignment'); + }, + }); + + const applyAlignedAnnotation = React.useCallback( + ( + sourceText: string, + targetText: string, + annotationData: NonNullable, + annotationId: string | null + ) => { + if (!annotationData?.target_annotation || !annotationData?.alignment_annotation) return; + + const store = useTextSelectionStore.getState(); + store.setLoadingAnnotations(true, "Reconstructing segments..."); + store.setHasAlignment(true); + store.setAnnotationId(annotationId); + store.setAnnotationData({ target_annotation: annotationData.target_annotation, alignment_annotation: annotationData.alignment_annotation, }); const targetAnnotationForReconstruction = (annotationData.target_annotation || []).map( - (ann: AnnotationFromBackend | null) => ann ? { ...ann, index: ann.index == null ? undefined : String(ann.index) } : null + (ann: AnnotationFromBackend | null) => + ann ? { ...ann, index: ann.index == null ? undefined : String(ann.index) } : null ); const alignmentAnnotationForReconstruction = (annotationData.alignment_annotation || []).map( - (ann: AnnotationFromBackend | null) => ann ? { ...ann, index: ann.index == null ? undefined : String(ann.index) } : null + (ann: AnnotationFromBackend | null) => + ann ? { ...ann, index: ann.index == null ? undefined : String(ann.index) } : null ); const { source, target } = reconstructSegments( @@ -101,30 +130,21 @@ function AlignmentWorkstationContent() { sourceText, targetText ); - setLoadingAnnotations(true, "Finalizing text display..."); + store.setLoadingAnnotations(true, "Finalizing text display..."); const segmentedSourceText = source.join("\n"); const segmentedTargetText = target.join("\n"); - setSourceText( - sourceTextId || '', - sourceInstanceId!, - segmentedSourceText, - "database" - ); - setTargetText( - `related-${targetInstanceId!}`, - targetInstanceId!, - segmentedTargetText, - "database" - ); - setAnnotationsApplied(true); - setLoadingAnnotations(false); - } - }, [setLoadingAnnotations, setHasAlignment, setAnnotationId, setAnnotationData, setSourceText, sourceTextId, sourceInstanceId, setTargetText, targetInstanceId, setAnnotationsApplied]); - + store.setSourceText(sourceInstanceId!, sourceInstanceId!, segmentedSourceText, "database"); + store.setTargetText(`related-${targetInstanceId!}`, targetInstanceId!, segmentedTargetText, "database"); + store.setAnnotationsApplied(true); + store.setLoadingAnnotations(false); + }, + [sourceInstanceId, targetInstanceId] + ); const processUnalignedData = React.useCallback((data: PreparedData) => { + const store = useTextSelectionStore.getState(); const sourceText = data.source_text; const targetText = data.target_text; - setLoadingAnnotations(true, "Applying segmentation..."); + store.setLoadingAnnotations(true, "Applying segmentation..."); const sourceSegmentation = data.source_segmentation as Array<{ span: { start: number; end: number } }> | undefined; const targetSegmentation = data.target_segmentation as Array<{ span: { start: number; end: number } }> | undefined; @@ -139,48 +159,138 @@ function AlignmentWorkstationContent() { segmentedTargetText = applySegmentation(targetText, targetSegmentation); } - setHasAlignment(false); - setSourceText( - sourceTextId || '', - sourceInstanceId!, - segmentedSourceText, - "database" - ); - setTargetText( - `related-${targetInstanceId!}`, - targetInstanceId!, - segmentedTargetText, - "database" - ); - setAnnotationsApplied(true); - setLoadingAnnotations(false); - }, [setLoadingAnnotations, setHasAlignment, setSourceText, sourceTextId, sourceInstanceId, setTargetText, targetInstanceId, setAnnotationsApplied]); + store.setHasAlignment(false); + store.setSourceText(sourceInstanceId!, sourceInstanceId!, segmentedSourceText, "database"); + store.setTargetText(`related-${targetInstanceId!}`, targetInstanceId!, segmentedTargetText, "database"); + store.setAnnotationsApplied(true); + store.setLoadingAnnotations(false); + }, [sourceInstanceId, targetInstanceId]); + + /** API reports alignment container exists but list is empty — show full plain texts for first alignment. */ + const processRawFullTextForNewAlignment = React.useCallback((data: PreparedData) => { + const store = useTextSelectionStore.getState(); + store.setLoadingAnnotations(true, "Loading texts..."); + store.setHasAlignment(false); + store.setAnnotationId(null); + store.setAnnotationData(null); + store.setSourceText(sourceInstanceId!, sourceInstanceId!, data.source_text, "database"); + store.setTargetText(`related-${targetInstanceId!}`, targetInstanceId!, data.target_text, "database"); + store.setAnnotationsApplied(true); + store.setLoadingAnnotations(false); + }, [sourceInstanceId, targetInstanceId]); React.useEffect(() => { + setSelectedAlignmentIndex(0); + }, [sourceInstanceId, targetInstanceId]); + + React.useEffect(() => { + const store = useTextSelectionStore.getState(); if (isFetchingPreparedData) { - setLoadingAnnotations(true, "Initializing..."); + store.setLoadingAnnotations(true, "Initializing..."); return; } if (isError) { console.error("Error loading data:", error); - setLoadingAnnotations(false); + store.setLoadingAnnotations(false); + return; + } + + if (!preparedData) return; + + if (!preparedData.has_alignment) { + processUnalignedData(preparedData); return; } - if (preparedData) { - if (preparedData.has_alignment) { - processAlignedData(preparedData); - } else { - processUnalignedData(preparedData); + if ( + Array.isArray(preparedData.annotation) && + preparedData.annotation.length === 0 + ) { + processRawFullTextForNewAlignment(preparedData); + return; + } + + const variants = normalizeAlignmentPayload(preparedData.annotation); + const maxIdx = Math.max(0, variants.length - 1); + const idx = Math.min(selectedAlignmentIndex, maxIdx); + const chosen = variants[idx]; + + if (chosen) { + let fallbackId: string | null = preparedData.annotation_id ?? null; + const ann = preparedData.annotation; + if (fallbackId == null && ann && typeof ann === "object" && !Array.isArray(ann)) { + const rid = (ann as Record).id; + if (typeof rid === "string") fallbackId = rid; } + applyAlignedAnnotation( + preparedData.source_text, + preparedData.target_text, + chosen.data as unknown as NonNullable, + chosen.id ?? fallbackId + ); + return; + } + + // Legacy: annotation present but normalize failed — try raw single object + const raw = preparedData.annotation; + if ( + raw && + typeof raw === "object" && + !Array.isArray(raw) && + Array.isArray((raw as { target_annotation?: unknown }).target_annotation) && + Array.isArray((raw as { alignment_annotation?: unknown }).alignment_annotation) + ) { + const legacy = raw as NonNullable; + applyAlignedAnnotation( + preparedData.source_text, + preparedData.target_text, + legacy, + preparedData.annotation_id ?? null + ); + return; } - }, [preparedData, isFetchingPreparedData, isError, error, processAlignedData, processUnalignedData, setLoadingAnnotations]); - const bothTextLoaded = isSourceLoaded && isTargetLoaded; + console.warn("Alignment data present but could not be parsed"); + useTextSelectionStore.getState().setLoadingAnnotations(false); + }, [ + preparedData, + isFetchingPreparedData, + isError, + error, + selectedAlignmentIndex, + applyAlignedAnnotation, + processUnalignedData, + processRawFullTextForNewAlignment, + ]); + + const alignmentVariants = + preparedData?.has_alignment && preparedData.annotation != null + ? normalizeAlignmentPayload(preparedData.annotation) + : []; + const alignmentSelectOptions = + alignmentVariants.length > 1 ? alignmentVariants : []; + + const maxVariantIdx = Math.max(0, alignmentVariants.length - 1); + const safeVariantIdx = Math.min(selectedAlignmentIndex, maxVariantIdx); + const selectedVariant = alignmentVariants[safeVariantIdx]; + let fallbackAnnotationId: string | null = preparedData?.annotation_id ?? null; + const annRaw = preparedData?.annotation; + if ( + fallbackAnnotationId == null && + annRaw && + typeof annRaw === 'object' && + !Array.isArray(annRaw) + ) { + const rid = (annRaw as Record).id; + if (typeof rid === 'string') fallbackAnnotationId = rid; + } + const deleteAnnotationId = + selectedVariant?.id ?? fallbackAnnotationId ?? null; // Render main content - always show editor view + const type= useTextSelectionStore.getState().targetType; // text info from instance id @@ -193,24 +303,65 @@ function AlignmentWorkstationContent() { ); } - if (!bothTextLoaded) { - return ( -
-
-
-
-
-
Loading texts...
-
-
- ); - } + const annotationListEmpty = + Boolean(preparedData?.has_alignment) && + Array.isArray(preparedData?.annotation) && + preparedData.annotation.length === 0; + + return ( -
+
+ {annotationListEmpty && ( +
+

Create alignment

+

+ Full source and target texts appear below with no alignment markup. Select matching + spans in both columns, build mappings in the sidebar, then publish to save your first + alignment. +

+
+ )} + + + + + + + Delete this alignment? + + This removes the alignment from OpenPecha. You can recreate it later if needed. + + + + + + + + - {/* Always show editor view */}
+ + {isFetchingPreparedData ? + : {/* Source Editor Panel */}
-

Source Text

+

Text

-

Target Text

+

{type}

- - - -
+ } +
+ + {alignmentVariants.length > 0 && ( +
+ {alignmentSelectOptions.length > 0 && ( + <> + Alignment + + + )} + {deleteAnnotationId ? ( + + ) : null} +
+ )}
); } @@ -304,3 +490,68 @@ function AlignmentWorkstation() { export default AlignmentWorkstation; + + + +function PanelLoader() { + // Skeleton loader for each pane, including the horizontal "Info/Sidebar" section below the editors + return ( +
+ {/* PanelGroup: Editors */} +
+ {/* Source Pane Skeleton */} +
+ {/* Header */} +
+
+
+
+ {/* Lines of fake text */} +
+
+
+
+
+
+
+
+ + {/* Target Pane Skeleton */} +
+ {/* Header */} +
+
+
+
+ {/* Lines of fake text */} +
+
+
+
+
+
+
+
+
+ {/* Horizontal Bottom Pane (MappingSidebar/Info) Skeleton */} +
+ {/* Example sections for mapping/sidebar info slots */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Aligner/components/MappingSidebar.tsx b/frontend/src/components/Aligner/components/MappingSidebar.tsx index 24e1491c..3aaed926 100644 --- a/frontend/src/components/Aligner/components/MappingSidebar.tsx +++ b/frontend/src/components/Aligner/components/MappingSidebar.tsx @@ -1,12 +1,10 @@ import { useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useEditorContext } from "../context"; import { useTextSelectionStore } from "../../../stores/textSelectionStore"; -import { createAnnotation, updateAnnotation } from "../../../api/text"; -import type { Annotations } from "../../../types/text"; import { useTranslation } from "react-i18next"; -import { generateAlignment } from "../utils/generateAnnotation"; import { toast } from "sonner"; +import segmentsToAlignmentPayload from "@/features/cataloger/lib/alignment_generator"; +import { usePostEditionAlignments } from "@/hooks/useTexts"; const MappingSidebar = () => { const { t } = useTranslation(); @@ -16,106 +14,14 @@ const MappingSidebar = () => { sourceInstanceId, targetInstanceId, hasAlignment, - sourceText, - targetText, - annotationId, - clearAllSelections, - resetAllSelections, } = useTextSelectionStore(); - const queryClient = useQueryClient(); - // Local state for success/error messages + const createAlignmentMutation = usePostEditionAlignments(); + const [saveSuccess, setSaveSuccess] = useState(null); const [saveError, setSaveError] = useState(null); - const [showConfirmModal, setShowConfirmModal] = useState(false); - // React Query mutation for creating annotation - const createAnnotationMutation = useMutation< - Annotations, - Error, - { - inferenceId: string; - annotationData: { - type: string; - target_manifestation_id: string; - target_annotation: Array<{ - span: { start: number; end: number }; - index: number; - }>; - alignment_annotation: Array<{ - span: { start: number; end: number }; - index: number; - alignment_index: number[]; - }>; - }; - } - >({ - mutationFn: async ({ inferenceId, annotationData }) => { - return await createAnnotation(inferenceId, annotationData); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["relatedInstances"] }); - toast.success("alignmentSavedSuccess"); - window.location.reload(); - }, - onError: (error) => { - console.error(t("mapping.savingAlignmentFailed"), error); - setSaveError(error.message || t("mapping.failedToSaveAlignment")); - setSaveSuccess(null); - }, - }); - - // React Query mutation for updating annotation - const updateAnnotationMutation = useMutation< - Annotations, - Error, - { - annotationId: string; - annotationData: { - type: string; - target_manifestation_id: string; - target_annotation: Array<{ - span: { start: number; end: number }; - index: number; - }>; - alignment_annotation: Array<{ - span: { start: number; end: number }; - index: number; - alignment_index: number[]; - }>; - }; - } - >({ - mutationFn: async ({ annotationId, annotationData }) => { - return await updateAnnotation(annotationId, annotationData); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["relatedInstances"] }); - toast.success("Alignment updated successfully"); - window.location.reload(); - }, - onError: (error) => { - console.error("Failed to update alignment:", error); - setSaveError( - error.message || - t("mapping.failedToUpdateAlignment") || - "Failed to update alignment" - ); - setSaveSuccess(null); - }, - }); - - // Handle confirmation and proceed with save - const handleConfirmSave = () => { - setShowConfirmModal(false); - // Small delay to allow modal to close before showing loading overlay - setTimeout(() => { - proceedWithSave(); - }, 100); - }; - // Handle saving alignment annotation const handleSave = () => { - // Show custom confirmation modal - setShowConfirmModal(true); + proceedWithSave(); }; const proceedWithSave = () => { @@ -124,153 +30,51 @@ const MappingSidebar = () => { return; } - // Validate content before saving if (!isContentValid()) { setSaveError(t("mapping.contentModifiedError")); return; } - // Get source and target content from editors, with fallback to store - const editorSourceContent = getSourceContent(); - const editorTargetContent = getTargetContent(); - // Use editor content if available, otherwise fallback to store content - const sourceContent = editorSourceContent ?? sourceText ?? ""; - const targetContent = editorTargetContent ?? targetText ?? ""; - - if (!sourceContent || !targetContent) { - setSaveError(t("mapping.sourceAndTargetContentRequired")); - return; - } - const sourceSegments = sourceContent.split("\n"); - const targetSegments = targetContent.split("\n"); - - const spans = generateAlignment(sourceSegments, targetSegments); - - // Use update if hasAlignment and annotationId exists, otherwise create - if (hasAlignment) { - // Convert string indices to numbers for updateAnnotation API - const targetAnnotation = spans.target_annotation - .filter((item) => item.index !== undefined && item.index !== null) - .map((item) => ({ - span: item.span, - index: - typeof item.index === "string" - ? Number.parseInt(item.index, 10) - : item.index ?? 0, - })); - - const alignmentAnnotation = spans.alignment_annotation - .filter( - (item) => - item.index !== undefined && - item.index !== null && - item.alignment_index !== undefined - ) - .map((item) => ({ - span: item.span, - index: - typeof item.index === "string" - ? Number.parseInt(item.index, 10) - : item.index ?? 0, - alignment_index: (item.alignment_index ?? []).map((idx) => - typeof idx === "string" ? Number.parseInt(idx, 10) : idx - ), - })); - updateAnnotationMutation.mutate({ - annotationId, - annotationData: { - type: "alignment", - target_manifestation_id: sourceInstanceId, - target_annotation: targetAnnotation, - alignment_annotation: alignmentAnnotation, + const sourceContent = getSourceContent(); + const targetContent = getTargetContent(); + // URL source = aligned edition text; URL target = root edition text. + // API target_segments are on the root edition; aligned_segments on the aligned edition. + const payload = segmentsToAlignmentPayload({ + targetId: targetInstanceId, + targetTexts: targetContent?.split("\n"), + alignedTexts: sourceContent?.split("\n"), + metadata: {}, + }); + + createAlignmentMutation.mutate( + { + editionId: sourceInstanceId, + rootEditionId: targetInstanceId, + payload, + }, + { + onSuccess: () => { + setSaveError(null); + const msg = hasAlignment + ? "Alignment updated successfully." + : "Alignment published successfully."; + setSaveSuccess(msg); + toast.success(msg); }, - }); - } else { - // Convert to number format for createAnnotation - const checkCondition = (d) => d.span.start < d.span.end; - const createTargetAnnotation = spans.target_annotation - .filter((item) => item.index !== undefined && item.index !== null) - .filter(checkCondition) - .map((item) => ({ - span: item.span, - index: - typeof item.index === "string" - ? Number.parseInt(item.index, 10) - : item.index ?? 0, - })); - - const createAlignmentAnnotation = spans.alignment_annotation - .filter( - (item) => - item.index !== undefined && - item.index !== null && - item.alignment_index !== undefined - ) - .filter(checkCondition) - .map((item) => ({ - span: item.span, - index: - typeof item.index === "string" - ? Number.parseInt(item.index, 10) - : item.index ?? 0, - alignment_index: (item.alignment_index ?? []).map((idx) => - typeof idx === "string" ? Number.parseInt(idx, 10) : idx - ), - })); - function filterAlignedArrays(arr1, arr2) { - // Collect all indices from arr1 - const arr1IndicesSet = new Set(); - for (const item of arr1) { - arr1IndicesSet.add(item.index); - } - - // Collect all indices that appear in arr2's alignment_index arrays - const alignmentIndicesSet = new Set(); - for (const item of arr2) { - if (item.alignment_index && Array.isArray(item.alignment_index)) { - for (const idx of item.alignment_index) { - alignmentIndicesSet.add(idx); - } - } - } - - // Filter arr1: keep only elements whose index exists in arr2's alignment_index - const filteredArr1 = arr1.filter((item) => - alignmentIndicesSet.has(item.index) - ); - - // Filter arr2: keep only elements whose alignment_index contains at least one index from arr1 - const filteredArr2 = arr2.filter((item) => { - if (!item.alignment_index || !Array.isArray(item.alignment_index)) { - return false; - } - return item.alignment_index.some((idx) => arr1IndicesSet.has(idx)); - }); - - return { - source: filteredArr1, - target: filteredArr2, - }; - } - const filtered = filterAlignedArrays( - createTargetAnnotation, - createAlignmentAnnotation - ); - createAnnotationMutation.mutate({ - inferenceId: targetInstanceId, - annotationData: { - type: "alignment", - target_manifestation_id: sourceInstanceId, - target_annotation: filtered.source, - alignment_annotation: filtered.target, + onError: (err) => { + const message = + err instanceof Error + ? err.message + : "Unable to save alignment. Please try again."; + setSaveSuccess(null); + setSaveError(message); + toast.error(message); }, - }); - } + } + ); }; - const isPublishing = - createAnnotationMutation.isPending || updateAnnotationMutation.isPending; - + const isPublishing = createAlignmentMutation.isPending; return ( <> @@ -292,14 +96,12 @@ const MappingSidebar = () => {
)} - {/* Success Message */} {saveSuccess && (

{saveSuccess}

)} - {/* Error Message */} {saveError && (

{saveError}

@@ -311,15 +113,6 @@ const MappingSidebar = () => { handleSave={handleSave} disabled={isPublishing} /> - - {/* Confirmation Modal */} - {showConfirmModal && ( - - )} ); }; @@ -345,91 +138,3 @@ function SaveButton({ ); } - -function ConfirmModal({ - handleConfirmSave, - setShowConfirmModal, - hasAlignment, -}: { - handleConfirmSave: () => void; - setShowConfirmModal: (show: boolean) => void; - hasAlignment: boolean; -}) { - return ( -
setShowConfirmModal(false)} - onKeyDown={(e) => { - if (e.key === "Escape") { - setShowConfirmModal(false); - } - }} - className="fixed inset-0 bg-black/40 bg-opacity-50 flex items-center justify-center z-50" - > -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - {/* Modal Header */} -
-

- {hasAlignment ? "Confirm Save" : "Confirm Publish"} -

-
- - {/* Modal Body */} -
-
-
- - - -
-
-

- ⚠️ Dangerous Operation -

-

- WARNING: This action will permanently modify - the alignment data and cannot be easily undone. Are you - absolutely certain you want to proceed with this operation? -

-
-
-
- - {/* Modal Footer */} -
- - -
-
-
- ); -} 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/SourceSelectionPanel.tsx b/frontend/src/components/Aligner/components/SourceSelectionPanel.tsx index 39863a27..b34185a2 100644 --- a/frontend/src/components/Aligner/components/SourceSelectionPanel.tsx +++ b/frontend/src/components/Aligner/components/SourceSelectionPanel.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useTexts, useInstance, useTextInstances } from '@/hooks/useTextData'; +import { useTexts, useEdition, useTextInstances } from '@/hooks/useTextData'; import { useTextSelectionStore } from '../../../stores/textSelectionStore'; import { PlusCircle, RotateCcw } from 'lucide-react'; import { CATALOGER_URL } from '@/config'; @@ -21,7 +21,7 @@ function SourceSelectionPanel() { // React Query hooks const { data: availableTexts = [], isLoading: isLoadingTexts, error: textsError } = useTexts(); const { data: instancesData, isLoading: isLoadingInstances, error: instancesError } = useTextInstances(selectedTextId); - const { isLoading: isLoadingInstance, error: instanceError } = useInstance(selectedInstanceId); + const { isLoading: isLoadingInstance, error: instanceError } = useEdition(selectedInstanceId); // Available instances for source editor const availableInstances = React.useMemo(() => { diff --git a/frontend/src/components/Aligner/components/TargetSelectionPanel.tsx b/frontend/src/components/Aligner/components/TargetSelectionPanel.tsx index 21120afe..1e18145f 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; @@ -115,7 +118,7 @@ function TargetSelectionPanel() { // React Query hooks - Disabled automatic instance fetching - // const { data: instanceData, isLoading: isLoadingInstance, error: instanceError } = useInstance(selectedInstanceId); + // const { data: instanceData, isLoading: isLoadingInstance, error: instanceError } = useEdition(selectedInstanceId); // Get related instances based on source selection const { data: relatedInstances = [], isLoading: isLoadingRelatedInstances, error: relatedInstancesError } = useRelatedInstances( @@ -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..e9ea7b83 100644 --- a/frontend/src/components/Aligner/components/UnifiedSelectionPanel.tsx +++ b/frontend/src/components/Aligner/components/UnifiedSelectionPanel.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from "react"; -import { useInstance, useTextInstances, useTextTitleSearch } from "../../../hooks/useTextData"; +import { useEdition, useTextInstances, useTextTitleSearch } from "../../../hooks/useTextData"; import { useTextSelectionStore } from "../../../stores/textSelectionStore"; import { useSearchParams } from "react-router-dom"; import { CATALOGER_URL } from "../../../config"; @@ -16,7 +16,7 @@ import { fetchAnnotation, fetchInstance, } from "../../../api/text"; -import { fetchRelatedInstances } from "../../../api/instances"; +import { editionIdFromRelated } from "@/utils/links"; import { applySegmentation, generateFileSegmentation, @@ -24,14 +24,12 @@ import { } from "../../../lib/annotation"; import { reconstructSegments } from "../utils/generateAnnotation"; import type { OpenPechaTextInstance } from "../../../types/text"; -import { prepareData } from "../utils/prepare_data"; function UnifiedSelectionPanel() { const { sourceInstanceId, sourceTextId, targetInstanceId, - isSourceLoaded, isTargetLoaded, setSourceSelection, isLoadingAnnotations, @@ -40,7 +38,6 @@ function UnifiedSelectionPanel() { setTargetText, setLoadingAnnotations, setAnnotationsApplied, - setHasAlignment, } = useTextSelectionStore(); const [searchParams, setSearchParams] = useSearchParams(); @@ -92,7 +89,7 @@ function UnifiedSelectionPanel() { data: instanceData, isLoading: isLoadingInstance, error: instanceError, - } = useInstance(selectedInstanceId); + } = useEdition(selectedInstanceId); // React Query hooks for target const { data: relatedInstances = [] } = useRelatedInstances(sourceInstanceId); @@ -194,7 +191,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 +424,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 +565,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 +618,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/useAlignments.ts b/frontend/src/components/Aligner/hooks/useAlignments.ts index d3a05934..0ba4eceb 100644 --- a/frontend/src/components/Aligner/hooks/useAlignments.ts +++ b/frontend/src/components/Aligner/hooks/useAlignments.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { fetchPreparedAlignmentData, type PreparedDataResponse } from '../../../api/annotation'; +import { fetchAlignment, type PreparedDataResponse } from '../../../api/annotation'; /** * Hook for fetching prepared alignment data for source and target instances @@ -18,7 +18,7 @@ export const usePreparedAlignmentData = ( if (!sourceInstanceId || !targetInstanceId) { throw new Error('Source and target instance IDs are required'); } - return fetchPreparedAlignmentData(sourceInstanceId, targetInstanceId); + return fetchAlignment(sourceInstanceId, targetInstanceId); }, enabled: Boolean(sourceInstanceId) && Boolean(targetInstanceId), staleTime: 10 * 60 * 1000, // 10 minutes 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..2fa029f0 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 @@ -115,9 +115,9 @@ export const useTextInstances = (textId: string | null) => { /** * Hook for fetching a specific instance */ -export const useInstance = (instanceId: string | null) => { +export const useEdition = (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 @@ -153,7 +153,7 @@ export const useTextFromInstance = ( instanceId: string | null, options: TextRetrievalOptions = {} ) => { - const { data: instanceData, isLoading: isLoadingInstance } = useInstance(instanceId); + const { data: instanceData, isLoading: isLoadingInstance } = useEdition(instanceId); // Get segmentation annotation ID from instance const segmentationAnnotationId = instanceData?.annotations && typeof instanceData.annotations === 'object' @@ -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/Aligner/utils/alignment_generator.ts b/frontend/src/components/Aligner/utils/alignment_generator.ts index 6f6adfe3..5d97dc25 100644 --- a/frontend/src/components/Aligner/utils/alignment_generator.ts +++ b/frontend/src/components/Aligner/utils/alignment_generator.ts @@ -81,7 +81,7 @@ function reverse_cleaned_alignments(cleanedAlignments: AlignmentData) { }; // Check if we have numeric string indices (e.g., "0", "1", "2") or UUIDs - const hasNumericIndices = cleaned_targets.length > 0 && + const hasNumericIndices = cleaned_targets?.length > 0 && cleaned_targets[0] !== null && cleaned_targets[0].index !== undefined && isNumericIndex(cleaned_targets[0].index); diff --git a/frontend/src/components/Aligner/utils/normalizeAlignments.ts b/frontend/src/components/Aligner/utils/normalizeAlignments.ts new file mode 100644 index 00000000..42b453db --- /dev/null +++ b/frontend/src/components/Aligner/utils/normalizeAlignments.ts @@ -0,0 +1,187 @@ +/** + * OpenPecha may return one alignment object or several (array or wrapped lists). + * Normalize to a list of { id, label, data } for UI selection and reconstruction. + */ + +export type AlignmentAnnotationPayload = { + target_annotation: unknown[] | null | undefined; + alignment_annotation: unknown[] | null | undefined; +}; + +export type NormalizedAlignmentVariant = { + id: string | null; + label: string; + data: AlignmentAnnotationPayload; +}; + +function isAlignmentShape(o: unknown): o is AlignmentAnnotationPayload { + if (!o || typeof o !== "object") return false; + const x = o as Record; + return Array.isArray(x.target_annotation) && Array.isArray(x.alignment_annotation); +} + +/** OpenPecha list API: segments with `lines` spans and optional `alignment_indices`. */ +type ApiLineSpan = { start?: number; end?: number }; + +function mergeLinesToSpan(lines: ApiLineSpan[] | undefined): { start: number; end: number } | null { + if (!lines?.length) return null; + let start = Number.POSITIVE_INFINITY; + let end = Number.NEGATIVE_INFINITY; + for (const l of lines) { + if (typeof l?.start !== "number" || typeof l?.end !== "number") continue; + start = Math.min(start, l.start); + end = Math.max(end, l.end); + } + if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end) return null; + return { start, end }; +} + +function isSegmentStyleAlignment(o: unknown): o is Record { + if (!o || typeof o !== "object") return false; + const x = o as Record; + return Array.isArray(x.target_segments) && Array.isArray(x.aligned_segments); +} + +/** + * Convert OpenPecha segment alignment (`target_segments` / `aligned_segments`) to the + * legacy shape expected by `reconstructSegments`: + * - `target_annotation[i]` spans **source** (aligned edition) text + * - `alignment_annotation[i]` spans **target** (root) text + */ +function segmentStyleToLegacyPayload(rec: Record): AlignmentAnnotationPayload | null { + const targetSegments = rec.target_segments as Array<{ lines?: ApiLineSpan[] }> | undefined; + const alignedSegments = rec.aligned_segments as Array<{ + lines?: ApiLineSpan[]; + alignment_indices?: number[]; + }> | undefined; + if (!Array.isArray(targetSegments) || !Array.isArray(alignedSegments)) return null; + + const target_annotation: unknown[] = []; + const alignment_annotation: unknown[] = []; + + const findAlignedSpanForIndex = (j: number): { start: number; end: number } | null => { + const spans: { start: number; end: number }[] = []; + for (const al of alignedSegments) { + const indices = Array.isArray(al.alignment_indices) ? al.alignment_indices : []; + if (!indices.includes(j)) continue; + const s = mergeLinesToSpan(al.lines); + if (s) spans.push(s); + } + if (spans.length === 0) return null; + return { + start: Math.min(...spans.map((s) => s.start)), + end: Math.max(...spans.map((s) => s.end)), + }; + }; + + for (let j = 0; j < targetSegments.length; j++) { + const targetSpan = mergeLinesToSpan(targetSegments[j]?.lines); + const alignedSpan = findAlignedSpanForIndex(j); + + if (!targetSpan && !alignedSpan) { + target_annotation.push(null); + alignment_annotation.push(null); + continue; + } + + const idxStr = String(j); + const rowId = `seg-${idxStr}`; + if (targetSpan && alignedSpan) { + target_annotation.push({ + index: idxStr, + span: alignedSpan, + id: rowId, + }); + alignment_annotation.push({ + index: idxStr, + span: targetSpan, + id: rowId, + }); + } else if (targetSpan && !alignedSpan) { + alignment_annotation.push({ + index: idxStr, + span: targetSpan, + id: rowId, + }); + target_annotation.push(null); + } else if (alignedSpan) { + target_annotation.push({ + index: idxStr, + span: alignedSpan, + id: rowId, + }); + alignment_annotation.push(null); + } + } + + return { + target_annotation, + alignment_annotation, + }; +} + +function extractAnnotationId(rec: Record): string | null { + if (typeof rec.id === "string") return rec.id; + if (typeof rec.annotation_id === "string") return rec.annotation_id; + return null; +} + +function variantFromObject(o: unknown, index: number): NormalizedAlignmentVariant | null { + const rec = typeof o === "object" && o !== null ? (o as Record) : null; + if (!rec) return null; + + const id = extractAnnotationId(rec); + const explicitLabel = + (typeof rec.name === "string" && rec.name.trim()) || + (typeof rec.title === "string" && rec.title.trim()) || + (typeof rec.label === "string" && rec.label.trim()) || + ""; + const label = explicitLabel || `Alignment ${index + 1}`; + + if (isAlignmentShape(o)) { + return { + id, + label, + data: { + target_annotation: rec.target_annotation as unknown[], + alignment_annotation: rec.alignment_annotation as unknown[], + }, + }; + } + + if (isSegmentStyleAlignment(rec)) { + const data = segmentStyleToLegacyPayload(rec); + if (!data) return null; + return { id, label, data }; + } + + return null; +} + +/** + * Extract zero or more alignment variants from API `annotation` field. + */ +export function normalizeAlignmentPayload(raw: unknown): NormalizedAlignmentVariant[] { + if (raw == null) return []; + + if (Array.isArray(raw)) { + return raw + .map((item, i) => variantFromObject(item, i)) + .filter((v): v is NormalizedAlignmentVariant => v !== null); + } + + if (typeof raw === "object") { + const o = raw as Record; + for (const key of ["alignments", "items", "data", "results"] as const) { + const inner = o[key]; + if (Array.isArray(inner)) { + const nested = normalizeAlignmentPayload(inner); + if (nested.length > 0) return nested; + } + } + const single = variantFromObject(raw, 0); + return single ? [single] : []; + } + + return []; +} diff --git a/frontend/src/components/Aligner/utils/prepare_data.ts b/frontend/src/components/Aligner/utils/prepare_data.ts index cfef0013..ae8748d6 100644 --- a/frontend/src/components/Aligner/utils/prepare_data.ts +++ b/frontend/src/components/Aligner/utils/prepare_data.ts @@ -1,23 +1,8 @@ // get alignment data if given s_id and t_id; -import { fetchPreparedAlignmentData } from "../../../api/annotation"; +import { fetchAlignment } from "../../../api/annotation"; import { populateMissingSpans, reverse_cleaned_alignments, addContentToAnnotations } from "./alignment_generator"; - -// Type for alignment annotation data structure -type AlignmentAnnotationData = { - type: "alignment"; - target_annotation: Array<{ - id?: string | null; - span: { start: number; end: number }; - index?: string; - } | null>; - alignment_annotation: Array<{ - id?: string | null; - span: { start: number; end: number }; - index?: string; - aligned_segments?: string[]; - } | null>; -}; +import { normalizeAlignmentPayload } from "./normalizeAlignments"; type ProgressCallback = (message: string) => void; @@ -27,32 +12,52 @@ async function prepareData( onProgress?: ProgressCallback ) { onProgress?.("Fetching source instance data..."); - const preparedData = await fetchPreparedAlignmentData(sourceInstanceId, targetInstanceId); + const preparedData = await fetchAlignment(sourceInstanceId, targetInstanceId); const targetText = preparedData.target_text; const sourceText = preparedData.source_text; const annotation = preparedData.annotation; const has_alignment = preparedData.has_alignment; - const annotation_id = preparedData.annotation_id; if(has_alignment){ - // Type assertion for annotation.data - it should be AlignmentAnnotationData - - + const variants = normalizeAlignmentPayload(annotation); + const primary = + variants[0]?.data ?? + (annotation && + typeof annotation === "object" && + !Array.isArray(annotation) && + Array.isArray((annotation as { target_annotation?: unknown }).target_annotation) && + Array.isArray((annotation as { alignment_annotation?: unknown }).alignment_annotation) + ? { + target_annotation: (annotation as { target_annotation: unknown[] }).target_annotation, + alignment_annotation: (annotation as { alignment_annotation: unknown[] }) + .alignment_annotation, + } + : null); + if (!primary) { + onProgress?.("Skipping alignment reconstruction (unrecognized shape)..."); + } + onProgress?.("Reconstructing alignment segments..."); - const reconstructed_annoations=reverse_cleaned_alignments(annotation as Parameters[0]); - //get th text for each alignment segment from content - - onProgress?.("Populating missing spans..."); - const populated_annoations=populateMissingSpans(reconstructed_annoations); - - onProgress?.("Adding content to annotations..."); - // Add content to each annotation based on spans - const annotationsWithContent = addContentToAnnotations( - populated_annoations, - sourceText, - targetText - ); - + const reconstructed_annoations = primary + ? reverse_cleaned_alignments({ + type: "alignment", + target_annotation: primary.target_annotation as Parameters< + typeof reverse_cleaned_alignments + >[0]["target_annotation"], + alignment_annotation: primary.alignment_annotation as Parameters< + typeof reverse_cleaned_alignments + >[0]["alignment_annotation"], + }) + : null; + + if (reconstructed_annoations) { + onProgress?.("Populating missing spans..."); + const populated_annoations = populateMissingSpans(reconstructed_annoations); + + onProgress?.("Adding content to annotations..."); + addContentToAnnotations(populated_annoations, sourceText, targetText); + } + onProgress?.("Applying annotations to text..."); // Extract annotation ID from preparedData response or annotation object if it exists 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..6846c5ee 100644 --- a/frontend/src/components/InstanceCard.tsx +++ b/frontend/src/components/InstanceCard.tsx @@ -1,6 +1,6 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import type { OpenPechaTextInstance, SegmentationAnnotation } from '@/types/text'; -import { useAnnnotation, useText } from '@/hooks/useTexts'; +import { useAnnnotation, useEditionSegmentations, useText } from '@/hooks/useTexts'; import { Button } from './ui/button'; import FormattedTextDisplay from './FormattedTextDisplay'; import { useTranslation } from 'react-i18next'; @@ -15,10 +15,27 @@ interface InstanceCardProps { instance: OpenPechaTextInstance; } +/** Build display lines from saved segmentation blocks (character spans in edition content). */ +function linesFromSavedSegmentation( + content: string, + segments: Array<{ lines?: Array<{ start: number; end: number }> }>, +): string[] { + const lines: string[] = []; + const n = content.length; + for (const seg of segments) { + for (const ln of seg.lines || []) { + const start = Math.max(0, Math.min(ln.start, n)); + const end = Math.max(start, Math.min(ln.end, n)); + if (start < end) lines.push(content.slice(start, end)); + } + } + return lines; +} + 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"; @@ -34,6 +51,14 @@ const InstanceCard: React.FC = ({ instance }) => { error: annotationError } = useAnnnotation(segmentationAnnotationId); const {data:text} = useText(text_id || ''); + const { data: segmentationVersions = [], isLoading: segmentationListLoading } = + useEditionSegmentations(edition_id || ''); + const [pickedSegmentationId, setPickedSegmentationId] = useState(''); + + useEffect(() => { + setPickedSegmentationId(''); + }, [edition_id]); + const title_text = text?.title?.tib || text?.title?.bo || text?.title?.en || text?.title?.sa || text?.title?.pi || t('instance.content'); const toggleAnnotation = (annotationType: string) => { setExpandedAnnotations(prev => @@ -69,7 +94,25 @@ const InstanceCard: React.FC = ({ instance }) => { return lines.join('\n'); }; - const contentForView=getFormattedContent().split('\n'); + + const contentForView = (() => { + if (!instance.content) return []; + if (pickedSegmentationId) { + const item = segmentationVersions.find((x) => x.id === pickedSegmentationId); + if (item?.segments?.length) { + const fromList = linesFromSavedSegmentation(instance.content, item.segments); + if (fromList.length > 0) return fromList; + } + } + return getFormattedContent().split('\n'); + })(); + + let defaultSegmentationOptionLabel = 'Default (annotation)'; + if (segmentationListLoading) { + defaultSegmentationOptionLabel = 'Loading…'; + } else if (segmentationVersions.length === 0) { + defaultSegmentationOptionLabel = 'None available'; + } const renderAnnotationContent = (annotationType: string, annotations: unknown[]) => { if (annotationType === 'segmentation') { return annotations.map((annotation) => { @@ -140,7 +183,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 +269,7 @@ const InstanceCard: React.FC = ({ instance }) => { )} {/* Error State for Annotation */} - {segmentationAnnotationId && annotationError && ( + {!pickedSegmentationId && segmentationAnnotationId && annotationError && (
@@ -206,8 +282,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/components/InstanceCreationForm.tsx b/frontend/src/components/InstanceCreationForm.tsx index 53cf2680..c5dc600e 100644 --- a/frontend/src/components/InstanceCreationForm.tsx +++ b/frontend/src/components/InstanceCreationForm.tsx @@ -10,35 +10,74 @@ import FormsubmitSection from "./FormsubmitSection"; import SourceSelection from "./formComponent/SourceSelection"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; +import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; -interface InstanceData { +/** OpenPecha edition metadata `type` (v2). */ +export type EditionMetadataType = "diplomatic" | "critical" | "collated"; + +function parseEditionType(value: unknown): EditionMetadataType { + if (value === "diplomatic" || value === "critical" || value === "collated") { + return value; + } + return "critical"; +} + +export interface InstanceData { metadata: { - type: string; - source: string; - colophon?: string; - incipit_title?: Record; - alt_incipit_titles?: Record[]; bdrc?: string; - wiki?: string | null; + wiki?: string; + type: EditionMetadataType; + source?: string; + colophon?: string; + incipit_title?: { + [key: string]: string; + }; + alt_incipit_titles?: Array<{ + [key: string]: string; + }>; }; - annotation?: Array<{ - span: { start: number; end: number }; - reference?: string; - }>; + // (Optional) Pagination details of the instance, if applicable. + pagination?: { + volumes: Array<{ + index: number; + pages: Array<{ + lines: Array<{ + start: number; + end: number; + }>; + reference: string; + }>; + metadata: Record; + }>; + metadata: Record; + }; + segmentation: { + segments: Array<{ + lines: Array<{ + start: number; + end: number; + }>; + }>; + metadata: Record; + }; + content: string; +} + +/** OpenPecha post-create annotation bundle (not part of the edition body schema). */ +export type InstanceCreatePayload = InstanceData & { biblography_annotation?: Array<{ span: { start: number; end: number }; type: string; }>; - content?: string; -} +}; interface InstanceCreationFormProps { - onSubmit: (instanceData: InstanceData) => void; + onSubmit: (instanceData: InstanceCreatePayload) => void; isSubmitting: boolean; onCancel?: () => void; content?: string; // Content from editor for annotation calculation disableSubmit?: boolean; // Additional condition to disable submit button - instance?:InstanceData|null; + instance?: InstanceData | null; } export interface InstanceCreationFormRef { @@ -46,7 +85,7 @@ export interface InstanceCreationFormRef { addIncipit: (text: string, language?: string) => void; addAltIncipit: (text: string, language?: string) => void; hasIncipit: () => boolean; - getFormData: () => InstanceData | null; + getFormData: () => InstanceCreatePayload | null; initializeForm?: (data: { type?: string; source?: string; @@ -82,8 +121,8 @@ const InstanceCreationForm = forwardRef< const { t } = useTranslation(); // State declarations - const [type, setType] = useState<"diplomatic" | "critical">( - "critical" + const [type, setType] = useState(() => + parseEditionType(instance?.metadata?.type) ); const [source, setSource] = useState(instance?.metadata?.source ?? ""); const [colophon, setColophon] = useState(instance?.metadata?.colophon ?? ""); @@ -111,9 +150,9 @@ const InstanceCreationForm = forwardRef< // Bibliography annotations hook const { getAPIAnnotations, hasAnnotations } = useBibliographyAPI(); - // Clear BDRC ID when switching to critical type + // BDRC instance id is only for diplomatic editions useEffect(() => { - if (type === "critical" && bdrc) { + if (type !== "diplomatic" && bdrc) { setBdrc(""); setSelectedBdrc(null); setBdrcSearch(""); @@ -224,7 +263,7 @@ const InstanceCreationForm = forwardRef< wiki?: string; colophon?: string; }) => { - if (data.type) setType(data.type as "diplomatic" | "critical"); + if (data.type) setType(parseEditionType(data.type)); if (data.source) setSource(data.source); if (data.bdrc) { setBdrc(data.bdrc); @@ -319,15 +358,29 @@ const InstanceCreationForm = forwardRef< }; // Data cleaning function - const cleanFormData = (): InstanceData => { - const cleaned: InstanceData = { + const cleanFormData = (): InstanceCreatePayload => { + const { annotations, cleanedContent } = content + ? calculateAnnotations(content) + : { annotations: [], cleanedContent: "" }; + + const lineSpans = annotations.map((a) => ({ + start: a.span.start, + end: a.span.end, + })); + + const pageReference = type === "diplomatic" ? "temp" : ""; + + const cleaned: InstanceCreatePayload = { metadata: { - type: type, - source: source.trim(), + type, + }, + segmentation: { + segments: lineSpans.map((span) => ({ lines: [span] })), + metadata: {}, }, + content: cleanedContent, }; - // Add optional metadata fields only if non-empty if (bdrc?.trim()) { cleaned.metadata.bdrc = bdrc.trim(); } @@ -340,7 +393,6 @@ const InstanceCreationForm = forwardRef< cleaned.metadata.colophon = colophon.trim(); } - // Build incipit_title only if has non-empty values const incipitTitle: Record = {}; incipitTitles.forEach(({ language, value }) => { if (language && value.trim()) { @@ -351,7 +403,6 @@ const InstanceCreationForm = forwardRef< cleaned.metadata.incipit_title = incipitTitle; } - // Build alt_incipit_titles only if incipit_title exists if (cleaned.metadata.incipit_title && altIncipitTitles.length > 0) { const altTitles = altIncipitTitles .map((titleGroup) => { @@ -370,24 +421,6 @@ const InstanceCreationForm = forwardRef< } } - // Add content and calculate annotations - if (content) { - const { annotations, cleanedContent } = calculateAnnotations(content); - cleaned.content = cleanedContent; // Content without newlines - - // Add reference field for diplomatic type only - if (type === "diplomatic") { - cleaned.annotation = annotations.map(ann => ({ - ...ann, - reference: "temp" - })); - } else { - // Critical type - no reference field - cleaned.annotation = annotations; - } - } - - // Add bibliography annotations if they exist if (hasAnnotations()) { cleaned.biblography_annotation = getAPIAnnotations(); } @@ -425,7 +458,7 @@ const InstanceCreationForm = forwardRef< } // Validate BDRC ID for diplomatic type - if (type === "diplomatic" && !bdrc?.trim()) { + if (type === "diplomatic" && !selectedBdrc?.id) { setErrors({ bdrc: t("instance.bdrcIdRequired") }); return; } @@ -523,7 +556,39 @@ const InstanceCreationForm = forwardRef< {/* Metadata Section */}
- + {/* Edition type */} +
+ + setType(parseEditionType(v))} + className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:gap-6" + > +
+ + +
+
+ + +
+
+ + +
+
+ {errors.type && ( +

{errors.type}

+ )} +
{/* Source */}
@@ -604,7 +669,7 @@ const InstanceCreationForm = forwardRef< type="button" onClick={() => { setSelectedBdrc({ - id: result.instanceId ?? "", + id: result.workId ?? "", label: result.title ?? "", }); setBdrc(result.instanceId ?? ""); diff --git a/frontend/src/components/MultilevelCategorySelector.tsx b/frontend/src/components/MultilevelCategorySelector.tsx index a30b5d95..9a582f1f 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) { @@ -66,18 +79,17 @@ export const MultilevelCategorySelector: React.FC
- + {!filterMode && } {/* Breadcrumb Navigation */} {navigationPath.length > 0 && ( @@ -106,9 +118,9 @@ export const MultilevelCategorySelector: React.FC +
{isLoading && (
@@ -129,7 +141,9 @@ export const MultilevelCategorySelector: React.FC {categories.map((category) => { return ( - +
+ +
); })}
diff --git a/frontend/src/components/TextCard.tsx b/frontend/src/components/TextCard.tsx index 3b56bad5..81e0eddb 100644 --- a/frontend/src/components/TextCard.tsx +++ b/frontend/src/components/TextCard.tsx @@ -10,49 +10,41 @@ import { useNavigate } from "react-router-dom"; import { Button } from "./ui/button"; import { usePermission } from "@/hooks/usePermission"; import PermissionButton from "./PermissionButton"; +import { alignmentLink } from "@/utils/links"; interface TextCardProps { title: string; language: string; type: string; - isAnnotationAvailable?: boolean; instanceId: string; sourceInstanceId: string; - relationship: string; } const TextCard = ({ title, language, type, - isAnnotationAvailable, instanceId, sourceInstanceId, - relationship, }: TextCardProps) => { const { data: permission,isFetching:isFetchingPermission } = usePermission(); const isAdmin=permission?.role === "admin"; const navigate = useNavigate(); - const getAlignmentInfo = (source: string, target: string) => { - const alignmentInfo = { - source: source, - target: target, - } - return alignmentInfo; - } + const navigateToAlignment = (e) => { e.preventDefault() e.stopPropagation() const source=sourceInstanceId const target= instanceId - const alignmentInfo=getAlignmentInfo(source,target); - if(relationship === "translation" || relationship === "commentary"){ - navigate(`/align/${alignmentInfo.source}/${alignmentInfo.target}`) + let url; + if(type == "source"){ + url= alignmentLink(target,source) }else{ - navigate(`/align/${alignmentInfo.target}/${alignmentInfo.source}`) + url= alignmentLink(source,target) } + navigate(url) } @@ -69,12 +61,7 @@ const TextCard = ({ }} className=" flex gap-2 font-monlam group-hover:text-blue-500 transition-smooth"> {title} - {/* Annotation Status Column */} - {isAnnotationAvailable ? ( - - ) : ( - - )} +
@@ -101,13 +88,12 @@ const TextCard = ({ diff --git a/frontend/src/components/TextCreationForm.tsx b/frontend/src/components/TextCreationForm.tsx index fc3d187c..1a56fba3 100644 --- a/frontend/src/components/TextCreationForm.tsx +++ b/frontend/src/components/TextCreationForm.tsx @@ -7,7 +7,13 @@ import { useRef, } from "react"; import type { Person } from "@/types/person"; -import type { OpenPechaText ,Title as TitleType} from "@/types/text"; +import { + coerceLicense, + type CreateTextPayload, + type LicenseType, + type OpenPechaText, + type Title as TitleType, +} from "@/types/text"; import { MultilevelCategorySelector } from "@/components/MultilevelCategorySelector"; import { useTranslation } from "react-i18next"; import LanguageSelectorForm from "./formComponent/LanguageSelectorForm"; @@ -60,7 +66,7 @@ const TextCreationForm = forwardRef( const { t } = useTranslation(); // State declarations const [selectedType, setSelectedType] = useState< - "root" | "translation" | "" + "root" | "translation" | "commentary" | "" >("root"); const [titles, setTitles] = useState([]); const [altTitles, setAltTitles] = useState([]); @@ -75,7 +81,7 @@ const TextCreationForm = forwardRef( const [categoryId, setCategoryId] = useState(""); const [categoryError, setCategoryError] = useState(false); const [copyright, setCopyright] = useState("Unknown"); - const [license, setLicense] = useState("Unknown"); + const [license, setLicense] = useState("public"); // Contributor management const [contributors, setContributors] = useState([]); @@ -140,47 +146,38 @@ const TextCreationForm = forwardRef( }) .filter((alt) => Object.keys(alt).length > 0); - // Build final payload - const textData: any = { - type: selectedType, + // Build final payload (POST /text — matches cataloger CreateText; no extra keys for OpenPecha) + const textData: CreateTextPayload = { title, language: language.trim(), - alt_titles: altTitlesArray, + category_id: categoryId.trim(), + license: copyright === "Unknown" ? "unknown" : license, }; - // Only add contributions if there are any - if (contributors.length > 0) { - textData.contributions = contributionsArray; + if (altTitlesArray.length > 0) { + textData.alt_titles = altTitlesArray; } - // Add target field for translation and commentary types - if (selectedType === "translation" || selectedType === "commentary") { - textData.target = target.trim() || "N/A"; + if (contributors.length > 0) { + textData.contributions = contributionsArray; } - // Add optional fields - if (date.trim()) textData.date = date.trim(); - if (bdrc.trim()) textData.bdrc = bdrc.trim(); - - // Add required category - if (categoryId && categoryId.trim()) { - textData.category_id = categoryId.trim(); + if (selectedType === "translation") { + const t = target.trim(); + if (t && t !== "N/A") { + textData.translation_of = t; + } } - // Add copyright (optional field) - if (copyright && copyright.trim()) { - textData.copyright = copyright.trim(); + if (selectedType === "commentary") { + const c = target.trim(); + if (c && c !== "N/A") { + textData.commentary_of = c; + } } - // Add license (if copyright is Unknown, send "unknown", otherwise use selected license or default to "CC0") - if (copyright === "Unknown") { - textData.license = "unknown"; - } else if (license && license.trim()) { - textData.license = license.trim(); - } else { - // If license is empty, send "CC0" as default - textData.license = "CC0"; - } + if (date.trim()) textData.date = date.trim(); + if (bdrc.trim()) textData.bdrc = bdrc.trim(); return textData; }, [ @@ -314,14 +311,13 @@ const TextCreationForm = forwardRef( if (data.date) setDate(data.date); if (data.categoryId) setCategoryId(data.categoryId); if (data.copyright) setCopyright(data.copyright); - if (data.license) setLicense(data.license); + if (data.license) setLicense(coerceLicense(data.license)); if (data.bdrc) { bdrcWorkRef.current?.setBdrcId(data.bdrc, data.bdrc); } if (data.target) setTarget(data.target); }, }), [language, titles, altTitles, contributors]); - return (
{/* Type and Language */} @@ -364,7 +360,7 @@ const TextCreationForm = forwardRef(
{/* Target field - only for commentary/translation */} - {selectedType === "translation"&& ( + {(selectedType === "translation" || selectedType === "commentary") && (
+
+ { + const leaf = path.at(-1); + setParam({ + ...param, + categoryId, + categoryTitle: leaf?.title ?? categoryId, + }); + }} + /> +
+ {/* Active Filters Display */} {hasActiveFilters && (
@@ -165,13 +186,14 @@ function TextFilter({ param, setParam, clearSearch }: PropTextFilter) { )} - {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} @@ -180,7 +202,7 @@ function TextFilter({ param, setParam, clearSearch }: PropTextFilter) {
)}
- ) + ); } export default TextFilter diff --git a/frontend/src/components/TextInstanceCard.tsx b/frontend/src/components/TextInstanceCard.tsx index 0aae76ea..35ae7ca5 100644 --- a/frontend/src/components/TextInstanceCard.tsx +++ b/frontend/src/components/TextInstanceCard.tsx @@ -1,15 +1,36 @@ import React from 'react'; import type { OpenPechaTextInstanceListItem } from '@/types/text'; import { Link, useParams } from 'react-router-dom'; +import { editionLink } from '@/utils/links'; +import { useDeleteEdition } from '@/hooks/useTexts'; +import { toast } from 'sonner'; interface TextInstanceCardProps { instance: OpenPechaTextInstanceListItem; } const TextInstanceCard: React.FC = ({ instance }) => { + const { text_id } = useParams(); + const deleteMutation = useDeleteEdition(); - - const {text_id}=useParams(); + const handleDelete = () => { + if (!text_id) return; + if ( + !globalThis.confirm( + 'Delete this edition? This cannot be undone.' + ) + ) { + return; + } + deleteMutation.mutate( + { editionId: instance.id, textId: text_id }, + { + onSuccess: () => toast.success('Edition deleted'), + onError: (err) => + toast.error(err instanceof Error ? err.message : 'Failed to delete edition'), + } + ); + }; const getTypeColor = (type: string) => { if (!type) return 'bg-gray-100 text-gray-800 border-gray-200'; @@ -51,23 +72,51 @@ const TextInstanceCard: React.FC = ({ instance }) => { : "Text Instance"; } return ( - - {/* Header */} -
+
+
-

- {title} - -

-
-
- - {instance.type} - + +

+ {title} +

+
+ + {instance.type} + +
+
+ + + + + Add translation + + + + + + Add commentary + +
- - +
); }; 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..0224889d 100644 --- a/frontend/src/components/TextListCard.tsx +++ b/frontend/src/components/TextListCard.tsx @@ -1,17 +1,13 @@ 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'; +import { textEditionsLink } from '@/utils/links'; interface TextListCardProps { text: OpenPechaText; @@ -34,30 +30,12 @@ const TextListCard = ({ text }: TextListCardProps) => { if (title) return title; 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'; - }; + function getType(){ + if(text.commentaries.length>0|| text.translations.length>0) return "source" + if(text.translation_of) return "translation" + if(text.commentary_of) return "commentary" + return "" + } return ( @@ -66,7 +44,7 @@ const TextListCard = ({ text }: TextListCardProps) => { @@ -87,19 +65,6 @@ const TextListCard = ({ text }: TextListCardProps) => { )} - - - {/* Type Column */} - - - {getTypeLabel(text.type) && ( - - - {getTypeLabel(text.type)} - - )} - - {/* Language Column */} {text.language && ( @@ -109,7 +74,9 @@ const TextListCard = ({ text }: TextListCardProps) => { )} - + + + {/* Contributors Column */} {text.contributions && text.contributions.length > 0 ? ( @@ -177,12 +144,25 @@ const TextListCard = ({ text }: TextListCardProps) => { )} - ) : ( - 0 - )} + ) : null} ); }; export default TextListCard; + +function TypeBadge({ type }: { type: string }) { + + const typeLabel={ + "translation": {label: "Translation", color: "#3B82F6"}, + "commentary": {label: "Commentary", color: "#10B981"}, + "source": {label: "Source", color: "#6B7280"}, + } + if(type==='') return null + return ( + + {typeLabel[type].label} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/formComponent/Contributor.tsx b/frontend/src/components/formComponent/Contributor.tsx index 3a1028bd..dbb2a22a 100644 --- a/frontend/src/components/formComponent/Contributor.tsx +++ b/frontend/src/components/formComponent/Contributor.tsx @@ -4,7 +4,6 @@ import { Label } from '../ui/label'; import { useTranslation } from 'react-i18next'; import { Plus, X, User } from 'lucide-react'; import type { Person } from '@/types/person'; -import { useBdrcSearch } from '@/hooks/useBdrcSearch'; import { usePersons } from '@/hooks/usePersons'; import { useThrottledValue } from '@tanstack/react-pacer'; import RoleSelectionForm from './RoleSelectionForm'; @@ -43,19 +42,12 @@ const Contributor: React.FC = ({ // Person creation modal const [showPersonFormModal, setShowPersonFormModal] = useState(false); - // BDRC search hook for persons const [debouncedPersonSearch] = useThrottledValue(personSearch, { wait: 1000 }); - const { results: bdrcPersonResults, isLoading: bdrcPersonLoading } = useBdrcSearch( - debouncedPersonSearch, - "Person", - 1000 - ); - - const { isLoading: personsLoading } = usePersons({ + const { data: PersonResults, isLoading: PersonLoading } = usePersons({ limit: 100, offset: 0, + name: debouncedPersonSearch, }); - const getPersonDisplayName = useCallback((person: Person): string => { if (!person?.name || typeof person.name !== "object") { return person?.id || t("textForm.unknown"); @@ -121,6 +113,12 @@ const Contributor: React.FC = ({
@@ -330,6 +307,6 @@ const BdrcPersonList = memo(({ bdrcPersonResults, handlePersonSelect }: BdrcPers ); }); -BdrcPersonList.displayName = "BdrcPersonList"; +PersonList.displayName = "PersonList"; export default Contributor; diff --git a/frontend/src/components/formComponent/Copyright.tsx b/frontend/src/components/formComponent/Copyright.tsx index d8a61672..c6d8e286 100644 --- a/frontend/src/components/formComponent/Copyright.tsx +++ b/frontend/src/components/formComponent/Copyright.tsx @@ -8,12 +8,13 @@ import { } from "@/components/ui/select" import { Label } from '../ui/label'; import { useEffect } from 'react'; +import type { LicenseType } from '@/types/text'; interface CopyrightProps { readonly copyright: string; readonly setCopyright: (copyright: string) => void; - readonly license: string; - readonly setLicense: (license: string) => void; + readonly license: LicenseType; + readonly setLicense: (license: LicenseType) => void; readonly copyrightLabelKey?: string; readonly licenseLabelKey?: string; readonly required?: boolean; @@ -32,20 +33,18 @@ function Copyright({ }: CopyrightProps) { const { t } = useTranslation(); - // Auto-set license to "unknown" when copyright is "Unknown" + // Auto-set license when copyright category changes (slugs match API LicenseType) useEffect(() => { if (copyright === "Unknown") { setLicense("unknown"); + } else if (copyright === "Public domain") { + setLicense("public"); + } else if (copyright === "In copyright") { + setLicense("copyrighted"); } - if (copyright === "Public domain") { - setLicense("Public Domain Mark"); - } - if (copyright === "In copyright") { - setLicense("under copyright"); - } - }, [copyright]); + }, [copyright, setLicense]); - const isLicenseDisabled = copyright === "Unknown" || copyright === "Public domain"; + const isLicenseDisabled = copyright === "Unknown"; return (
@@ -79,7 +78,7 @@ function Copyright({
diff --git a/frontend/src/components/textCreation/TextCreation.tsx b/frontend/src/components/textCreation/TextCreation.tsx index 9fb5f584..8325bb6c 100644 --- a/frontend/src/components/textCreation/TextCreation.tsx +++ b/frontend/src/components/textCreation/TextCreation.tsx @@ -27,10 +27,26 @@ import { cn } from "@/lib/utils"; interface SuccessResult { message: string; textId: string; - instanceId: string; + editionId: string; } -const TextCreation = () => { +/** When set, skip catalog text search and run “new text + edition” under a parent text route. */ +export type TextCreationEmbeddedConfig = { + forceNewText: true; + createType: "edition" | "translation" | "commentary"; + /** + * Route `text_id`. + * - `edition`: add an instance to this existing text (no new text). + * - `translation` / `commentary`: new text with `translation_of` / `commentary_of` set to this id. + */ + parentTextId: string; +}; + +export interface TextCreationProps { + embedded?: TextCreationEmbeddedConfig; +} + +const TextCreation = ({ embedded }: TextCreationProps) => { const navigate = useNavigate(); const { user } = useAuth0(); const { t } = useTranslation(); @@ -76,8 +92,14 @@ const TextCreation = () => { const w_id = searchParams.get("w_id") || ""; const i_id = searchParams.get("i_id") || ""; + const isEmbeddedEdition = + !!embedded?.forceNewText && embedded.createType === "edition"; + // — Workflow & selection — - const [isCreatingNewText, setIsCreatingNewText] = useState(false); + /** Translation/commentary embedded flows create a new text; edition adds an instance to the URL text only. */ + const [isCreatingNewText, setIsCreatingNewText] = useState( + () => !!(embedded?.forceNewText && embedded.createType !== "edition") + ); const [selectedText, setSelectedText] = useState(null); const [textSearch, setTextSearch] = useState(t_id); const [showTextDropdown, setShowTextDropdown] = useState(false); @@ -109,6 +131,12 @@ const TextCreation = () => { // Only fetch texts when t_id is present in URL (for auto-selection) // Use useText to fetch only the specific text needed instead of all texts const { data: textFromUrl, isLoading: isLoadingTextFromUrl } = useText(t_id || ''); + const embeddedEditionParentId = isEmbeddedEdition ? embedded!.parentTextId : ""; + const { + data: embeddedParentText, + isLoading: isLoadingEmbeddedParent, + isError: isEmbeddedParentError, + } = useText(embeddedEditionParentId); const createTextMutation = useCreateText(); const createInstanceMutation = useCreateTextInstance(); @@ -143,6 +171,27 @@ const TextCreation = () => { }, [t_id, textFromUrl, selectedText]); // Auto-select text when t_id is provided in URL + // Embedded /texts/:id/create?type=edition — bind existing text, no new text record + useEffect(() => { + if (!isEmbeddedEdition) return; + if (!embeddedParentText) return; + setSelectedText(embeddedParentText); + setTextSearch( + embeddedParentText.title.bo || + embeddedParentText.title.en || + Object.values(embeddedParentText.title)[0] || + "Untitled" + ); + setIsCreatingNewText(false); + }, [isEmbeddedEdition, embeddedParentText]); + + useEffect(() => { + if (!isEmbeddedEdition) return; + if (embeddedParentText?.language) { + setSelectedLanguage(embeddedParentText.language); + } + }, [isEmbeddedEdition, embeddedParentText]); + // Helper function to get text display name const getTextDisplayName = (text: OpenPechaText): string => { return ( @@ -196,6 +245,29 @@ const TextCreation = () => { return () => clearTimeout(t); }, [isCreatingNewText]); + // Prefill text form when opened from /texts/:text_id/create?type=translation|commentary + useEffect(() => { + if (!embedded?.forceNewText) return; + if (embedded.createType === "edition") return; + const timer = setTimeout(() => { + if (!textFormRef.current) return; + const { createType, parentTextId } = embedded; + const linkTarget = parentTextId.trim(); + if (createType === "translation") { + textFormRef.current.initializeForm?.({ + type: "translation", + target: linkTarget, + }); + } else if (createType === "commentary") { + textFormRef.current.initializeForm?.({ + type: "commentary", + target: linkTarget, + }); + } + }, 150); + return () => clearTimeout(timer); + }, [embedded]); + // Handle BDRC workId from URL when page loads (e.g. /create?w_id=W12345) useEffect(() => { const handleBdrcWorkFromUrl = async () => { @@ -250,11 +322,15 @@ const TextCreation = () => { // Clear all state setSelectedText(null); setTextSearch(""); - setIsCreatingNewText(false); + setIsCreatingNewText( + !!(embedded?.forceNewText && embedded.createType !== "edition") + ); clearFileUpload(); clearAnnotations(); // Clear URL params - clearUrlParams(); + if (!embedded?.forceNewText) { + clearUrlParams(); + } }; const handleTextSearchChange = (e: React.ChangeEvent) => { @@ -290,6 +366,9 @@ const TextCreation = () => { // Handle when existing text is found from TextCreationForm BDRC selection const handleExistingTextFoundFromForm = (text: OpenPechaText) => { + if (embedded?.forceNewText) { + return; + } // Switch from creating new text to using existing text setSelectedText(text); setTextSearch(getTextDisplayName(text)); @@ -405,7 +484,7 @@ const TextCreation = () => { ? t("create.textAndInstanceCreated") : t("create.instanceCreated"), textId, - instanceId, + editionId: instanceId, }); setSelectedText(null); setTextSearch(""); @@ -421,7 +500,7 @@ const TextCreation = () => { const handleSuccessModalClose = useCallback(() => { if (successResult) { - navigate(`/texts/${successResult.textId}/instances/${successResult.instanceId}`); + navigate(`/texts/${successResult.textId}/editions/${successResult.editionId}`); } setSuccessResult(null); }, [successResult, navigate]); @@ -510,6 +589,10 @@ const TextCreation = () => { // Show loading screen when auto-selecting text from URL const isAutoSelecting = t_id && (isLoadingTextFromUrl || (textFromUrl && !selectedText)); + const isEmbeddedEditionLoading = + isEmbeddedEdition && + (isLoadingEmbeddedParent || (!embeddedParentText && !isEmbeddedParentError)); + return ( @@ -665,7 +748,7 @@ const TextCreation = () => { )} {/* Beautiful Loading Screen - Show while auto-selecting text from URL */} - {isAutoSelecting && ( + {(isAutoSelecting || isEmbeddedEditionLoading) && (
{/* Animated Logo/Icon */} @@ -775,8 +858,27 @@ const TextCreation = () => {
)} - {/* Single Full-Page Message when text exists in cataloger */} - {selectedText && !isCreatingNewText ? ( + {isEmbeddedEdition && + isEmbeddedParentError && + !isLoadingEmbeddedParent && ( +
+
+

{t("messages.createError")}

+ +
+
+ )} + + {/* Single Full-Page Message when text exists in cataloger (not when adding edition to URL text) */} + {selectedText && !isCreatingNewText && !isEmbeddedEdition ? (
@@ -870,7 +972,7 @@ const TextCreation = () => {
{/* Search Input - Only show when no text is selected and not creating new */} - {!selectedText && !isCreatingNewText && ( + {!embedded?.forceNewText && !selectedText && !isCreatingNewText && (